"""Public task backend configuration; runtime credentials stay server-side.""" from typing import Any, Literal from urllib.parse import urlsplit from pydantic import ConfigDict, Field, field_validator, model_validator from schemas import CamelModel, ToolBinding class TaskBackendConfig(CamelModel): model_config = ConfigDict(extra="forbid") instructions: str = Field(default="", max_length=16000) timeout_seconds: int = Field(default=600, ge=30, le=7200) max_concurrency: Literal[1] = 1 class TaskSkill(CamelModel): model_config = ConfigDict(extra="forbid") name: str = Field(min_length=1, max_length=128) content: str = Field(min_length=1, max_length=16000) @field_validator("name", "content") @classmethod def require_nonempty(cls, value: str) -> str: if not value.strip(): raise ValueError("技能名称和正文不能为空") return value.strip() class ReasonerBackendConfig(TaskBackendConfig): model_resource_id: str = Field(min_length=1, max_length=40) reasoning_effort: Literal["provider_default", "low", "medium", "high"] = "provider_default" tool_bindings: list[ToolBinding] = Field(default_factory=list, max_length=50) skills: list[TaskSkill] = Field(default_factory=list, max_length=20) @model_validator(mode="after") def unique_bindings_and_skills(self): ids = [binding.tool_id for binding in self.tool_bindings] if len(ids) != len(set(ids)): raise ValueError("Reasoner 工具绑定不能重复") names = [skill.name for skill in self.skills] if len(names) != len(set(names)): raise ValueError("技能名称不能重复") return self class OpenCodeBackendConfig(TaskBackendConfig): url: str = Field(min_length=1, max_length=2048) directory: str = Field(min_length=1, max_length=2048) username: str = Field(default="opencode", max_length=128) agent: str = Field(default="", max_length=128) model_provider_id: str = Field(default="", max_length=128) model_id: str = Field(default="", max_length=256) skills_note: str = Field(default="", max_length=4000) @field_validator("url") @classmethod def validate_url(cls, value: str) -> str: parsed = urlsplit(value) if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment: raise ValueError("任务服务须使用不含凭证、查询参数的 HTTP(S) 地址") return value.rstrip("/") @field_validator("directory") @classmethod def require_directory(cls, value: str) -> str: if not value.strip(): raise ValueError("必须填写远端工作目录") return value.strip() @model_validator(mode="after") def require_model_pair(self): if bool(self.model_provider_id) != bool(self.model_id): raise ValueError("远端模型的 provider ID 和 model ID 必须同时填写") return self class TaskBackendUpsert(CamelModel): name: str = Field(min_length=1, max_length=128) type: Literal["reasoner", "opencode"] description: str = Field(default="", max_length=2048) config: ReasonerBackendConfig | OpenCodeBackendConfig secrets: dict[str, Any] = Field(default_factory=dict) enabled: bool = True @model_validator(mode="before") @classmethod def select_config(cls, value): if isinstance(value, dict) and value.get("type") in {"reasoner", "opencode"}: value = dict(value) config_class = ReasonerBackendConfig if value["type"] == "reasoner" else OpenCodeBackendConfig raw = value.get("config") or {} if isinstance(raw, CamelModel): raw = raw.model_dump() value["config"] = config_class.model_validate(raw) return value @field_validator("name") @classmethod def require_name(cls, value: str) -> str: if not value.strip(): raise ValueError("后端名称不能为空") return value.strip() class TaskBackendOut(TaskBackendUpsert): id: str updated_at: str | None = None