177 lines
6.4 KiB
Python
177 lines
6.4 KiB
Python
"""Versioned plugin contracts shared by the installer, API and task workers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import sys
|
|
import platform
|
|
from typing import Any, Annotated, Literal
|
|
from urllib.parse import urlsplit
|
|
|
|
from packaging.specifiers import SpecifierSet
|
|
from packaging.version import Version
|
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
|
|
|
|
Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_-]{0,47}$")]
|
|
|
|
|
|
class PluginModel(BaseModel):
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
class ServerConfig(PluginModel):
|
|
transport: Literal["stdio", "streamable_http"] = "stdio"
|
|
command: str = ""
|
|
args: list[str] = Field(default_factory=list)
|
|
env: dict[str, str] = Field(default_factory=dict)
|
|
url: str = ""
|
|
headers: dict[str, str] = Field(default_factory=dict)
|
|
|
|
@model_validator(mode="after")
|
|
def validate_transport(self):
|
|
if self.transport == "stdio" and not self.command.strip():
|
|
raise ValueError("stdio 插件必须提供启动命令")
|
|
if self.transport == "streamable_http":
|
|
parsed = urlsplit(self.url)
|
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
|
|
raise ValueError("MCP 地址必须是 HTTP(S) 地址")
|
|
if parsed.username or parsed.password or parsed.fragment:
|
|
raise ValueError("MCP 地址不能包含凭据或片段;请使用密钥配置")
|
|
return self
|
|
|
|
|
|
class ConfigField(PluginModel):
|
|
label: str = Field(min_length=1, max_length=100)
|
|
description: str = Field(default="", max_length=1000)
|
|
secret: bool = False
|
|
required: bool = False
|
|
default: str = ""
|
|
|
|
|
|
ExecutionTarget = Literal["android", "computer", "mixed"]
|
|
|
|
|
|
class PluginObservation(PluginModel):
|
|
tool: str = Field(min_length=1)
|
|
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
refresh_tools: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class PluginManifest(PluginModel):
|
|
schema_version: Literal[1] = 1
|
|
id: Identifier
|
|
name: str = Field(min_length=1, max_length=100)
|
|
version: str
|
|
description: str = Field(default="", max_length=4000)
|
|
artemis_version: str = ">=2.0.0,<3.0.0"
|
|
platforms: list[Literal["win32", "linux", "darwin"]] = Field(default_factory=lambda: ["win32"])
|
|
architectures: list[Literal["amd64", "arm64"]] = Field(
|
|
default_factory=lambda: ["amd64", "arm64"]
|
|
)
|
|
source: str = ""
|
|
license: str = ""
|
|
server: ServerConfig | None = None
|
|
skill: str | None = None
|
|
config: dict[Identifier, ConfigField] = Field(default_factory=dict)
|
|
resources: list[str] = Field(default_factory=list)
|
|
targets: list[ExecutionTarget] = Field(default_factory=lambda: ["mixed"])
|
|
observation: PluginObservation | None = None
|
|
|
|
@field_validator("version")
|
|
@classmethod
|
|
def valid_version(cls, value: str) -> str:
|
|
Version(value)
|
|
if not re.fullmatch(r"[0-9][a-zA-Z0-9.+-]{0,63}", value):
|
|
raise ValueError("插件版本格式无效")
|
|
return value
|
|
|
|
@field_validator("artemis_version")
|
|
@classmethod
|
|
def valid_specifier(cls, value: str) -> str:
|
|
SpecifierSet(value)
|
|
return value
|
|
|
|
@model_validator(mode="after")
|
|
def has_capability(self):
|
|
if self.server is None and self.skill is None:
|
|
raise ValueError("插件至少需要 MCP 服务或 Skill")
|
|
for name, field in self.config.items():
|
|
if field.secret and field.default:
|
|
raise ValueError(f"密钥字段 {name} 不允许默认值")
|
|
return self
|
|
|
|
def check_compatible(self, artemis_version: str | None = None) -> None:
|
|
from artemis._version import __version__
|
|
|
|
artemis_version = artemis_version or __version__
|
|
if Version(artemis_version) not in SpecifierSet(self.artemis_version):
|
|
raise ValueError(f"插件不兼容 ARTEMIS {artemis_version}")
|
|
if sys.platform not in self.platforms:
|
|
raise ValueError(f"插件不支持当前平台 {sys.platform}")
|
|
architecture = {"x86_64": "amd64", "aarch64": "arm64"}.get(
|
|
platform.machine().lower(), platform.machine().lower()
|
|
)
|
|
if architecture not in self.architectures:
|
|
raise ValueError(f"插件不支持当前处理器架构 {architecture}")
|
|
|
|
|
|
class PluginProfile(PluginModel):
|
|
name: Identifier = "default"
|
|
label: str = "默认配置"
|
|
is_default: bool = False
|
|
values: dict[str, str] = Field(default_factory=dict)
|
|
# Only a write payload contains secrets; read responses expose configured names.
|
|
secrets: dict[str, SecretStr] = Field(default_factory=dict, repr=False)
|
|
allowed_tools: list[str] | None = None
|
|
argument_constraints: dict[str, dict[str, list[str]]] = Field(default_factory=dict)
|
|
resources: list[str] = Field(default_factory=list)
|
|
authorized: bool = False
|
|
|
|
|
|
class PluginSelection(PluginModel):
|
|
id: Identifier
|
|
profile: Identifier = "default"
|
|
version: str | None = None
|
|
|
|
|
|
class CatalogEntry(PluginModel):
|
|
manifest: PluginManifest
|
|
release_stage: Literal["stable", "preview"] = "stable"
|
|
archive_url: str
|
|
sha256: str = Field(pattern=r"^[a-f0-9]{64}$")
|
|
verification: str = Field(min_length=1, max_length=4000)
|
|
|
|
@field_validator("archive_url")
|
|
@classmethod
|
|
def https_archive(cls, value: str) -> str:
|
|
parsed = urlsplit(value)
|
|
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
|
|
raise ValueError("市场安装包必须使用不含凭据的 HTTPS 地址")
|
|
return value
|
|
|
|
|
|
class PluginCatalog(PluginModel):
|
|
schema_version: Literal[1] = 1
|
|
plugins: list[CatalogEntry] = Field(default_factory=list)
|
|
|
|
|
|
class PluginCallResult(PluginModel):
|
|
status: Literal["success", "error", "uncertain"]
|
|
content: list[dict[str, Any]] = Field(default_factory=list)
|
|
structured_content: dict[str, Any] | None = None
|
|
message: str = ""
|
|
plugin_id: str = ""
|
|
version: str = ""
|
|
tool: str = ""
|
|
image_names: list[str] = Field(default_factory=list)
|
|
|
|
def text(self) -> str:
|
|
import json
|
|
|
|
parts = [str(block["text"]) for block in self.content if block.get("type") == "text"]
|
|
if self.structured_content is not None:
|
|
parts.append(json.dumps(self.structured_content, ensure_ascii=False))
|
|
if self.message:
|
|
parts.append(self.message)
|
|
return "\n".join(parts) or "工具已返回;请根据证据判断业务结果。"
|