provider-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- provider_sdk/__init__.py +39 -0
- provider_sdk/capabilities/__init__.py +73 -0
- provider_sdk/config.py +78 -0
- provider_sdk/context.py +78 -0
- provider_sdk/integrate.py +51 -0
- provider_sdk/platform.py +140 -0
- provider_sdk/plugin.py +172 -0
- provider_sdk/runtime/__init__.py +15 -0
- provider_sdk/runtime/loader.py +264 -0
- provider_sdk/types/__init__.py +14 -0
- provider_sdk/types/candidate.py +138 -0
- provider_sdk/types/manifest.py +108 -0
- provider_sdk-0.1.0.dist-info/METADATA +103 -0
- provider_sdk-0.1.0.dist-info/RECORD +17 -0
- provider_sdk-0.1.0.dist-info/WHEEL +5 -0
- provider_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- provider_sdk-0.1.0.dist-info/top_level.txt +1 -0
provider_sdk/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Provider-V2 平台插件 SDK。
|
|
2
|
+
|
|
3
|
+
插件作者的唯一依赖入口。插件不得导入 ``src.*``,只能通过本 SDK 与 Host 交互。
|
|
4
|
+
|
|
5
|
+
核心导出:
|
|
6
|
+
|
|
7
|
+
- :class:`ProviderPlugin` — 插件基类(生命周期 + 配置)
|
|
8
|
+
- :class:`PlatformAdapter` — 平台适配器抽象接口
|
|
9
|
+
- :class:`PluginContext` — 运行时上下文(日志 / 配置 / HTTP)
|
|
10
|
+
- :class:`PluginConfigBase` — 插件配置模型基类
|
|
11
|
+
- :class:`Candidate` — 网关候选项
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from provider_sdk.config import Field, PluginConfigBase
|
|
15
|
+
from provider_sdk.context import PluginContext
|
|
16
|
+
from provider_sdk.platform import PlatformAdapter
|
|
17
|
+
from provider_sdk.plugin import ProviderPlugin
|
|
18
|
+
from provider_sdk.types import (
|
|
19
|
+
CONFIG_RELOAD_SCOPE_GLOBAL,
|
|
20
|
+
CONFIG_RELOAD_SCOPE_SELF,
|
|
21
|
+
ALL_CAPABILITIES,
|
|
22
|
+
)
|
|
23
|
+
from provider_sdk.types.candidate import Candidate, make_id
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ProviderPlugin",
|
|
29
|
+
"PlatformAdapter",
|
|
30
|
+
"PluginContext",
|
|
31
|
+
"PluginConfigBase",
|
|
32
|
+
"Field",
|
|
33
|
+
"Candidate",
|
|
34
|
+
"make_id",
|
|
35
|
+
"CONFIG_RELOAD_SCOPE_SELF",
|
|
36
|
+
"CONFIG_RELOAD_SCOPE_GLOBAL",
|
|
37
|
+
"ALL_CAPABILITIES",
|
|
38
|
+
"__version__",
|
|
39
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""能力代理:配置、HTTP、日志。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
import aiohttp
|
|
10
|
+
|
|
11
|
+
__all__ = ["ConfigCapability", "HttpCapability", "LoggingCapability"]
|
|
12
|
+
|
|
13
|
+
GetConfigFn = Callable[[str, str], Any]
|
|
14
|
+
GetGlobalConfigFn = Callable[[], Mapping[str, Any]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LoggingCapability:
|
|
18
|
+
"""插件日志能力。"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, logger: logging.Logger) -> None:
|
|
21
|
+
self._logger = logger
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def logger(self) -> logging.Logger:
|
|
25
|
+
return self._logger
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConfigCapability:
|
|
29
|
+
"""插件配置读取能力。"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
get_plugin_config: Callable[[], Mapping[str, Any]],
|
|
35
|
+
get_global_config: Optional[GetGlobalConfigFn] = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self._get_plugin_config = get_plugin_config
|
|
38
|
+
self._get_global_config = get_global_config
|
|
39
|
+
|
|
40
|
+
def get_plugin(self) -> dict[str, Any]:
|
|
41
|
+
"""返回当前插件配置字典副本。"""
|
|
42
|
+
raw = self._get_plugin_config()
|
|
43
|
+
return dict(raw) if isinstance(raw, Mapping) else {}
|
|
44
|
+
|
|
45
|
+
def get_global(self) -> dict[str, Any]:
|
|
46
|
+
"""返回 Host 全局配置字典副本。"""
|
|
47
|
+
if self._get_global_config is None:
|
|
48
|
+
return {}
|
|
49
|
+
raw = self._get_global_config()
|
|
50
|
+
return dict(raw) if isinstance(raw, Mapping) else {}
|
|
51
|
+
|
|
52
|
+
def get(self, section: str, key: str, default: Any = None) -> Any:
|
|
53
|
+
"""读取全局配置中的嵌套键。"""
|
|
54
|
+
data = self.get_global()
|
|
55
|
+
node = data.get(section)
|
|
56
|
+
if isinstance(node, Mapping):
|
|
57
|
+
return node.get(key, default)
|
|
58
|
+
return default
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class HttpCapability:
|
|
62
|
+
"""共享 HTTP 会话能力。"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, session: aiohttp.ClientSession) -> None:
|
|
65
|
+
self._session = session
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def session(self) -> aiohttp.ClientSession:
|
|
69
|
+
return self._session
|
|
70
|
+
|
|
71
|
+
async def request(self, method: str, url: str, **kwargs: Any) -> aiohttp.ClientResponse:
|
|
72
|
+
"""对共享会话发起请求。"""
|
|
73
|
+
return await self._session.request(method, url, **kwargs)
|
provider_sdk/config.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""插件配置模型与校验工具。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any, ClassVar, TypeVar, cast
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"Field",
|
|
13
|
+
"PluginConfigBase",
|
|
14
|
+
"build_plugin_default_config",
|
|
15
|
+
"is_plugin_config_class",
|
|
16
|
+
"merge_plugin_config_data",
|
|
17
|
+
"validate_plugin_config",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
PluginConfigT = TypeVar("PluginConfigT", bound="PluginConfigBase")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PluginConfigBase(BaseModel):
|
|
24
|
+
"""插件配置模型基类。
|
|
25
|
+
|
|
26
|
+
插件作者继承此类声明配置结构;Host 据此生成默认配置与 WebUI Schema。
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(validate_assignment=True, extra="ignore")
|
|
30
|
+
|
|
31
|
+
__ui_label__: ClassVar[str] = ""
|
|
32
|
+
__ui_icon__: ClassVar[str] = ""
|
|
33
|
+
__ui_order__: ClassVar[int] = 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_plugin_config_class(candidate: Any) -> bool:
|
|
37
|
+
"""判断对象是否为插件配置模型类。"""
|
|
38
|
+
return bool(inspect.isclass(candidate) and issubclass(candidate, PluginConfigBase))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_plugin_default_config(config_class: type[PluginConfigT]) -> dict[str, Any]:
|
|
42
|
+
"""根据配置模型构造默认配置字典。"""
|
|
43
|
+
try:
|
|
44
|
+
instance = config_class()
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"插件配置模型 {config_class.__name__} 需要为所有字段提供默认值"
|
|
48
|
+
) from exc
|
|
49
|
+
return instance.model_dump(mode="python")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_plugin_config(
|
|
53
|
+
config_class: type[PluginConfigT],
|
|
54
|
+
config_data: Mapping[str, Any],
|
|
55
|
+
) -> PluginConfigT:
|
|
56
|
+
"""校验并构造强类型配置实例。"""
|
|
57
|
+
return config_class.model_validate(dict(config_data))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def merge_plugin_config_data(
|
|
61
|
+
default_config: Mapping[str, Any],
|
|
62
|
+
raw_config: Mapping[str, Any],
|
|
63
|
+
) -> tuple[dict[str, Any], bool]:
|
|
64
|
+
"""将用户配置递归合并到默认配置之上。"""
|
|
65
|
+
changed = False
|
|
66
|
+
merged: dict[str, Any] = dict(default_config)
|
|
67
|
+
|
|
68
|
+
for key, value in raw_config.items():
|
|
69
|
+
base_value = merged.get(key)
|
|
70
|
+
if isinstance(base_value, Mapping) and isinstance(value, Mapping):
|
|
71
|
+
nested, nested_changed = merge_plugin_config_data(base_value, value)
|
|
72
|
+
merged[key] = nested
|
|
73
|
+
changed = changed or nested_changed or nested != dict(base_value)
|
|
74
|
+
elif base_value != value:
|
|
75
|
+
merged[key] = value
|
|
76
|
+
changed = True
|
|
77
|
+
|
|
78
|
+
return merged, changed
|
provider_sdk/context.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""插件运行时上下文。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
|
|
12
|
+
from provider_sdk.capabilities import ConfigCapability, HttpCapability, LoggingCapability
|
|
13
|
+
|
|
14
|
+
__all__ = ["HostServices", "PluginContext", "create_plugin_context"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class HostServices:
|
|
19
|
+
"""Host 注入给 SDK 的运行时服务集合。"""
|
|
20
|
+
|
|
21
|
+
session: aiohttp.ClientSession
|
|
22
|
+
plugin_id: str
|
|
23
|
+
plugin_dir: str = ""
|
|
24
|
+
get_plugin_config: Any = None
|
|
25
|
+
get_global_config: Any = None
|
|
26
|
+
logger: Optional[logging.Logger] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PluginContext:
|
|
30
|
+
"""插件运行时上下文。
|
|
31
|
+
|
|
32
|
+
插件通过 ``self.ctx`` 访问日志、配置与 HTTP 会话。
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
*,
|
|
38
|
+
plugin_id: str,
|
|
39
|
+
plugin_dir: str,
|
|
40
|
+
logger: logging.Logger,
|
|
41
|
+
config: ConfigCapability,
|
|
42
|
+
http: HttpCapability,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.plugin_id = plugin_id
|
|
45
|
+
self.plugin_dir = plugin_dir
|
|
46
|
+
self.logger = logger
|
|
47
|
+
self.config = config
|
|
48
|
+
self.http = http
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def paths(self) -> Mapping[str, str]:
|
|
52
|
+
"""插件相关路径。"""
|
|
53
|
+
return {"plugin_dir": self.plugin_dir}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def create_plugin_context(services: HostServices) -> PluginContext:
|
|
57
|
+
"""根据 Host 服务构造插件上下文。"""
|
|
58
|
+
plugin_id = services.plugin_id
|
|
59
|
+
logger = services.logger or logging.getLogger(f"provider_sdk.plugin.{plugin_id}")
|
|
60
|
+
|
|
61
|
+
def _plugin_config() -> Mapping[str, Any]:
|
|
62
|
+
if callable(services.get_plugin_config):
|
|
63
|
+
raw = services.get_plugin_config()
|
|
64
|
+
return dict(raw) if isinstance(raw, Mapping) else {}
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
config_cap = ConfigCapability(
|
|
68
|
+
get_plugin_config=_plugin_config,
|
|
69
|
+
get_global_config=services.get_global_config if callable(services.get_global_config) else None,
|
|
70
|
+
)
|
|
71
|
+
http_cap = HttpCapability(services.session)
|
|
72
|
+
return PluginContext(
|
|
73
|
+
plugin_id=plugin_id,
|
|
74
|
+
plugin_dir=services.plugin_dir,
|
|
75
|
+
logger=logger,
|
|
76
|
+
config=config_cap,
|
|
77
|
+
http=http_cap,
|
|
78
|
+
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""与 Provider Host 注册表集成的辅助函数。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable, Mapping, Optional, Sequence
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
|
|
10
|
+
from provider_sdk.runtime.loader import LoadedPlugin, PluginLoader
|
|
11
|
+
|
|
12
|
+
__all__ = ["load_platform_plugins", "register_loaded_plugins"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def load_platform_plugins(
|
|
16
|
+
plugins_root: Path,
|
|
17
|
+
session: aiohttp.ClientSession,
|
|
18
|
+
*,
|
|
19
|
+
host_version: str = "",
|
|
20
|
+
get_plugin_config: Optional[Callable[[str], Mapping[str, Any]]] = None,
|
|
21
|
+
get_global_config: Optional[Callable[[], Mapping[str, Any]]] = None,
|
|
22
|
+
whitelist: Optional[Sequence[str]] = None,
|
|
23
|
+
blacklist: Optional[Sequence[str]] = None,
|
|
24
|
+
) -> list[LoadedPlugin]:
|
|
25
|
+
"""发现并加载 ``plugins/`` 目录下的平台插件。"""
|
|
26
|
+
loader = PluginLoader(host_version=host_version, plugin_type_filter="platform")
|
|
27
|
+
return await loader.discover_and_load(
|
|
28
|
+
plugins_root,
|
|
29
|
+
session,
|
|
30
|
+
get_plugin_config=get_plugin_config,
|
|
31
|
+
get_global_config=get_global_config,
|
|
32
|
+
whitelist=whitelist,
|
|
33
|
+
blacklist=blacklist,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def register_loaded_plugins(registry: Any, loaded: Sequence[LoadedPlugin]) -> None:
|
|
38
|
+
"""将已加载插件的适配器注册到 echotools 风格注册表。
|
|
39
|
+
|
|
40
|
+
``registry`` 需实现 ``register(adapter)`` 或内部 ``_registry.register``。
|
|
41
|
+
"""
|
|
42
|
+
for record in loaded:
|
|
43
|
+
adapter = record.adapter
|
|
44
|
+
if hasattr(registry, "register") and callable(registry.register):
|
|
45
|
+
registry.register(adapter)
|
|
46
|
+
continue
|
|
47
|
+
inner = getattr(registry, "_registry", None)
|
|
48
|
+
if inner is not None and hasattr(inner, "register"):
|
|
49
|
+
inner.register(adapter)
|
|
50
|
+
continue
|
|
51
|
+
raise TypeError("registry 不支持 register(adapter)")
|
provider_sdk/platform.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""平台适配器抽象接口 — 与 Provider Host 内置适配器契约一致。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
|
|
12
|
+
from provider_sdk.types.candidate import Candidate
|
|
13
|
+
|
|
14
|
+
__all__ = ["PlatformAdapter", "DEFAULT_CONTEXT_LENGTH"]
|
|
15
|
+
|
|
16
|
+
DEFAULT_CONTEXT_LENGTH = 131072
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PlatformAdapter(ABC):
|
|
20
|
+
"""所有平台适配器的抽象基类。
|
|
21
|
+
|
|
22
|
+
设计原则(与 Host 一致):
|
|
23
|
+
|
|
24
|
+
- ``init()`` 必须立即返回,耗时操作放后台 Task
|
|
25
|
+
- ``candidates()`` 随时反映真实状态
|
|
26
|
+
- 可选能力方法有安全默认实现
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def name(self) -> str:
|
|
32
|
+
"""平台标识名(小写,建议与目录名一致)。"""
|
|
33
|
+
...
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def supported_models(self) -> List[str]:
|
|
37
|
+
"""支持的模型列表。"""
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def default_capabilities(self) -> Dict[str, bool]:
|
|
42
|
+
"""默认能力字典,用于 ``/v1/models`` 输出。"""
|
|
43
|
+
return {"chat": True}
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def context_length(self) -> Optional[int]:
|
|
47
|
+
"""默认上下文长度。"""
|
|
48
|
+
return DEFAULT_CONTEXT_LENGTH
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
async def init(self, session: aiohttp.ClientSession) -> None:
|
|
52
|
+
"""初始化适配器,必须立即返回。"""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
@abstractmethod
|
|
56
|
+
async def candidates(self) -> List[Candidate]:
|
|
57
|
+
"""返回当前可用候选项列表。"""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
async def ensure_candidates(self, count: int) -> int:
|
|
62
|
+
"""确保至少有 ``count`` 个候选项可用。"""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
async def complete(
|
|
67
|
+
self,
|
|
68
|
+
candidate: Candidate,
|
|
69
|
+
messages: List[Dict[str, Any]],
|
|
70
|
+
model: str,
|
|
71
|
+
stream: bool,
|
|
72
|
+
*,
|
|
73
|
+
thinking: bool = False,
|
|
74
|
+
search: bool = False,
|
|
75
|
+
**kw: Any,
|
|
76
|
+
) -> AsyncGenerator[Union[str, Dict[str, Any]], None]:
|
|
77
|
+
"""聊天补全,yield 文本增量或结构化事件。"""
|
|
78
|
+
...
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
async def close(self) -> None:
|
|
82
|
+
"""关闭适配器并释放资源。"""
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
async def fetch_remote_models(self) -> List[str]:
|
|
86
|
+
"""拉取远程模型列表。"""
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
async def create_embedding(
|
|
90
|
+
self,
|
|
91
|
+
candidate: Candidate,
|
|
92
|
+
input_data: Union[str, List[str]],
|
|
93
|
+
model: str,
|
|
94
|
+
**kw: Any,
|
|
95
|
+
) -> Dict[str, Any]:
|
|
96
|
+
inputs = [input_data] if isinstance(input_data, str) else list(input_data)
|
|
97
|
+
return {
|
|
98
|
+
"object": "list",
|
|
99
|
+
"data": [
|
|
100
|
+
{"object": "embedding", "index": i, "embedding": []}
|
|
101
|
+
for i, _ in enumerate(inputs)
|
|
102
|
+
],
|
|
103
|
+
"model": model,
|
|
104
|
+
"usage": {"prompt_tokens": 0, "total_tokens": 0},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async def create_image(
|
|
108
|
+
self,
|
|
109
|
+
candidate: Candidate,
|
|
110
|
+
prompt: str,
|
|
111
|
+
model: str,
|
|
112
|
+
**kw: Any,
|
|
113
|
+
) -> Dict[str, Any]:
|
|
114
|
+
return {"created": int(time.time()), "data": []}
|
|
115
|
+
|
|
116
|
+
async def create_moderation(
|
|
117
|
+
self,
|
|
118
|
+
candidate: Candidate,
|
|
119
|
+
input_data: Union[str, List[str]],
|
|
120
|
+
model: str,
|
|
121
|
+
**kw: Any,
|
|
122
|
+
) -> Dict[str, Any]:
|
|
123
|
+
inputs = [input_data] if isinstance(input_data, str) else list(input_data)
|
|
124
|
+
return {
|
|
125
|
+
"id": f"modr-{uuid.uuid4().hex[:24]}",
|
|
126
|
+
"model": model,
|
|
127
|
+
"results": [{"flagged": False, "categories": {}, "category_scores": {}} for _ in inputs],
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
def set_proxy_enabled(self, enabled: bool) -> None:
|
|
131
|
+
"""设置代理覆盖开关(可选)。"""
|
|
132
|
+
del enabled
|
|
133
|
+
|
|
134
|
+
def is_proxy_allowed(self) -> bool:
|
|
135
|
+
"""是否允许代理切换。"""
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
def is_proxy_enabled(self) -> bool:
|
|
139
|
+
"""当前是否启用代理。"""
|
|
140
|
+
return False
|
provider_sdk/plugin.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Provider 平台插件基类。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Any, ClassVar, Iterable
|
|
8
|
+
|
|
9
|
+
from provider_sdk.config import (
|
|
10
|
+
PluginConfigBase,
|
|
11
|
+
build_plugin_default_config,
|
|
12
|
+
is_plugin_config_class,
|
|
13
|
+
merge_plugin_config_data,
|
|
14
|
+
validate_plugin_config,
|
|
15
|
+
)
|
|
16
|
+
from provider_sdk.context import PluginContext
|
|
17
|
+
from provider_sdk.platform import PlatformAdapter
|
|
18
|
+
from provider_sdk.types import CONFIG_RELOAD_SCOPE_SELF
|
|
19
|
+
|
|
20
|
+
__all__ = ["ProviderPlugin", "get_platform_adapter", "is_platform_plugin"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ProviderPlugin:
|
|
24
|
+
"""SDK 平台插件基类。
|
|
25
|
+
|
|
26
|
+
用法示例::
|
|
27
|
+
|
|
28
|
+
class EchoPlugin(ProviderPlugin, PlatformAdapter):
|
|
29
|
+
@property
|
|
30
|
+
def name(self) -> str:
|
|
31
|
+
return "echo"
|
|
32
|
+
|
|
33
|
+
async def on_load(self) -> None:
|
|
34
|
+
self.ctx.logger.info("echo 插件已加载")
|
|
35
|
+
|
|
36
|
+
async def init(self, session):
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
def create_plugin() -> EchoPlugin:
|
|
40
|
+
return EchoPlugin()
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
config_reload_subscriptions: ClassVar[Iterable[str]] = ()
|
|
44
|
+
config_model: ClassVar[type[PluginConfigBase] | None] = None
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self._ctx: PluginContext | None = None
|
|
48
|
+
self._plugin_config_data: dict[str, Any] = {}
|
|
49
|
+
self._plugin_config_instance: PluginConfigBase | None = None
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def get_config_model(cls) -> type[PluginConfigBase] | None:
|
|
53
|
+
candidate = cls.config_model
|
|
54
|
+
return candidate if is_plugin_config_class(candidate) else None
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def has_config_model(cls) -> bool:
|
|
58
|
+
return cls.get_config_model() is not None
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def build_default_config(cls) -> dict[str, Any]:
|
|
62
|
+
config_class = cls.get_config_model()
|
|
63
|
+
if config_class is None:
|
|
64
|
+
return {}
|
|
65
|
+
return build_plugin_default_config(config_class)
|
|
66
|
+
|
|
67
|
+
def normalize_plugin_config(
|
|
68
|
+
self,
|
|
69
|
+
config_data: Mapping[str, Any] | None,
|
|
70
|
+
) -> tuple[dict[str, Any], bool]:
|
|
71
|
+
raw_config: dict[str, Any] = dict(config_data) if isinstance(config_data, Mapping) else {}
|
|
72
|
+
config_class = type(self).get_config_model()
|
|
73
|
+
if config_class is None:
|
|
74
|
+
return raw_config, False
|
|
75
|
+
|
|
76
|
+
default_config = type(self).build_default_config()
|
|
77
|
+
if not raw_config:
|
|
78
|
+
validated = validate_plugin_config(config_class, default_config)
|
|
79
|
+
return validated.model_dump(mode="python"), bool(default_config)
|
|
80
|
+
|
|
81
|
+
merged, changed = merge_plugin_config_data(default_config, raw_config)
|
|
82
|
+
validated = validate_plugin_config(config_class, merged)
|
|
83
|
+
normalized = validated.model_dump(mode="python")
|
|
84
|
+
return normalized, changed or normalized != merged
|
|
85
|
+
|
|
86
|
+
def set_plugin_config(self, config: dict[str, Any]) -> None:
|
|
87
|
+
normalized, _ = self.normalize_plugin_config(config)
|
|
88
|
+
self._plugin_config_data = normalized
|
|
89
|
+
|
|
90
|
+
config_class = type(self).get_config_model()
|
|
91
|
+
if config_class is None:
|
|
92
|
+
self._plugin_config_instance = None
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
self._plugin_config_instance = validate_plugin_config(config_class, normalized)
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
self._plugin_config_instance = None
|
|
99
|
+
self._get_logger().warning("插件配置校验失败: %s", exc)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def config(self) -> PluginConfigBase:
|
|
103
|
+
if not type(self).has_config_model():
|
|
104
|
+
raise RuntimeError("当前插件未声明 config_model")
|
|
105
|
+
if self._plugin_config_instance is None:
|
|
106
|
+
raise RuntimeError("当前插件配置尚未注入")
|
|
107
|
+
return self._plugin_config_instance
|
|
108
|
+
|
|
109
|
+
def get_plugin_config_data(self) -> dict[str, Any]:
|
|
110
|
+
return dict(self._plugin_config_data)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def ctx(self) -> PluginContext:
|
|
114
|
+
if self._ctx is None:
|
|
115
|
+
raise RuntimeError("插件上下文尚未初始化")
|
|
116
|
+
return self._ctx
|
|
117
|
+
|
|
118
|
+
def _set_context(self, ctx: PluginContext) -> None:
|
|
119
|
+
self._ctx = ctx
|
|
120
|
+
|
|
121
|
+
def _get_logger(self) -> logging.Logger:
|
|
122
|
+
if self._ctx is not None:
|
|
123
|
+
return self._ctx.logger
|
|
124
|
+
return logging.getLogger("provider_sdk.plugin")
|
|
125
|
+
|
|
126
|
+
async def on_load(self) -> None:
|
|
127
|
+
"""插件加载完成后的生命周期钩子。"""
|
|
128
|
+
|
|
129
|
+
async def on_unload(self) -> None:
|
|
130
|
+
"""插件卸载前的生命周期钩子。"""
|
|
131
|
+
|
|
132
|
+
async def on_config_update(
|
|
133
|
+
self,
|
|
134
|
+
scope: str,
|
|
135
|
+
config_data: dict[str, object],
|
|
136
|
+
version: str,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""配置热重载回调。
|
|
139
|
+
|
|
140
|
+
``scope`` 为 ``self`` 时表示插件自身 ``config.toml`` 变更。
|
|
141
|
+
"""
|
|
142
|
+
if scope == CONFIG_RELOAD_SCOPE_SELF:
|
|
143
|
+
self.set_plugin_config(dict(config_data))
|
|
144
|
+
del version
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_platform_plugin(instance: Any) -> bool:
|
|
148
|
+
"""判断对象是否为合法的 Provider 平台插件。"""
|
|
149
|
+
return isinstance(instance, ProviderPlugin)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_platform_adapter(plugin: ProviderPlugin) -> PlatformAdapter:
|
|
153
|
+
"""从插件实例提取平台适配器。
|
|
154
|
+
|
|
155
|
+
插件类可同时继承 :class:`PlatformAdapter`;否则必须实现
|
|
156
|
+
``get_adapter()`` 并返回适配器实例。
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
TypeError: 插件未提供平台适配器。
|
|
160
|
+
"""
|
|
161
|
+
if isinstance(plugin, PlatformAdapter):
|
|
162
|
+
return plugin
|
|
163
|
+
|
|
164
|
+
getter = getattr(plugin, "get_adapter", None)
|
|
165
|
+
if callable(getter):
|
|
166
|
+
adapter = getter()
|
|
167
|
+
if isinstance(adapter, PlatformAdapter):
|
|
168
|
+
return adapter
|
|
169
|
+
|
|
170
|
+
raise TypeError(
|
|
171
|
+
f"插件 {type(plugin).__name__} 必须继承 PlatformAdapter 或实现 get_adapter()"
|
|
172
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Host 侧插件运行时。"""
|
|
2
|
+
|
|
3
|
+
from provider_sdk.runtime.loader import (
|
|
4
|
+
LoadedPlugin,
|
|
5
|
+
PluginLoadError,
|
|
6
|
+
PluginLoader,
|
|
7
|
+
discover_plugin_dirs,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"LoadedPlugin",
|
|
12
|
+
"PluginLoadError",
|
|
13
|
+
"PluginLoader",
|
|
14
|
+
"discover_plugin_dirs",
|
|
15
|
+
]
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Host 侧插件发现与加载。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from collections import deque
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
import aiohttp
|
|
14
|
+
|
|
15
|
+
from provider_sdk.context import HostServices, create_plugin_context
|
|
16
|
+
from provider_sdk.plugin import ProviderPlugin, get_platform_adapter, is_platform_plugin
|
|
17
|
+
from provider_sdk.platform import PlatformAdapter
|
|
18
|
+
from provider_sdk.types.manifest import PluginManifest, load_manifest_file
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"LoadedPlugin",
|
|
22
|
+
"PluginLoadError",
|
|
23
|
+
"PluginLoader",
|
|
24
|
+
"discover_plugin_dirs",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("provider_sdk.runtime.loader")
|
|
28
|
+
|
|
29
|
+
_MANIFEST_NAME = "_manifest.json"
|
|
30
|
+
_ENTRY_MODULE = "plugin.py"
|
|
31
|
+
_FACTORY_NAME = "create_plugin"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PluginLoadError(RuntimeError):
|
|
35
|
+
"""插件加载失败。"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class LoadedPlugin:
|
|
40
|
+
"""加载成功的插件记录。"""
|
|
41
|
+
|
|
42
|
+
manifest: PluginManifest
|
|
43
|
+
plugin: ProviderPlugin
|
|
44
|
+
adapter: PlatformAdapter
|
|
45
|
+
plugin_dir: Path
|
|
46
|
+
module_name: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def discover_plugin_dirs(plugins_root: Path) -> List[Path]:
|
|
50
|
+
"""扫描插件根目录,返回包含 manifest 的子目录列表。"""
|
|
51
|
+
root = plugins_root.resolve()
|
|
52
|
+
if not root.is_dir():
|
|
53
|
+
return []
|
|
54
|
+
|
|
55
|
+
candidates: List[Path] = []
|
|
56
|
+
for child in sorted(root.iterdir()):
|
|
57
|
+
if not child.is_dir():
|
|
58
|
+
continue
|
|
59
|
+
if child.name.startswith(".") or child.name.startswith("_"):
|
|
60
|
+
continue
|
|
61
|
+
if (child / _MANIFEST_NAME).is_file() and (child / _ENTRY_MODULE).is_file():
|
|
62
|
+
candidates.append(child)
|
|
63
|
+
return candidates
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _topological_sort(manifests: Mapping[str, PluginManifest]) -> List[str]:
|
|
67
|
+
indegree: Dict[str, int] = {pid: 0 for pid in manifests}
|
|
68
|
+
graph: Dict[str, List[str]] = {pid: [] for pid in manifests}
|
|
69
|
+
|
|
70
|
+
for pid, manifest in manifests.items():
|
|
71
|
+
for dep in manifest.dependencies:
|
|
72
|
+
if dep not in manifests:
|
|
73
|
+
raise PluginLoadError(f"插件 {pid} 依赖未找到的插件: {dep}")
|
|
74
|
+
graph[dep].append(pid)
|
|
75
|
+
indegree[pid] += 1
|
|
76
|
+
|
|
77
|
+
queue = deque(sorted(pid for pid, deg in indegree.items() if deg == 0))
|
|
78
|
+
ordered: List[str] = []
|
|
79
|
+
while queue:
|
|
80
|
+
current = queue.popleft()
|
|
81
|
+
ordered.append(current)
|
|
82
|
+
for nxt in graph[current]:
|
|
83
|
+
indegree[nxt] -= 1
|
|
84
|
+
if indegree[nxt] == 0:
|
|
85
|
+
queue.append(nxt)
|
|
86
|
+
|
|
87
|
+
if len(ordered) != len(manifests):
|
|
88
|
+
raise PluginLoadError("插件依赖存在环")
|
|
89
|
+
return ordered
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _load_entry_module(plugin_dir: Path, plugin_id: str) -> Any:
|
|
93
|
+
module_name = f"provider_plugin_{plugin_id.replace('.', '_').replace('-', '_')}"
|
|
94
|
+
entry = plugin_dir / _ENTRY_MODULE
|
|
95
|
+
spec = importlib.util.spec_from_file_location(module_name, entry)
|
|
96
|
+
if spec is None or spec.loader is None:
|
|
97
|
+
raise PluginLoadError(f"无法加载入口模块: {entry}")
|
|
98
|
+
|
|
99
|
+
module = importlib.util.module_from_spec(spec)
|
|
100
|
+
sys.modules[module_name] = module
|
|
101
|
+
spec.loader.exec_module(module)
|
|
102
|
+
return module
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _validate_lifecycle(plugin: ProviderPlugin) -> None:
|
|
106
|
+
cls = type(plugin)
|
|
107
|
+
if cls.on_load is ProviderPlugin.on_load:
|
|
108
|
+
pass
|
|
109
|
+
if cls.on_unload is ProviderPlugin.on_unload:
|
|
110
|
+
pass
|
|
111
|
+
if cls.on_config_update is ProviderPlugin.on_config_update:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class PluginLoader:
|
|
116
|
+
"""扫描 ``plugins/`` 目录并加载 SDK 平台插件。"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
host_version: str = "",
|
|
122
|
+
plugin_type_filter: str = "platform",
|
|
123
|
+
) -> None:
|
|
124
|
+
self._host_version = host_version
|
|
125
|
+
self._plugin_type_filter = plugin_type_filter.strip().lower()
|
|
126
|
+
self._loaded: Dict[str, LoadedPlugin] = {}
|
|
127
|
+
self._failed: Dict[str, str] = {}
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def loaded_plugins(self) -> Dict[str, LoadedPlugin]:
|
|
131
|
+
return dict(self._loaded)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def failed_plugins(self) -> Dict[str, str]:
|
|
135
|
+
return dict(self._failed)
|
|
136
|
+
|
|
137
|
+
async def discover_and_load(
|
|
138
|
+
self,
|
|
139
|
+
plugins_root: Path,
|
|
140
|
+
session: aiohttp.ClientSession,
|
|
141
|
+
*,
|
|
142
|
+
get_plugin_config: Optional[Callable[[str], Mapping[str, Any]]] = None,
|
|
143
|
+
get_global_config: Optional[Callable[[], Mapping[str, Any]]] = None,
|
|
144
|
+
whitelist: Optional[Sequence[str]] = None,
|
|
145
|
+
blacklist: Optional[Sequence[str]] = None,
|
|
146
|
+
) -> List[LoadedPlugin]:
|
|
147
|
+
"""发现并加载全部合法插件。"""
|
|
148
|
+
plugin_dirs = discover_plugin_dirs(plugins_root)
|
|
149
|
+
manifests: Dict[str, PluginManifest] = {}
|
|
150
|
+
dir_by_id: Dict[str, Path] = {}
|
|
151
|
+
|
|
152
|
+
for plugin_dir in plugin_dirs:
|
|
153
|
+
try:
|
|
154
|
+
manifest = load_manifest_file(plugin_dir)
|
|
155
|
+
except Exception as exc:
|
|
156
|
+
self._failed[plugin_dir.name] = str(exc)
|
|
157
|
+
logger.error("manifest 解析失败 [%s]: %s", plugin_dir.name, exc)
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
if self._plugin_type_filter and manifest.plugin_type != self._plugin_type_filter:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
manifests[manifest.id] = manifest
|
|
164
|
+
dir_by_id[manifest.id] = plugin_dir
|
|
165
|
+
|
|
166
|
+
wl = set(whitelist) if whitelist else None
|
|
167
|
+
bl = set(blacklist or [])
|
|
168
|
+
ordered_ids = _topological_sort(manifests)
|
|
169
|
+
|
|
170
|
+
loaded: List[LoadedPlugin] = []
|
|
171
|
+
for plugin_id in ordered_ids:
|
|
172
|
+
manifest = manifests[plugin_id]
|
|
173
|
+
adapter_name = plugin_id.rsplit(".", 1)[-1]
|
|
174
|
+
|
|
175
|
+
if wl is not None and adapter_name not in wl and plugin_id not in wl:
|
|
176
|
+
continue
|
|
177
|
+
if adapter_name in bl or plugin_id in bl:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
plugin_dir = dir_by_id[plugin_id]
|
|
181
|
+
try:
|
|
182
|
+
record = await self._load_one(
|
|
183
|
+
plugin_dir,
|
|
184
|
+
manifest,
|
|
185
|
+
session,
|
|
186
|
+
get_plugin_config=get_plugin_config,
|
|
187
|
+
get_global_config=get_global_config,
|
|
188
|
+
)
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
self._failed[plugin_id] = str(exc)
|
|
191
|
+
logger.error("插件加载失败 [%s]: %s", plugin_id, exc)
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
self._loaded[plugin_id] = record
|
|
195
|
+
loaded.append(record)
|
|
196
|
+
|
|
197
|
+
return loaded
|
|
198
|
+
|
|
199
|
+
async def _load_one(
|
|
200
|
+
self,
|
|
201
|
+
plugin_dir: Path,
|
|
202
|
+
manifest: PluginManifest,
|
|
203
|
+
session: aiohttp.ClientSession,
|
|
204
|
+
*,
|
|
205
|
+
get_plugin_config: Optional[Callable[[str], Mapping[str, Any]]] = None,
|
|
206
|
+
get_global_config: Optional[Callable[[], Mapping[str, Any]]] = None,
|
|
207
|
+
) -> LoadedPlugin:
|
|
208
|
+
module = _load_entry_module(plugin_dir, manifest.id)
|
|
209
|
+
factory = getattr(module, _FACTORY_NAME, None)
|
|
210
|
+
if not callable(factory):
|
|
211
|
+
raise PluginLoadError(f"插件 {manifest.id} 缺少 {_FACTORY_NAME}() 工厂函数")
|
|
212
|
+
|
|
213
|
+
instance = factory()
|
|
214
|
+
if not is_platform_plugin(instance):
|
|
215
|
+
raise PluginLoadError(f"插件 {manifest.id} 必须返回 ProviderPlugin 实例")
|
|
216
|
+
|
|
217
|
+
_validate_lifecycle(instance)
|
|
218
|
+
adapter = get_platform_adapter(instance)
|
|
219
|
+
|
|
220
|
+
if adapter.name != manifest.id.split(".")[-1] and adapter.name != manifest.id:
|
|
221
|
+
logger.warning(
|
|
222
|
+
"插件 %s 的 adapter.name=%s 与 manifest id 不完全一致",
|
|
223
|
+
manifest.id,
|
|
224
|
+
adapter.name,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
config_lookup = get_plugin_config or (lambda _pid: {})
|
|
228
|
+
services = HostServices(
|
|
229
|
+
session=session,
|
|
230
|
+
plugin_id=manifest.id,
|
|
231
|
+
plugin_dir=str(plugin_dir.resolve()),
|
|
232
|
+
get_plugin_config=lambda: config_lookup(manifest.id),
|
|
233
|
+
get_global_config=get_global_config,
|
|
234
|
+
)
|
|
235
|
+
ctx = create_plugin_context(services)
|
|
236
|
+
instance._set_context(ctx)
|
|
237
|
+
|
|
238
|
+
initial_config = config_lookup(manifest.id)
|
|
239
|
+
if isinstance(initial_config, Mapping):
|
|
240
|
+
instance.set_plugin_config(dict(initial_config))
|
|
241
|
+
|
|
242
|
+
await instance.on_load()
|
|
243
|
+
await adapter.init(session)
|
|
244
|
+
|
|
245
|
+
return LoadedPlugin(
|
|
246
|
+
manifest=manifest,
|
|
247
|
+
plugin=instance,
|
|
248
|
+
adapter=adapter,
|
|
249
|
+
plugin_dir=plugin_dir,
|
|
250
|
+
module_name=module.__name__,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
async def unload_all(self) -> None:
|
|
254
|
+
"""卸载全部已加载插件。"""
|
|
255
|
+
for plugin_id, record in list(self._loaded.items()):
|
|
256
|
+
try:
|
|
257
|
+
await record.adapter.close()
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
logger.warning("关闭适配器失败 [%s]: %s", plugin_id, exc)
|
|
260
|
+
try:
|
|
261
|
+
await record.plugin.on_unload()
|
|
262
|
+
except Exception as exc:
|
|
263
|
+
logger.warning("on_unload 失败 [%s]: %s", plugin_id, exc)
|
|
264
|
+
self._loaded.clear()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""类型常量与重载范围定义。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from provider_sdk.types.candidate import ALL_CAPABILITIES
|
|
6
|
+
|
|
7
|
+
CONFIG_RELOAD_SCOPE_SELF = "self"
|
|
8
|
+
CONFIG_RELOAD_SCOPE_GLOBAL = "global"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"CONFIG_RELOAD_SCOPE_SELF",
|
|
12
|
+
"CONFIG_RELOAD_SCOPE_GLOBAL",
|
|
13
|
+
"ALL_CAPABILITIES",
|
|
14
|
+
]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""网关候选项类型 — 与 Provider Host ``Candidate`` 字段对齐。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
__all__ = ["ALL_CAPABILITIES", "Candidate", "make_id"]
|
|
12
|
+
|
|
13
|
+
ALL_CAPABILITIES: tuple[str, ...] = (
|
|
14
|
+
"chat",
|
|
15
|
+
"vision",
|
|
16
|
+
"image_gen",
|
|
17
|
+
"image_edit",
|
|
18
|
+
"image_variation",
|
|
19
|
+
"video_gen",
|
|
20
|
+
"audio_gen",
|
|
21
|
+
"audio_in",
|
|
22
|
+
"audio_transcription",
|
|
23
|
+
"audio_translation",
|
|
24
|
+
"embedding",
|
|
25
|
+
"research",
|
|
26
|
+
"thinking",
|
|
27
|
+
"search",
|
|
28
|
+
"code_exec",
|
|
29
|
+
"artifacts",
|
|
30
|
+
"tools",
|
|
31
|
+
"native_tools",
|
|
32
|
+
"upload",
|
|
33
|
+
"continuation",
|
|
34
|
+
"moderation",
|
|
35
|
+
"rerank",
|
|
36
|
+
"batch",
|
|
37
|
+
"fine_tuning",
|
|
38
|
+
"files",
|
|
39
|
+
"assistants",
|
|
40
|
+
"threads",
|
|
41
|
+
"runs",
|
|
42
|
+
"vector_stores",
|
|
43
|
+
"realtime",
|
|
44
|
+
"responses",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def make_id(platform: str, resource_id: str = "") -> str:
|
|
49
|
+
"""生成候选项 ID。
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
platform: 平台标识名。
|
|
53
|
+
resource_id: 平台资源标识;提供时生成确定性 ID。
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
格式为 ``{platform}_{hash12}`` 的 ID。
|
|
57
|
+
"""
|
|
58
|
+
if resource_id:
|
|
59
|
+
digest = hashlib.sha256(f"{platform}:{resource_id}".encode()).hexdigest()[:12]
|
|
60
|
+
return f"{platform}_{digest}"
|
|
61
|
+
return f"{platform}_{uuid.uuid4().hex[:12]}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Candidate:
|
|
66
|
+
"""候选项,包含能力布尔字段与元数据。"""
|
|
67
|
+
|
|
68
|
+
id: str
|
|
69
|
+
platform: str
|
|
70
|
+
resource_id: str
|
|
71
|
+
|
|
72
|
+
chat: bool = False
|
|
73
|
+
vision: bool = False
|
|
74
|
+
tools: bool = False
|
|
75
|
+
native_tools: bool = False
|
|
76
|
+
thinking: bool = False
|
|
77
|
+
search: bool = False
|
|
78
|
+
continuation: bool = False
|
|
79
|
+
|
|
80
|
+
image_gen: bool = False
|
|
81
|
+
image_edit: bool = False
|
|
82
|
+
image_variation: bool = False
|
|
83
|
+
video_gen: bool = False
|
|
84
|
+
audio_gen: bool = False
|
|
85
|
+
|
|
86
|
+
audio_in: bool = False
|
|
87
|
+
audio_transcription: bool = False
|
|
88
|
+
audio_translation: bool = False
|
|
89
|
+
|
|
90
|
+
embedding: bool = False
|
|
91
|
+
rerank: bool = False
|
|
92
|
+
|
|
93
|
+
research: bool = False
|
|
94
|
+
code_exec: bool = False
|
|
95
|
+
artifacts: bool = False
|
|
96
|
+
moderation: bool = False
|
|
97
|
+
responses: bool = False
|
|
98
|
+
|
|
99
|
+
upload: bool = False
|
|
100
|
+
files: bool = False
|
|
101
|
+
vector_stores: bool = False
|
|
102
|
+
|
|
103
|
+
batch: bool = False
|
|
104
|
+
fine_tuning: bool = False
|
|
105
|
+
|
|
106
|
+
assistants: bool = False
|
|
107
|
+
threads: bool = False
|
|
108
|
+
runs: bool = False
|
|
109
|
+
|
|
110
|
+
realtime: bool = False
|
|
111
|
+
|
|
112
|
+
context_length: Optional[int] = None
|
|
113
|
+
|
|
114
|
+
models: List[str] = field(default_factory=list)
|
|
115
|
+
available: bool = True
|
|
116
|
+
busy: bool = False
|
|
117
|
+
cooldown: float = 0.0
|
|
118
|
+
meta: Dict[str, Any] = field(default_factory=dict)
|
|
119
|
+
|
|
120
|
+
def has_capability(self, cap: str) -> bool:
|
|
121
|
+
"""检查是否具备指定能力。"""
|
|
122
|
+
return bool(getattr(self, cap, False))
|
|
123
|
+
|
|
124
|
+
def to_model_dict(self, owned_by: str = "") -> Dict[str, Any]:
|
|
125
|
+
"""转换为 ``/v1/models`` 条目格式。"""
|
|
126
|
+
caps: Dict[str, bool] = {}
|
|
127
|
+
for cap in ALL_CAPABILITIES:
|
|
128
|
+
if getattr(self, cap, False):
|
|
129
|
+
caps[cap] = True
|
|
130
|
+
result: Dict[str, Any] = {
|
|
131
|
+
"object": "model",
|
|
132
|
+
"created": int(time.time()),
|
|
133
|
+
"owned_by": owned_by or self.platform,
|
|
134
|
+
"capabilities": caps,
|
|
135
|
+
}
|
|
136
|
+
if self.context_length is not None:
|
|
137
|
+
result["context_length"] = self.context_length
|
|
138
|
+
return result
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""插件 manifest 解析与校验。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
10
|
+
|
|
11
|
+
__all__ = ["PluginManifest", "parse_manifest", "load_manifest_file"]
|
|
12
|
+
|
|
13
|
+
_MANIFEST_FILENAME = "_manifest.json"
|
|
14
|
+
_PLUGIN_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{1,126}[a-z0-9]$")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class PluginManifest:
|
|
19
|
+
"""``_manifest.json`` 强类型视图。"""
|
|
20
|
+
|
|
21
|
+
id: str
|
|
22
|
+
name: str
|
|
23
|
+
version: str
|
|
24
|
+
description: str = ""
|
|
25
|
+
plugin_type: str = "platform"
|
|
26
|
+
author: str = ""
|
|
27
|
+
dependencies: List[str] = field(default_factory=list)
|
|
28
|
+
capabilities: List[str] = field(default_factory=list)
|
|
29
|
+
host_min_version: str = ""
|
|
30
|
+
host_max_version: str = ""
|
|
31
|
+
sdk_min_version: str = "0.1.0"
|
|
32
|
+
sdk_max_version: str = "0.99.99"
|
|
33
|
+
manifest_version: int = 1
|
|
34
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def plugin_dependency_ids(self) -> List[str]:
|
|
38
|
+
"""依赖插件 ID 列表(``dependencies`` 字段别名)。"""
|
|
39
|
+
return list(self.dependencies)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _read_author(value: Any) -> str:
|
|
43
|
+
if isinstance(value, str):
|
|
44
|
+
return value.strip()
|
|
45
|
+
if isinstance(value, Mapping):
|
|
46
|
+
return str(value.get("name") or "").strip()
|
|
47
|
+
return ""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _read_dependencies(value: Any) -> List[str]:
|
|
51
|
+
if not isinstance(value, list):
|
|
52
|
+
return []
|
|
53
|
+
out: List[str] = []
|
|
54
|
+
for item in value:
|
|
55
|
+
if isinstance(item, str) and item.strip():
|
|
56
|
+
out.append(item.strip())
|
|
57
|
+
elif isinstance(item, Mapping):
|
|
58
|
+
dep_id = str(item.get("id") or item.get("plugin_id") or "").strip()
|
|
59
|
+
if dep_id:
|
|
60
|
+
out.append(dep_id)
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_manifest(data: Mapping[str, Any]) -> PluginManifest:
|
|
65
|
+
"""将原始 JSON 映射解析为 :class:`PluginManifest`。
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: manifest 缺少必填字段或格式非法。
|
|
69
|
+
"""
|
|
70
|
+
plugin_id = str(data.get("id") or "").strip()
|
|
71
|
+
if not plugin_id:
|
|
72
|
+
raise ValueError("manifest 缺少 id")
|
|
73
|
+
if not _PLUGIN_ID_RE.match(plugin_id):
|
|
74
|
+
raise ValueError(f"manifest id 格式非法: {plugin_id}")
|
|
75
|
+
|
|
76
|
+
name = str(data.get("name") or plugin_id).strip()
|
|
77
|
+
version = str(data.get("version") or "0.0.0").strip()
|
|
78
|
+
host_app = data.get("host_application") if isinstance(data.get("host_application"), Mapping) else {}
|
|
79
|
+
sdk_info = data.get("sdk") if isinstance(data.get("sdk"), Mapping) else {}
|
|
80
|
+
|
|
81
|
+
return PluginManifest(
|
|
82
|
+
id=plugin_id,
|
|
83
|
+
name=name,
|
|
84
|
+
version=version,
|
|
85
|
+
description=str(data.get("description") or "").strip(),
|
|
86
|
+
plugin_type=str(data.get("plugin_type") or "platform").strip().lower(),
|
|
87
|
+
author=_read_author(data.get("author")),
|
|
88
|
+
dependencies=_read_dependencies(data.get("dependencies")),
|
|
89
|
+
capabilities=[str(x).strip() for x in (data.get("capabilities") or []) if str(x).strip()],
|
|
90
|
+
host_min_version=str(host_app.get("min_version") or "").strip(),
|
|
91
|
+
host_max_version=str(host_app.get("max_version") or "").strip(),
|
|
92
|
+
sdk_min_version=str(sdk_info.get("min_version") or "0.1.0").strip(),
|
|
93
|
+
sdk_max_version=str(sdk_info.get("max_version") or "0.99.99").strip(),
|
|
94
|
+
manifest_version=int(data.get("manifest_version") or 1),
|
|
95
|
+
raw=dict(data),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_manifest_file(plugin_dir: Path) -> PluginManifest:
|
|
100
|
+
"""从插件目录读取 ``_manifest.json``。"""
|
|
101
|
+
path = plugin_dir / _MANIFEST_FILENAME
|
|
102
|
+
if not path.is_file():
|
|
103
|
+
raise FileNotFoundError(f"缺少 {_MANIFEST_FILENAME}: {plugin_dir}")
|
|
104
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
105
|
+
data = json.load(fh)
|
|
106
|
+
if not isinstance(data, Mapping):
|
|
107
|
+
raise ValueError(f"manifest 根节点必须是对象: {path}")
|
|
108
|
+
return parse_manifest(data)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: provider-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Provider-V2 平台插件 SDK — 第三方平台适配器开发与加载
|
|
5
|
+
Author: provider-sdk
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/nichengfuben/provider-sdk
|
|
8
|
+
Project-URL: Documentation, https://github.com/nichengfuben/provider-sdk/blob/main/docs/guide.md
|
|
9
|
+
Project-URL: Repository, https://github.com/nichengfuben/provider-sdk
|
|
10
|
+
Project-URL: Issues, https://github.com/nichengfuben/provider-sdk/issues
|
|
11
|
+
Keywords: provider,llm,gateway,plugin,sdk,openai
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: aiohttp>=3.9.0
|
|
24
|
+
Requires-Dist: pydantic>=2.0.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# provider-sdk
|
|
31
|
+
|
|
32
|
+
Provider-V2 平台插件 SDK。对标 [maibot-plugin-sdk](https://github.com/Mai-with-u/maibot-plugin-sdk) 的插件契约,为第三方**平台适配器**提供独立开发与加载能力。
|
|
33
|
+
|
|
34
|
+
## 安装
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install -e X:\Project\provider-sdk
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## 快速开始
|
|
41
|
+
|
|
42
|
+
在 Provider Host 的 `plugins/` 目录下创建插件文件夹:
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
plugins/
|
|
46
|
+
my_platform/
|
|
47
|
+
_manifest.json
|
|
48
|
+
plugin.py
|
|
49
|
+
config.toml # 可选,由 Host 管理
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`plugin.py` 最小示例见 [`examples/echo_platform/plugin.py`](examples/echo_platform/plugin.py)。
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from provider_sdk import ProviderPlugin, PlatformAdapter, Candidate, make_id
|
|
56
|
+
|
|
57
|
+
class MyPlugin(ProviderPlugin, PlatformAdapter):
|
|
58
|
+
@property
|
|
59
|
+
def name(self) -> str:
|
|
60
|
+
return "my_platform"
|
|
61
|
+
|
|
62
|
+
async def on_load(self) -> None:
|
|
63
|
+
self.ctx.logger.info("loaded")
|
|
64
|
+
|
|
65
|
+
async def init(self, session): ...
|
|
66
|
+
async def candidates(self) -> list[Candidate]: ...
|
|
67
|
+
async def ensure_candidates(self, count: int) -> int: ...
|
|
68
|
+
async def complete(self, candidate, messages, model, stream, **kw): ...
|
|
69
|
+
async def close(self) -> None: ...
|
|
70
|
+
|
|
71
|
+
def create_plugin() -> MyPlugin:
|
|
72
|
+
return MyPlugin()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 与 MaiBot SDK 的对应关系
|
|
76
|
+
|
|
77
|
+
| MaiBot | provider-sdk |
|
|
78
|
+
|--------|----------------|
|
|
79
|
+
| `MaiBotPlugin` | `ProviderPlugin` |
|
|
80
|
+
| `create_plugin()` | `create_plugin()` |
|
|
81
|
+
| `_manifest.json` | `_manifest.json` |
|
|
82
|
+
| `on_load` / `on_unload` / `on_config_update` | 相同 |
|
|
83
|
+
| `self.ctx.send` / `gateway` 等 | `self.ctx.config` / `http` / `logger` |
|
|
84
|
+
| `@Tool` / `@Command` | 平台插件以 `PlatformAdapter` 能力为主 |
|
|
85
|
+
|
|
86
|
+
## Host 集成
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from pathlib import Path
|
|
90
|
+
from provider_sdk.integrate import load_platform_plugins, register_loaded_plugins
|
|
91
|
+
|
|
92
|
+
loaded = await load_platform_plugins(Path("plugins"), session)
|
|
93
|
+
register_loaded_plugins(registry, loaded)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
完整说明见 [`docs/guide.md`](docs/guide.md)。
|
|
97
|
+
|
|
98
|
+
## 开发
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install -e ".[dev]"
|
|
102
|
+
pytest -q
|
|
103
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
provider_sdk/__init__.py,sha256=_tfZdsO_EM_-poHePu_o_91h0DGOqdZuy8fLlS7rG28,1168
|
|
2
|
+
provider_sdk/config.py,sha256=pgi9DeDtjX0iv6MR1eEK5tNUwyv9arTy2n_10acDab4,2490
|
|
3
|
+
provider_sdk/context.py,sha256=BF4uT6qQbnmMMnMC-0RSD7AWlKzC6L9Pyc1CNY9LpUc,2250
|
|
4
|
+
provider_sdk/integrate.py,sha256=-dySX5DnEPfrGUxEW3CCvM58j4cxm3WULekwcoCEZpA,1850
|
|
5
|
+
provider_sdk/platform.py,sha256=rXPbRPorYYLlmOnQ9o3rMzAD-l0CPkQXszuvwZNCriM,4072
|
|
6
|
+
provider_sdk/plugin.py,sha256=tEMum5CN-xHGsnsc6Tx8CvXGssCxauYTmSyWcXLf8IM,5821
|
|
7
|
+
provider_sdk/capabilities/__init__.py,sha256=WCQqMcNOFGnU9eQsPC4rj0QYpy7rUU4uyNz9IkMrgPs,2235
|
|
8
|
+
provider_sdk/runtime/__init__.py,sha256=dWU2zbaNJOIqrEwAjR-KYDr6B3u4UrVWXHF2siG94lA,282
|
|
9
|
+
provider_sdk/runtime/loader.py,sha256=-87KaYYRu2ly8btOJK9uCC6sqhbkB1FO1YZd1AjgzdQ,9088
|
|
10
|
+
provider_sdk/types/__init__.py,sha256=QpX4KxNbqHL54qgqeGdCmFUWLcIWdBl_edAsxcyUcWk,330
|
|
11
|
+
provider_sdk/types/candidate.py,sha256=MWPK4L9gdGEdxkI4Yzk4TZ5-EBn_7okQ5kHuAJd7y0o,3468
|
|
12
|
+
provider_sdk/types/manifest.py,sha256=WEv3ep_YKyxBo7tV1SaWeJgT8Oq56AKLSCP26VVoqYE,3948
|
|
13
|
+
provider_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=hUIaOfCtc8HRKim1ozd7uE6nPGr4fmfOlY-4rK6lwPs,1103
|
|
14
|
+
provider_sdk-0.1.0.dist-info/METADATA,sha256=6viMG9fsnmHgr308bLg8HGbGjpxel3VSCiYmZMuvJrE,3295
|
|
15
|
+
provider_sdk-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
16
|
+
provider_sdk-0.1.0.dist-info/top_level.txt,sha256=gp3jTnkwS1ISaTgWZLrHyxpiNDiTdcuq-Ze1QXu9ebI,13
|
|
17
|
+
provider_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 provider-sdk contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
provider_sdk
|