Add dual-prefix AI command
This commit is contained in:
140
app/config.py
140
app/config.py
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -28,12 +28,45 @@ 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"
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppConfig:
|
||||
api_id: int | None
|
||||
api_hash: str | None
|
||||
session_dir: Path
|
||||
accounts: list[AccountConfig]
|
||||
ai: AIConfig | None = None
|
||||
|
||||
|
||||
def _read_json_file(path: Path) -> dict[str, Any]:
|
||||
@@ -47,6 +80,21 @@ 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_mapping(value: Any, *, field_name: str) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError(f"{field_name} must be an object")
|
||||
return value
|
||||
|
||||
|
||||
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 []
|
||||
@@ -112,6 +160,90 @@ 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()
|
||||
prompt = AIPromptConfig(
|
||||
root=prompt_root,
|
||||
system_file=str(prompt_raw.get("system_file", "system.md")),
|
||||
parts_dir=str(prompt_raw.get("parts_dir", "parts")),
|
||||
)
|
||||
|
||||
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}"
|
||||
)
|
||||
|
||||
return AIConfig(
|
||||
default_model=default_model,
|
||||
context_messages=context_messages,
|
||||
prompt=prompt,
|
||||
providers=providers,
|
||||
models=models,
|
||||
)
|
||||
|
||||
|
||||
def _accounts_from_env() -> list[dict[str, Any]] | None:
|
||||
raw_accounts = os.environ.get("ACCOUNTS_JSON")
|
||||
if raw_accounts:
|
||||
@@ -154,6 +286,7 @@ def load_app_config(root: Path) -> AppConfig:
|
||||
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
|
||||
@@ -161,6 +294,8 @@ 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:
|
||||
@@ -184,6 +319,8 @@ def load_app_config(root: Path) -> AppConfig:
|
||||
default_session_dir=session_dir,
|
||||
)
|
||||
|
||||
ai = _parse_ai_config(raw.get("ai"), root=root)
|
||||
|
||||
if not accounts:
|
||||
raise ValueError("At least one account must be configured")
|
||||
|
||||
@@ -202,4 +339,5 @@ def load_app_config(root: Path) -> AppConfig:
|
||||
api_hash=api_hash,
|
||||
session_dir=session_dir,
|
||||
accounts=accounts,
|
||||
ai=ai,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user