feat: rebuild AI commands and migrate config to TOML
This commit is contained in:
555
app/config.py
555
app/config.py
@@ -1,15 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import tomllib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
except ModuleNotFoundError: # pragma: no cover - optional dependency fallback
|
||||
@@ -36,71 +33,6 @@ class AccountConfig:
|
||||
hide_password: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AIProviderConfig:
|
||||
name: str
|
||||
api_key: str
|
||||
base_url: str
|
||||
timeout: float | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AIModelConfig:
|
||||
alias: str
|
||||
provider: str
|
||||
model: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AIPromptConfig:
|
||||
root: Path
|
||||
system_file: str = "system.md"
|
||||
parts_dir: str = "parts"
|
||||
user_template: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AIWebSearchConfig:
|
||||
enabled: bool = True
|
||||
api_key: str | None = None
|
||||
endpoint: str = "https://api.search.brave.com/res/v1/web/search"
|
||||
count: int = 5
|
||||
country: str = "US"
|
||||
search_lang: str = "en"
|
||||
ui_lang: str = "en-US"
|
||||
safesearch: str = "moderate"
|
||||
timeout: float = 10.0
|
||||
extra_snippets: bool = False
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self.enabled and bool(self.api_key)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AIPageExtractConfig:
|
||||
enabled: bool = True
|
||||
timeout: float = 15.0
|
||||
max_chars: int = 12000
|
||||
render_dynamic: bool = True
|
||||
dynamic_wait_ms: int = 1500
|
||||
block_private_hosts: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AIConfig:
|
||||
default_model: str
|
||||
context_messages: int
|
||||
prompt: AIPromptConfig
|
||||
providers: dict[str, AIProviderConfig] = field(default_factory=dict)
|
||||
models: dict[str, AIModelConfig] = field(default_factory=dict)
|
||||
workspace_dir: Path = field(default_factory=lambda: Path("workspace"))
|
||||
tool_max_rounds: int = 10
|
||||
web_search: AIWebSearchConfig = field(default_factory=AIWebSearchConfig)
|
||||
page_extract: AIPageExtractConfig = field(default_factory=AIPageExtractConfig)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GitHubWebhookConfig:
|
||||
enabled: bool = False
|
||||
@@ -109,47 +41,58 @@ class GitHubWebhookConfig:
|
||||
secret: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ElevenLabsConfig:
|
||||
api_key: str | None = None
|
||||
model_id: str = "scribe_v2"
|
||||
language_code: str | None = None
|
||||
request_timeout_seconds: float = 300.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AiProviderConfig:
|
||||
api_url: str
|
||||
api_key: str | None = None
|
||||
model: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AiConfig:
|
||||
glm: AiProviderConfig = field(
|
||||
default_factory=lambda: AiProviderConfig(
|
||||
api_url="https://api.z.ai/api/paas/v4",
|
||||
model="glm-5.3",
|
||||
)
|
||||
)
|
||||
deepseek: AiProviderConfig = field(
|
||||
default_factory=lambda: AiProviderConfig(
|
||||
api_url="https://api.deepseek.com",
|
||||
model="deepseek-v4-flash",
|
||||
)
|
||||
)
|
||||
prompt_dir: Path = Path("prompts")
|
||||
base_dir: Path = Path(".")
|
||||
max_context_messages: int = 100
|
||||
max_media_bytes: int = 5 * 1024 * 1024
|
||||
max_tool_rounds: int = 16
|
||||
brave_api_key: str | None = None
|
||||
brave_timeout_seconds: float = 10.0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppConfig:
|
||||
api_id: int | None
|
||||
api_hash: str | None
|
||||
session_dir: Path
|
||||
accounts: list[AccountConfig]
|
||||
ai: AIConfig | None = None
|
||||
github_webhook: GitHubWebhookConfig = field(default_factory=GitHubWebhookConfig)
|
||||
elevenlabs: ElevenLabsConfig = field(default_factory=ElevenLabsConfig)
|
||||
ai: AiConfig = field(default_factory=AiConfig)
|
||||
|
||||
|
||||
def _read_json_file(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _write_json_file(path: Path, data: dict[str, Any]) -> None:
|
||||
serialized = json.dumps(data, ensure_ascii=False, indent=2)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
delete=False,
|
||||
dir=path.parent,
|
||||
prefix=path.name,
|
||||
suffix=".tmp",
|
||||
) as handle:
|
||||
handle.write(serialized)
|
||||
temp_path = Path(handle.name)
|
||||
temp_path.replace(path)
|
||||
|
||||
|
||||
def _merge_example_defaults(target: dict[str, Any], source: dict[str, Any], path: str = "") -> list[str]:
|
||||
"""Add scalar keys present in source but absent in target; recurse into existing dicts."""
|
||||
added: list[str] = []
|
||||
for key, value in source.items():
|
||||
full_path = f"{path}.{key}" if path else key
|
||||
if key not in target:
|
||||
if not isinstance(value, (dict, list)):
|
||||
target[key] = value
|
||||
added.append(full_path)
|
||||
elif isinstance(target[key], dict) and isinstance(value, dict):
|
||||
added.extend(_merge_example_defaults(target[key], value, full_path))
|
||||
return added
|
||||
def _read_toml_file(path: Path) -> dict[str, Any]:
|
||||
with path.open("rb") as handle:
|
||||
return tomllib.load(handle)
|
||||
|
||||
|
||||
def _parse_int(value: Any, *, field_name: str) -> int:
|
||||
@@ -159,13 +102,6 @@ def _parse_int(value: Any, *, field_name: str) -> int:
|
||||
raise ValueError(f"{field_name} must be an integer") from exc
|
||||
|
||||
|
||||
def _parse_float(value: Any, *, field_name: str) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{field_name} must be a number") from exc
|
||||
|
||||
|
||||
def _parse_bool(value: Any, *, field_name: str) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
@@ -180,6 +116,13 @@ def _parse_bool(value: Any, *, field_name: str) -> bool:
|
||||
raise ValueError(f"{field_name} must be a boolean")
|
||||
|
||||
|
||||
def _parse_float(value: Any, *, field_name: str) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{field_name} must be a number") from exc
|
||||
|
||||
|
||||
def _parse_mapping(value: Any, *, field_name: str) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
@@ -188,125 +131,6 @@ def _parse_mapping(value: Any, *, field_name: str) -> dict[str, Any]:
|
||||
return value
|
||||
|
||||
|
||||
def _normalize_brave_api_key(value: Any) -> str | None:
|
||||
api_key = str(value or "").strip()
|
||||
if api_key.lower().startswith("bearer "):
|
||||
api_key = api_key[7:].strip()
|
||||
return api_key or None
|
||||
|
||||
|
||||
def _parse_web_search_config(raw_ai: dict[str, Any]) -> AIWebSearchConfig:
|
||||
raw = _parse_mapping(raw_ai.get("web_search"), field_name="ai.web_search")
|
||||
|
||||
enabled = _parse_bool(raw.get("enabled", True), field_name="ai.web_search.enabled")
|
||||
api_key = _normalize_brave_api_key(raw.get("api_key"))
|
||||
endpoint = str(
|
||||
raw.get("endpoint") or "https://api.search.brave.com/res/v1/web/search"
|
||||
).strip()
|
||||
if not endpoint:
|
||||
raise ValueError("ai.web_search.endpoint is required")
|
||||
|
||||
count = _parse_int(raw.get("count", 5), field_name="ai.web_search.count")
|
||||
count = min(max(count, 1), 20)
|
||||
|
||||
country = str(raw.get("country", "US") or "US").strip().upper()
|
||||
search_lang = str(raw.get("search_lang", "en") or "en").strip().lower()
|
||||
ui_lang = str(raw.get("ui_lang", "en-US") or "en-US").strip()
|
||||
safesearch = str(raw.get("safesearch", "moderate") or "moderate").strip().lower()
|
||||
if safesearch not in {"off", "moderate", "strict"}:
|
||||
raise ValueError("ai.web_search.safesearch must be one of: off, moderate, strict")
|
||||
|
||||
timeout = _parse_float(raw.get("timeout", 10.0), field_name="ai.web_search.timeout")
|
||||
if timeout <= 0:
|
||||
raise ValueError("ai.web_search.timeout must be > 0")
|
||||
|
||||
extra_snippets = _parse_bool(
|
||||
raw.get("extra_snippets", False),
|
||||
field_name="ai.web_search.extra_snippets",
|
||||
)
|
||||
|
||||
env_search_api_key = os.environ.get("BRAVE_SEARCH_API_KEY")
|
||||
if env_search_api_key is not None:
|
||||
api_key = _normalize_brave_api_key(env_search_api_key)
|
||||
env_api_key = os.environ.get("BRAVE_API_KEY")
|
||||
if env_api_key is not None:
|
||||
api_key = _normalize_brave_api_key(env_api_key)
|
||||
if os.environ.get("BRAVE_SEARCH_COUNT") is not None:
|
||||
count = min(
|
||||
max(_parse_int(os.environ["BRAVE_SEARCH_COUNT"], field_name="BRAVE_SEARCH_COUNT"), 1),
|
||||
20,
|
||||
)
|
||||
if os.environ.get("BRAVE_SEARCH_COUNTRY") is not None:
|
||||
country = os.environ["BRAVE_SEARCH_COUNTRY"].strip().upper() or country
|
||||
if os.environ.get("BRAVE_SEARCH_LANG") is not None:
|
||||
search_lang = os.environ["BRAVE_SEARCH_LANG"].strip().lower() or search_lang
|
||||
if os.environ.get("BRAVE_SEARCH_UI_LANG") is not None:
|
||||
ui_lang = os.environ["BRAVE_SEARCH_UI_LANG"].strip() or ui_lang
|
||||
if os.environ.get("BRAVE_SEARCH_SAFESEARCH") is not None:
|
||||
safesearch = os.environ["BRAVE_SEARCH_SAFESEARCH"].strip().lower()
|
||||
if safesearch not in {"off", "moderate", "strict"}:
|
||||
raise ValueError("BRAVE_SEARCH_SAFESEARCH must be one of: off, moderate, strict")
|
||||
|
||||
return AIWebSearchConfig(
|
||||
enabled=enabled,
|
||||
api_key=api_key,
|
||||
endpoint=endpoint,
|
||||
count=count,
|
||||
country=country,
|
||||
search_lang=search_lang,
|
||||
ui_lang=ui_lang,
|
||||
safesearch=safesearch,
|
||||
timeout=timeout,
|
||||
extra_snippets=extra_snippets,
|
||||
)
|
||||
|
||||
|
||||
def _parse_page_extract_config(raw_ai: dict[str, Any]) -> AIPageExtractConfig:
|
||||
raw = _parse_mapping(raw_ai.get("page_extract"), field_name="ai.page_extract")
|
||||
|
||||
enabled = _parse_bool(raw.get("enabled", True), field_name="ai.page_extract.enabled")
|
||||
timeout = _parse_float(raw.get("timeout", 15.0), field_name="ai.page_extract.timeout")
|
||||
if timeout <= 0:
|
||||
raise ValueError("ai.page_extract.timeout must be > 0")
|
||||
|
||||
max_chars = _parse_int(raw.get("max_chars", 12000), field_name="ai.page_extract.max_chars")
|
||||
if max_chars < 1000:
|
||||
raise ValueError("ai.page_extract.max_chars must be >= 1000")
|
||||
|
||||
render_dynamic = _parse_bool(
|
||||
raw.get("render_dynamic", True),
|
||||
field_name="ai.page_extract.render_dynamic",
|
||||
)
|
||||
dynamic_wait_ms = _parse_int(
|
||||
raw.get("dynamic_wait_ms", 1500),
|
||||
field_name="ai.page_extract.dynamic_wait_ms",
|
||||
)
|
||||
dynamic_wait_ms = min(max(dynamic_wait_ms, 0), 10000)
|
||||
block_private_hosts = _parse_bool(
|
||||
raw.get("block_private_hosts", True),
|
||||
field_name="ai.page_extract.block_private_hosts",
|
||||
)
|
||||
|
||||
if os.environ.get("PAGE_EXTRACT_MAX_CHARS") is not None:
|
||||
max_chars = _parse_int(os.environ["PAGE_EXTRACT_MAX_CHARS"], field_name="PAGE_EXTRACT_MAX_CHARS")
|
||||
if max_chars < 1000:
|
||||
raise ValueError("PAGE_EXTRACT_MAX_CHARS must be >= 1000")
|
||||
if os.environ.get("PAGE_EXTRACT_RENDER_DYNAMIC") is not None:
|
||||
render_dynamic = _parse_bool(
|
||||
os.environ["PAGE_EXTRACT_RENDER_DYNAMIC"],
|
||||
field_name="PAGE_EXTRACT_RENDER_DYNAMIC",
|
||||
)
|
||||
|
||||
return AIPageExtractConfig(
|
||||
enabled=enabled,
|
||||
timeout=timeout,
|
||||
max_chars=max_chars,
|
||||
render_dynamic=render_dynamic,
|
||||
dynamic_wait_ms=dynamic_wait_ms,
|
||||
block_private_hosts=block_private_hosts,
|
||||
)
|
||||
|
||||
|
||||
def _parse_github_webhook_config(raw: Any) -> GitHubWebhookConfig:
|
||||
raw_config = _parse_mapping(raw, field_name="github_webhook")
|
||||
|
||||
@@ -353,6 +177,153 @@ def _parse_github_webhook_config(raw: Any) -> GitHubWebhookConfig:
|
||||
)
|
||||
|
||||
|
||||
def _parse_elevenlabs_config(raw: Any) -> ElevenLabsConfig:
|
||||
raw_config = _parse_mapping(raw, field_name="elevenlabs")
|
||||
|
||||
raw_api_key = raw_config.get("api_key")
|
||||
api_key = str(raw_api_key).strip() if raw_api_key is not None else None
|
||||
api_key = api_key or None
|
||||
model_id = str(raw_config.get("model_id", "scribe_v2") or "scribe_v2").strip()
|
||||
raw_language = raw_config.get("language_code")
|
||||
language_code = str(raw_language).strip() if raw_language is not None else None
|
||||
language_code = language_code or None
|
||||
request_timeout_seconds = _parse_float(
|
||||
raw_config.get("request_timeout_seconds", 300),
|
||||
field_name="elevenlabs.request_timeout_seconds",
|
||||
)
|
||||
|
||||
if os.environ.get("ELEVENLABS_API_KEY") is not None:
|
||||
api_key = os.environ["ELEVENLABS_API_KEY"].strip() or None
|
||||
if os.environ.get("ELEVENLABS_MODEL_ID") is not None:
|
||||
model_id = os.environ["ELEVENLABS_MODEL_ID"].strip()
|
||||
if os.environ.get("ELEVENLABS_LANGUAGE_CODE") is not None:
|
||||
language_code = os.environ["ELEVENLABS_LANGUAGE_CODE"].strip() or None
|
||||
if os.environ.get("ELEVENLABS_REQUEST_TIMEOUT_SECONDS") is not None:
|
||||
request_timeout_seconds = _parse_float(
|
||||
os.environ["ELEVENLABS_REQUEST_TIMEOUT_SECONDS"],
|
||||
field_name="ELEVENLABS_REQUEST_TIMEOUT_SECONDS",
|
||||
)
|
||||
|
||||
if not model_id:
|
||||
raise ValueError("elevenlabs.model_id is required")
|
||||
if request_timeout_seconds <= 0:
|
||||
raise ValueError("elevenlabs.request_timeout_seconds must be greater than zero")
|
||||
|
||||
return ElevenLabsConfig(
|
||||
api_key=api_key,
|
||||
model_id=model_id,
|
||||
language_code=language_code,
|
||||
request_timeout_seconds=request_timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
def _parse_ai_config(raw: Any) -> AiConfig:
|
||||
raw_config = _parse_mapping(raw, field_name="ai")
|
||||
|
||||
def parse_provider(
|
||||
name: str,
|
||||
*,
|
||||
default_url: str,
|
||||
default_model: str,
|
||||
env_prefix: str,
|
||||
) -> AiProviderConfig:
|
||||
provider = _parse_mapping(raw_config.get(name), field_name=f"ai.{name}")
|
||||
api_url = str(provider.get("api_url", default_url)).strip()
|
||||
raw_api_key = provider.get("api_key")
|
||||
api_key = str(raw_api_key).strip() if raw_api_key is not None else None
|
||||
api_key = api_key or None
|
||||
model = str(provider.get("model", default_model)).strip()
|
||||
|
||||
if os.environ.get(f"{env_prefix}_API_URL") is not None:
|
||||
api_url = os.environ[f"{env_prefix}_API_URL"].strip()
|
||||
if os.environ.get(f"{env_prefix}_API_KEY") is not None:
|
||||
api_key = os.environ[f"{env_prefix}_API_KEY"].strip() or None
|
||||
if os.environ.get(f"{env_prefix}_MODEL") is not None:
|
||||
model = os.environ[f"{env_prefix}_MODEL"].strip()
|
||||
|
||||
if not api_url:
|
||||
raise ValueError(f"ai.{name}.api_url is required")
|
||||
if not model:
|
||||
raise ValueError(f"ai.{name}.model is required")
|
||||
return AiProviderConfig(api_url=api_url, api_key=api_key, model=model)
|
||||
|
||||
glm = parse_provider(
|
||||
"glm",
|
||||
default_url="https://api.z.ai/api/paas/v4",
|
||||
default_model="glm-5.3",
|
||||
env_prefix="GLM",
|
||||
)
|
||||
deepseek = parse_provider(
|
||||
"deepseek",
|
||||
default_url="https://api.deepseek.com",
|
||||
default_model="deepseek-v4-flash",
|
||||
env_prefix="DEEPSEEK",
|
||||
)
|
||||
max_context_messages = _parse_int(
|
||||
raw_config.get("max_context_messages", 100),
|
||||
field_name="ai.max_context_messages",
|
||||
)
|
||||
max_media_bytes = _parse_int(
|
||||
raw_config.get("max_media_bytes", 5 * 1024 * 1024),
|
||||
field_name="ai.max_media_bytes",
|
||||
)
|
||||
max_tool_rounds = _parse_int(
|
||||
raw_config.get("max_tool_rounds", 16),
|
||||
field_name="ai.max_tool_rounds",
|
||||
)
|
||||
brave_timeout_seconds = _parse_float(
|
||||
raw_config.get("brave_timeout_seconds", 10),
|
||||
field_name="ai.brave_timeout_seconds",
|
||||
)
|
||||
|
||||
raw_brave_key = raw_config.get("brave_api_key")
|
||||
brave_api_key = str(raw_brave_key).strip() if raw_brave_key is not None else None
|
||||
brave_api_key = brave_api_key or None
|
||||
|
||||
if os.environ.get("BRAVE_SEARCH_API_KEY") is not None:
|
||||
brave_api_key = os.environ["BRAVE_SEARCH_API_KEY"].strip() or None
|
||||
|
||||
if os.environ.get("AI_MAX_CONTEXT_MESSAGES") is not None:
|
||||
max_context_messages = _parse_int(
|
||||
os.environ["AI_MAX_CONTEXT_MESSAGES"],
|
||||
field_name="AI_MAX_CONTEXT_MESSAGES",
|
||||
)
|
||||
if os.environ.get("AI_MAX_MEDIA_BYTES") is not None:
|
||||
max_media_bytes = _parse_int(
|
||||
os.environ["AI_MAX_MEDIA_BYTES"],
|
||||
field_name="AI_MAX_MEDIA_BYTES",
|
||||
)
|
||||
if os.environ.get("AI_MAX_TOOL_ROUNDS") is not None:
|
||||
max_tool_rounds = _parse_int(
|
||||
os.environ["AI_MAX_TOOL_ROUNDS"],
|
||||
field_name="AI_MAX_TOOL_ROUNDS",
|
||||
)
|
||||
if os.environ.get("BRAVE_SEARCH_TIMEOUT_SECONDS") is not None:
|
||||
brave_timeout_seconds = _parse_float(
|
||||
os.environ["BRAVE_SEARCH_TIMEOUT_SECONDS"],
|
||||
field_name="BRAVE_SEARCH_TIMEOUT_SECONDS",
|
||||
)
|
||||
|
||||
if max_context_messages < 0:
|
||||
raise ValueError("ai.max_context_messages must not be negative")
|
||||
if max_media_bytes < 1:
|
||||
raise ValueError("ai.max_media_bytes must be greater than zero")
|
||||
if max_tool_rounds < 1:
|
||||
raise ValueError("ai.max_tool_rounds must be greater than zero")
|
||||
if brave_timeout_seconds <= 0:
|
||||
raise ValueError("ai.brave_timeout_seconds must be greater than zero")
|
||||
|
||||
return AiConfig(
|
||||
glm=glm,
|
||||
deepseek=deepseek,
|
||||
max_context_messages=max_context_messages,
|
||||
max_media_bytes=max_media_bytes,
|
||||
max_tool_rounds=max_tool_rounds,
|
||||
brave_api_key=brave_api_key,
|
||||
brave_timeout_seconds=brave_timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
def _parse_accounts(raw_accounts: Any, *, default_api_id: int | None, default_api_hash: str | None, default_session_dir: Path) -> list[AccountConfig]:
|
||||
if raw_accounts is None:
|
||||
return []
|
||||
@@ -418,109 +389,6 @@ def _parse_accounts(raw_accounts: Any, *, default_api_id: int | None, default_ap
|
||||
return accounts
|
||||
|
||||
|
||||
def _parse_ai_config(raw_ai: Any, *, root: Path) -> AIConfig | None:
|
||||
if raw_ai is None:
|
||||
return None
|
||||
if not isinstance(raw_ai, dict):
|
||||
raise ValueError("ai must be an object")
|
||||
|
||||
prompt_raw = _parse_mapping(raw_ai.get("prompt"), field_name="ai.prompt")
|
||||
prompt_root = Path(str(prompt_raw.get("root", "prompts/ai"))).expanduser()
|
||||
if not prompt_root.is_absolute():
|
||||
prompt_root = (root / prompt_root).resolve()
|
||||
raw_user_template = prompt_raw.get("user_template")
|
||||
user_template = str(raw_user_template).strip() if raw_user_template else None
|
||||
prompt = AIPromptConfig(
|
||||
root=prompt_root,
|
||||
system_file=str(prompt_raw.get("system_file", "system.md")),
|
||||
parts_dir=str(prompt_raw.get("parts_dir", "parts")),
|
||||
user_template=user_template or None,
|
||||
)
|
||||
|
||||
default_model = str(raw_ai.get("default_model", "")).strip()
|
||||
if not default_model:
|
||||
raise ValueError("ai.default_model is required")
|
||||
|
||||
context_messages = _parse_int(raw_ai.get("context_messages", 12), field_name="ai.context_messages")
|
||||
if context_messages < 0:
|
||||
raise ValueError("ai.context_messages must be >= 0")
|
||||
|
||||
providers_raw = _parse_mapping(raw_ai.get("providers"), field_name="ai.providers")
|
||||
models_raw = _parse_mapping(raw_ai.get("models"), field_name="ai.models")
|
||||
|
||||
providers: dict[str, AIProviderConfig] = {}
|
||||
for provider_name, provider_config in providers_raw.items():
|
||||
if not isinstance(provider_config, dict):
|
||||
raise ValueError(f"ai.providers.{provider_name} must be an object")
|
||||
api_key = str(provider_config.get("api_key", "")).strip()
|
||||
base_url = str(provider_config.get("base_url", "")).strip()
|
||||
if not api_key:
|
||||
raise ValueError(f"ai.providers.{provider_name}.api_key is required")
|
||||
if not base_url:
|
||||
raise ValueError(f"ai.providers.{provider_name}.base_url is required")
|
||||
timeout_value = provider_config.get("timeout")
|
||||
timeout = None
|
||||
if timeout_value is not None:
|
||||
timeout = _parse_float(timeout_value, field_name=f"ai.providers.{provider_name}.timeout")
|
||||
providers[provider_name] = AIProviderConfig(
|
||||
name=provider_name,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
models: dict[str, AIModelConfig] = {}
|
||||
for alias, model_config in models_raw.items():
|
||||
if not isinstance(model_config, dict):
|
||||
raise ValueError(f"ai.models.{alias} must be an object")
|
||||
provider = str(model_config.get("provider", "")).strip()
|
||||
model = str(model_config.get("model", "")).strip()
|
||||
if not provider:
|
||||
raise ValueError(f"ai.models.{alias}.provider is required")
|
||||
if not model:
|
||||
raise ValueError(f"ai.models.{alias}.model is required")
|
||||
description = model_config.get("description")
|
||||
models[alias] = AIModelConfig(
|
||||
alias=alias,
|
||||
provider=provider,
|
||||
model=model,
|
||||
description=str(description).strip() if description is not None else None,
|
||||
)
|
||||
|
||||
if default_model not in models:
|
||||
raise ValueError("ai.default_model must reference one of ai.models")
|
||||
|
||||
for alias, model_config in models.items():
|
||||
if model_config.provider not in providers:
|
||||
raise ValueError(
|
||||
f"ai.models.{alias}.provider references unknown provider: {model_config.provider}"
|
||||
)
|
||||
|
||||
workspace_dir_raw = raw_ai.get("workspace_dir", "workspace")
|
||||
workspace_dir = Path(str(workspace_dir_raw)).expanduser()
|
||||
if not workspace_dir.is_absolute():
|
||||
workspace_dir = (root / workspace_dir).resolve()
|
||||
|
||||
tool_max_rounds = _parse_int(raw_ai.get("tool_max_rounds", 10), field_name="ai.tool_max_rounds")
|
||||
if tool_max_rounds < 1:
|
||||
raise ValueError("ai.tool_max_rounds must be >= 1")
|
||||
|
||||
web_search = _parse_web_search_config(raw_ai)
|
||||
page_extract = _parse_page_extract_config(raw_ai)
|
||||
|
||||
return AIConfig(
|
||||
default_model=default_model,
|
||||
context_messages=context_messages,
|
||||
prompt=prompt,
|
||||
providers=providers,
|
||||
models=models,
|
||||
workspace_dir=workspace_dir,
|
||||
tool_max_rounds=tool_max_rounds,
|
||||
web_search=web_search,
|
||||
page_extract=page_extract,
|
||||
)
|
||||
|
||||
|
||||
def _accounts_from_env() -> list[dict[str, Any]] | None:
|
||||
raw_accounts = os.environ.get("ACCOUNTS_JSON")
|
||||
if raw_accounts:
|
||||
@@ -554,26 +422,15 @@ def load_app_config(root: Path) -> AppConfig:
|
||||
if not candidate.is_absolute():
|
||||
candidate = (root / candidate).resolve()
|
||||
else:
|
||||
candidate = root / "config.json"
|
||||
candidate = root / "config.toml"
|
||||
|
||||
raw: dict[str, Any] = {}
|
||||
if candidate.exists():
|
||||
raw = _read_json_file(candidate)
|
||||
example_path = root / "config.example.json"
|
||||
if example_path.exists():
|
||||
try:
|
||||
example_raw = _read_json_file(example_path)
|
||||
added = _merge_example_defaults(raw, example_raw)
|
||||
if added:
|
||||
_write_json_file(candidate, raw)
|
||||
log.info("Auto-updated %s with new fields: %s", candidate.name, ", ".join(added))
|
||||
except Exception as exc:
|
||||
log.warning("Could not auto-update config from example: %s", exc)
|
||||
raw = _read_toml_file(candidate)
|
||||
|
||||
env_api_id = os.environ.get("TG_API_ID")
|
||||
env_api_hash = os.environ.get("TG_API_HASH")
|
||||
env_session_dir = os.environ.get("SESSION_DIR")
|
||||
env_ai_config = os.environ.get("AI_CONFIG")
|
||||
|
||||
if env_api_id is not None:
|
||||
raw["api_id"] = env_api_id
|
||||
@@ -581,8 +438,6 @@ def load_app_config(root: Path) -> AppConfig:
|
||||
raw["api_hash"] = env_api_hash
|
||||
if env_session_dir is not None:
|
||||
raw["session_dir"] = env_session_dir
|
||||
if env_ai_config:
|
||||
raw["ai"] = json.loads(env_ai_config)
|
||||
|
||||
env_accounts = _accounts_from_env()
|
||||
if env_accounts is not None:
|
||||
@@ -590,7 +445,7 @@ def load_app_config(root: Path) -> AppConfig:
|
||||
|
||||
if "accounts" not in raw:
|
||||
raise ValueError(
|
||||
"Missing accounts configuration. Provide accounts in config.json or set ACCOUNTS_JSON / ACCOUNT_SESSIONS."
|
||||
"Missing accounts configuration. Provide accounts in config.toml or set ACCOUNTS_JSON / ACCOUNT_SESSIONS."
|
||||
)
|
||||
|
||||
api_id = _parse_int(raw["api_id"], field_name="api_id") if "api_id" in raw else None
|
||||
@@ -606,8 +461,11 @@ def load_app_config(root: Path) -> AppConfig:
|
||||
default_session_dir=session_dir,
|
||||
)
|
||||
|
||||
ai = _parse_ai_config(raw.get("ai"), root=root)
|
||||
github_webhook = _parse_github_webhook_config(raw.get("github_webhook"))
|
||||
elevenlabs = _parse_elevenlabs_config(raw.get("elevenlabs"))
|
||||
ai = _parse_ai_config(raw.get("ai"))
|
||||
ai.prompt_dir = root / "prompts"
|
||||
ai.base_dir = root
|
||||
|
||||
if not accounts:
|
||||
raise ValueError("At least one account must be configured")
|
||||
@@ -627,6 +485,7 @@ def load_app_config(root: Path) -> AppConfig:
|
||||
api_hash=api_hash,
|
||||
session_dir=session_dir,
|
||||
accounts=accounts,
|
||||
ai=ai,
|
||||
github_webhook=github_webhook,
|
||||
elevenlabs=elevenlabs,
|
||||
ai=ai,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user