Files
cdsl-cad/backend/app/settings.py
T
2026-09-04 11:17:36 +08:00

225 lines
9.5 KiB
Python

from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
BACKEND_ROOT = Path(__file__).resolve().parents[1]
PROJECT_ROOT = BACKEND_ROOT.parent
load_dotenv(BACKEND_ROOT / ".env")
@dataclass(frozen=True)
class ProviderModel:
id: str
vision: bool = False
@dataclass(frozen=True)
class ProviderConfig:
id: str
label: str
base_url: str
api_key: str
models: tuple[ProviderModel, ...]
# Chat Completions uses the legacy flat ``reasoning_effort`` parameter.
# Keep it provider-scoped because compatibility varies by endpoint/model.
reasoning_effort: str = ""
api_style: str = "chat_completions"
@property
def configured(self) -> bool:
return bool(self.base_url and self.api_key and self.models)
def model(self, model_id: str) -> ProviderModel | None:
return next((model for model in self.models if model.id == model_id), None)
@property
def chat_completion_options(self) -> dict[str, str]:
if self.reasoning_effort:
return {"reasoning_effort": self.reasoning_effort}
return {}
@property
def request_options(self) -> dict[str, object]:
if not self.reasoning_effort:
return {}
if self.api_style == "responses":
return {"reasoning": {"effort": self.reasoning_effort}}
return {"reasoning_effort": self.reasoning_effort}
@dataclass(frozen=True)
class Settings:
task_root: Path
conversation_root: Path
library_root: Path
engine_root: Path
llm_base_url: str
llm_api_key: str
llm_model: str
llm_timeout_s: float
default_provider_id: str
providers: tuple[ProviderConfig, ...]
review_provider_id: str = ""
review_model_id: str = ""
agent_tool_calls_per_cycle: int = 12
agent_consecutive_no_progress_limit: int = 6
agent_format_error_repeat_limit: int = 3
agent_context_char_limit: int = 14000
agent_author_guidance_enabled: bool = True
agent_author_guidance_max_chars: int = 3600
agent_render_cache: bool = True
autonomous_generation: bool = True
resume_running_tasks_on_startup: bool = True
@property
def llm_configured(self) -> bool:
return any(provider.configured for provider in self.providers)
def provider_for(self, provider_id: str | None) -> ProviderConfig | None:
requested = str(provider_id or self.default_provider_id).strip().lower()
return next((provider for provider in self.providers if provider.id == requested and provider.configured), None)
def resolve_model(self, provider_id: str | None, model_id: str | None) -> tuple[ProviderConfig, ProviderModel]:
provider = self.provider_for(provider_id)
if provider is None:
raise ValueError("The selected model provider is not configured")
# A caller that supplies neither value is asking for the configured
# application default, not the first model listed by that provider.
# The latter made CDSL_DEFAULT_MODEL ineffective and silently routed
# new runs to an unintended author model.
selected = str(model_id or "").strip()
if not selected and not str(provider_id or "").strip():
selected = self.llm_model
selected = selected or provider.models[0].id
model = provider.model(selected)
if model is None:
raise ValueError("The selected model is not enabled for this provider")
return provider, model
def resolve_review_model(self) -> tuple[ProviderConfig, ProviderModel]:
"""Return the independently configured visual reviewer, never an author fallback."""
provider_id = self.review_provider_id
if not provider_id:
raise ValueError("CDSL_REVIEW_PROVIDER must identify a configured vision provider")
provider = self.provider_for(provider_id)
if provider is None:
raise ValueError("The configured visual review provider is unavailable")
model_id = self.review_model_id or ""
if not model_id:
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision model")
model = provider.model(model_id)
if model is None or not model.vision:
raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision-capable model")
return provider, model
def resolve_independent_review_model(self, author_provider: ProviderConfig, author_model: ProviderModel) -> tuple[ProviderConfig, ProviderModel]:
"""Require the candidate judge to be a separately configured model."""
provider, model = self.resolve_review_model()
if provider.id == author_provider.id and model.id == author_model.id:
raise ValueError("CDSL_REVIEW_PROVIDER/CDSL_REVIEW_MODEL must differ from the autonomous author model")
return provider, model
def _reasoning_effort(value: str) -> str:
effort = value.strip().lower()
allowed = {"none", "minimal", "low", "medium", "high", "xhigh", "max"}
if effort and effort not in allowed:
raise ValueError(
"CDSL_*_REASONING_EFFORT must be one of "
f"{', '.join(sorted(allowed))}"
)
return effort
def _api_style(value: str) -> str:
style = value.strip().lower() or "chat_completions"
if style not in {"chat_completions", "responses"}:
raise ValueError("CDSL_*_API_STYLE must be 'chat_completions' or 'responses'")
return style
def _env_flag(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None or not value.strip():
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
def _models(
value: str,
vision_value: str = "",
) -> tuple[ProviderModel, ...]:
vision_ids = {item.strip() for item in vision_value.split(",") if item.strip()}
return tuple(
ProviderModel(
id=item,
vision=item in vision_ids,
)
for item in (part.strip() for part in value.split(","))
if item
)
def _provider(prefix: str, provider_id: str, label: str, default_base_url: str, default_model: str = "") -> ProviderConfig:
# The legacy CDSL_LLM_* variables remain the DeepSeek default so existing
# local installations continue to work without copying secrets.
legacy = provider_id == "deepseek"
base_url = os.getenv(f"CDSL_{prefix}_BASE_URL", os.getenv("CDSL_LLM_BASE_URL", default_base_url) if legacy else default_base_url).rstrip("/")
api_key = os.getenv(f"CDSL_{prefix}_API_KEY", os.getenv("CDSL_LLM_API_KEY", "") if legacy else "")
model_list = os.getenv(f"CDSL_{prefix}_MODELS", os.getenv("CDSL_LLM_MODEL", default_model) if legacy else default_model)
vision_models = os.getenv(f"CDSL_{prefix}_VISION_MODELS", "")
return ProviderConfig(
id=provider_id,
label=label,
base_url=base_url,
api_key=api_key,
models=_models(model_list, vision_models),
reasoning_effort=_reasoning_effort(os.getenv(f"CDSL_{prefix}_REASONING_EFFORT", "")),
api_style=_api_style(os.getenv(f"CDSL_{prefix}_API_STYLE", "responses" if provider_id == "openai" else "chat_completions")),
)
def get_settings() -> Settings:
data_root = BACKEND_ROOT / "data"
providers = (
_provider("DEEPSEEK", "deepseek", "DeepSeek", "https://api.deepseek.com/v1", "deepseek-chat"),
_provider("OPENAI", "openai", "OpenAI", "https://api.openai.com/v1"),
_provider("KIMI", "kimi", "Kimi", "https://api.moonshot.cn/v1"),
)
default_provider_id = os.getenv("CDSL_DEFAULT_PROVIDER", "deepseek").strip().lower() or "deepseek"
default_provider = next((item for item in providers if item.id == default_provider_id), providers[0])
default_model = os.getenv("CDSL_DEFAULT_MODEL", "").strip() or (default_provider.models[0].id if default_provider.models else "")
llm_timeout_s = float(os.getenv("CDSL_LLM_TIMEOUT_S", "90"))
return Settings(
task_root=data_root / "tasks",
conversation_root=data_root / "conversations",
library_root=BACKEND_ROOT / "cdsl_library",
engine_root=BACKEND_ROOT / "engine" / "cdsl_engine",
llm_base_url=default_provider.base_url,
llm_api_key=default_provider.api_key,
llm_model=default_model,
llm_timeout_s=llm_timeout_s,
default_provider_id=default_provider_id,
providers=providers,
review_provider_id=os.getenv("CDSL_REVIEW_PROVIDER", "").strip().lower(),
review_model_id=os.getenv("CDSL_REVIEW_MODEL", "").strip(),
agent_tool_calls_per_cycle=max(1, int(os.getenv("CDSL_AGENT_TOOL_CALLS_PER_CYCLE", "12"))),
agent_consecutive_no_progress_limit=max(1, int(os.getenv("CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT", "6"))),
agent_format_error_repeat_limit=max(1, int(os.getenv("CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT", "3"))),
agent_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))),
agent_author_guidance_enabled=_env_flag("CDSL_AGENT_AUTHOR_GUIDANCE_ENABLED", True),
agent_author_guidance_max_chars=min(6000, max(1200, int(os.getenv("CDSL_AGENT_AUTHOR_GUIDANCE_MAX_CHARS", "3600")))),
agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True),
autonomous_generation=True,
# Production instances recover durable runs by default. Test workers
# can disable this before startup to guarantee they touch only tasks
# explicitly created by that worker.
resume_running_tasks_on_startup=_env_flag("CDSL_RESUME_RUNNING_TASKS", True),
)