polyllm-core 0.1.0__tar.gz
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.
- polyllm_core-0.1.0/LICENSE +21 -0
- polyllm_core-0.1.0/PKG-INFO +43 -0
- polyllm_core-0.1.0/README.md +18 -0
- polyllm_core-0.1.0/pyproject.toml +38 -0
- polyllm_core-0.1.0/setup.cfg +4 -0
- polyllm_core-0.1.0/src/polyllm_core/__init__.py +18 -0
- polyllm_core-0.1.0/src/polyllm_core/client.py +239 -0
- polyllm_core-0.1.0/src/polyllm_core/config.py +36 -0
- polyllm_core-0.1.0/src/polyllm_core/errors.py +40 -0
- polyllm_core-0.1.0/src/polyllm_core/params.py +107 -0
- polyllm_core-0.1.0/src/polyllm_core/py.typed +0 -0
- polyllm_core-0.1.0/src/polyllm_core/registry.py +96 -0
- polyllm_core-0.1.0/src/polyllm_core/types.py +212 -0
- polyllm_core-0.1.0/src/polyllm_core.egg-info/PKG-INFO +43 -0
- polyllm_core-0.1.0/src/polyllm_core.egg-info/SOURCES.txt +16 -0
- polyllm_core-0.1.0/src/polyllm_core.egg-info/dependency_links.txt +1 -0
- polyllm_core-0.1.0/src/polyllm_core.egg-info/top_level.txt +1 -0
- polyllm_core-0.1.0/tests/test_core.py +158 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PolyLLM 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,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: polyllm-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PolyLLM Python core: unified protocol, registry, and parameter policy.
|
|
5
|
+
Author: PolyLLM contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://polyllm.raingpt.top
|
|
8
|
+
Project-URL: Repository, https://github.com/you-want/PolyLLM
|
|
9
|
+
Project-URL: Issues, https://github.com/you-want/PolyLLM/issues
|
|
10
|
+
Keywords: llm,polyllm,plugins
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
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: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# PolyLLM Core for Python
|
|
27
|
+
|
|
28
|
+
PolyLLM Python 版核心包提供统一请求/响应协议、模型注册表、别名解析、参数策略和插件客户端。核心不内置任何厂商逻辑,所有供应商能力都由独立插件提供。
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from polyllm_core import create_llm
|
|
32
|
+
from polyllm_openai import OpenAIPlugin
|
|
33
|
+
|
|
34
|
+
llm = create_llm(
|
|
35
|
+
plugins=[OpenAIPlugin()],
|
|
36
|
+
providers={"openai": {"api_key": "..."}},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
response = await llm.chat(
|
|
40
|
+
"openai:gpt-4o-mini",
|
|
41
|
+
messages=[{"role": "user", "content": "你好"}],
|
|
42
|
+
)
|
|
43
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# PolyLLM Core for Python
|
|
2
|
+
|
|
3
|
+
PolyLLM Python 版核心包提供统一请求/响应协议、模型注册表、别名解析、参数策略和插件客户端。核心不内置任何厂商逻辑,所有供应商能力都由独立插件提供。
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from polyllm_core import create_llm
|
|
7
|
+
from polyllm_openai import OpenAIPlugin
|
|
8
|
+
|
|
9
|
+
llm = create_llm(
|
|
10
|
+
plugins=[OpenAIPlugin()],
|
|
11
|
+
providers={"openai": {"api_key": "..."}},
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
response = await llm.chat(
|
|
15
|
+
"openai:gpt-4o-mini",
|
|
16
|
+
messages=[{"role": "user", "content": "你好"}],
|
|
17
|
+
)
|
|
18
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "polyllm-core"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "PolyLLM Python core: unified protocol, registry, and parameter policy."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = []
|
|
12
|
+
license = "MIT"
|
|
13
|
+
license-files = ["LICENSE"]
|
|
14
|
+
authors = [{ name = "PolyLLM contributors" }]
|
|
15
|
+
keywords = ["llm", "polyllm", "plugins"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://polyllm.raingpt.top"
|
|
31
|
+
Repository = "https://github.com/you-want/PolyLLM"
|
|
32
|
+
Issues = "https://github.com/you-want/PolyLLM/issues"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.package-data]
|
|
38
|
+
polyllm_core = ["py.typed"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from .config import resolve_provider_runtime
|
|
2
|
+
from .client import PolyLLM, create_llm
|
|
3
|
+
from .errors import AmbiguousModelError, PolyLLMError, UnknownModelError
|
|
4
|
+
from .params import ParamValidationResult, validate_and_map_params
|
|
5
|
+
from .registry import ModelRegistry
|
|
6
|
+
from .types import *
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AmbiguousModelError",
|
|
10
|
+
"ModelRegistry",
|
|
11
|
+
"ParamValidationResult",
|
|
12
|
+
"PolyLLM",
|
|
13
|
+
"PolyLLMError",
|
|
14
|
+
"UnknownModelError",
|
|
15
|
+
"create_llm",
|
|
16
|
+
"resolve_provider_runtime",
|
|
17
|
+
"validate_and_map_params",
|
|
18
|
+
]
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, AsyncIterator, Mapping
|
|
4
|
+
|
|
5
|
+
from .config import resolve_provider_runtime
|
|
6
|
+
from .errors import PolyLLMError, UnknownModelError
|
|
7
|
+
from .params import validate_and_map_params
|
|
8
|
+
from .registry import ModelRegistry
|
|
9
|
+
from .types import (
|
|
10
|
+
ChatContext,
|
|
11
|
+
ClientConfig,
|
|
12
|
+
LLMClient,
|
|
13
|
+
LLMPlugin,
|
|
14
|
+
ModelSpec,
|
|
15
|
+
ProviderConfig,
|
|
16
|
+
UnifiedContentPart,
|
|
17
|
+
UnifiedMessage,
|
|
18
|
+
UnifiedRequest,
|
|
19
|
+
UnifiedResponse,
|
|
20
|
+
UnifiedStreamChunk,
|
|
21
|
+
UnifiedTool,
|
|
22
|
+
UnifiedToolCall,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PolyLLM(LLMClient):
|
|
27
|
+
def __init__(self, config: ClientConfig):
|
|
28
|
+
self._config = config
|
|
29
|
+
self._plugins: dict[str, LLMPlugin] = {}
|
|
30
|
+
self._registry = ModelRegistry()
|
|
31
|
+
|
|
32
|
+
for plugin in config.plugins:
|
|
33
|
+
if plugin.provider in self._plugins:
|
|
34
|
+
raise PolyLLMError(
|
|
35
|
+
"DUPLICATE_PROVIDER",
|
|
36
|
+
f"供应商插件重复注册: {plugin.provider}",
|
|
37
|
+
{"provider": plugin.provider},
|
|
38
|
+
)
|
|
39
|
+
if plugin.provider not in config.providers:
|
|
40
|
+
raise PolyLLMError(
|
|
41
|
+
"MISSING_PROVIDER_CONFIG",
|
|
42
|
+
f"缺少供应商配置: {plugin.provider}",
|
|
43
|
+
{"provider": plugin.provider},
|
|
44
|
+
)
|
|
45
|
+
self._plugins[plugin.provider] = plugin
|
|
46
|
+
for spec in plugin.models:
|
|
47
|
+
self._registry.register(spec)
|
|
48
|
+
|
|
49
|
+
async def chat(
|
|
50
|
+
self,
|
|
51
|
+
model: str,
|
|
52
|
+
request: Mapping[str, Any] | None = None,
|
|
53
|
+
**kwargs: Any,
|
|
54
|
+
) -> UnifiedResponse:
|
|
55
|
+
request = _merge_request(request, kwargs)
|
|
56
|
+
plugin, spec, prepared = self._prepare(model, request)
|
|
57
|
+
return await plugin.chat(
|
|
58
|
+
prepared,
|
|
59
|
+
self._config.providers[plugin.provider],
|
|
60
|
+
ChatContext(model=spec),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def chat_stream(
|
|
64
|
+
self,
|
|
65
|
+
model: str,
|
|
66
|
+
request: Mapping[str, Any] | None = None,
|
|
67
|
+
**kwargs: Any,
|
|
68
|
+
) -> AsyncIterator[UnifiedStreamChunk]:
|
|
69
|
+
payload = _merge_request(request, kwargs)
|
|
70
|
+
payload["stream"] = True
|
|
71
|
+
plugin, spec, prepared = self._prepare(model, payload)
|
|
72
|
+
try:
|
|
73
|
+
stream = plugin.chat_stream(
|
|
74
|
+
prepared,
|
|
75
|
+
self._config.providers[plugin.provider],
|
|
76
|
+
ChatContext(model=spec),
|
|
77
|
+
)
|
|
78
|
+
except NotImplementedError as error:
|
|
79
|
+
raise PolyLLMError(
|
|
80
|
+
"UNSUPPORTED_PARAMETER",
|
|
81
|
+
f"插件 {plugin.provider} 不支持流式输出",
|
|
82
|
+
{"provider": plugin.provider},
|
|
83
|
+
) from error
|
|
84
|
+
|
|
85
|
+
async for chunk in stream:
|
|
86
|
+
yield chunk
|
|
87
|
+
|
|
88
|
+
def list_models(self) -> list[ModelSpec]:
|
|
89
|
+
return self._registry.list()
|
|
90
|
+
|
|
91
|
+
def get_model_info(self, model: str) -> ModelSpec | None:
|
|
92
|
+
try:
|
|
93
|
+
return self._registry.resolve(model)
|
|
94
|
+
except UnknownModelError:
|
|
95
|
+
if not self._config.allow_unlisted_models:
|
|
96
|
+
return None
|
|
97
|
+
return self._create_dynamic_model(model)
|
|
98
|
+
|
|
99
|
+
def _prepare(
|
|
100
|
+
self,
|
|
101
|
+
model: str,
|
|
102
|
+
request: Mapping[str, Any],
|
|
103
|
+
) -> tuple[LLMPlugin, ModelSpec, UnifiedRequest]:
|
|
104
|
+
try:
|
|
105
|
+
spec = self._registry.resolve(model)
|
|
106
|
+
except UnknownModelError as error:
|
|
107
|
+
if not self._config.allow_unlisted_models:
|
|
108
|
+
raise error
|
|
109
|
+
dynamic_spec = self._create_dynamic_model(model)
|
|
110
|
+
if dynamic_spec is None:
|
|
111
|
+
raise error
|
|
112
|
+
spec = dynamic_spec
|
|
113
|
+
|
|
114
|
+
plugin = self._plugins.get(spec.provider)
|
|
115
|
+
if plugin is None:
|
|
116
|
+
raise PolyLLMError(
|
|
117
|
+
"MISSING_PROVIDER_CONFIG",
|
|
118
|
+
f"模型 {model} 对应的插件未注册",
|
|
119
|
+
{"provider": spec.provider},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
unified = normalize_unified_request({**dict(request), "model": spec.id})
|
|
123
|
+
validated = validate_and_map_params(spec, unified, self._config.param_policy)
|
|
124
|
+
return plugin, spec, validated.request
|
|
125
|
+
|
|
126
|
+
def _create_dynamic_model(self, model: str) -> ModelSpec | None:
|
|
127
|
+
separator = model.find(":")
|
|
128
|
+
if separator <= 0:
|
|
129
|
+
return None
|
|
130
|
+
provider = model[:separator]
|
|
131
|
+
model_id = model[separator + 1 :]
|
|
132
|
+
plugin = self._plugins.get(provider)
|
|
133
|
+
if plugin is None or not model_id:
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
return plugin.create_model_spec(model_id)
|
|
137
|
+
except NotImplementedError:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def create_llm(config: ClientConfig | Mapping[str, Any] | None = None, **kwargs: Any) -> PolyLLM:
|
|
142
|
+
if config is None:
|
|
143
|
+
config = dict(kwargs)
|
|
144
|
+
elif kwargs:
|
|
145
|
+
raise PolyLLMError("INVALID_CONFIG", "create_llm 不能同时传入 config 对象和关键字参数")
|
|
146
|
+
if isinstance(config, Mapping):
|
|
147
|
+
config = ClientConfig(**config)
|
|
148
|
+
if not isinstance(config, ClientConfig):
|
|
149
|
+
raise PolyLLMError("INVALID_CONFIG", "create_llm 配置必须是 ClientConfig 或 dict")
|
|
150
|
+
config = replace_provider_configs(config)
|
|
151
|
+
resolve_runtime_configs(config)
|
|
152
|
+
return PolyLLM(config)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _merge_request(
|
|
156
|
+
request: Mapping[str, Any] | None,
|
|
157
|
+
kwargs: dict[str, Any],
|
|
158
|
+
) -> dict[str, Any]:
|
|
159
|
+
payload = dict(request or {})
|
|
160
|
+
payload.update(kwargs)
|
|
161
|
+
return payload
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def replace_provider_configs(config: ClientConfig) -> ClientConfig:
|
|
165
|
+
from dataclasses import replace
|
|
166
|
+
|
|
167
|
+
providers = {
|
|
168
|
+
provider: value if isinstance(value, ProviderConfig) else ProviderConfig(**value)
|
|
169
|
+
for provider, value in config.providers.items()
|
|
170
|
+
}
|
|
171
|
+
return replace(config, providers=providers)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def normalize_unified_request(payload: dict[str, Any]) -> UnifiedRequest:
|
|
175
|
+
payload = dict(payload)
|
|
176
|
+
payload["messages"] = [normalize_message(message) for message in payload.get("messages", [])]
|
|
177
|
+
if payload.get("tools") is not None:
|
|
178
|
+
payload["tools"] = [normalize_tool(tool) for tool in payload["tools"]]
|
|
179
|
+
return UnifiedRequest(**payload)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def normalize_message(value: Any) -> UnifiedMessage:
|
|
183
|
+
if isinstance(value, UnifiedMessage):
|
|
184
|
+
return value
|
|
185
|
+
if not isinstance(value, dict):
|
|
186
|
+
raise PolyLLMError("INVALID_CONFIG", "messages 必须是 UnifiedMessage 或 dict", {"message": value})
|
|
187
|
+
content = value.get("content", "")
|
|
188
|
+
if isinstance(content, list):
|
|
189
|
+
content = [normalize_content_part(part) for part in content]
|
|
190
|
+
tool_calls = value.get("tool_calls") or []
|
|
191
|
+
return UnifiedMessage(
|
|
192
|
+
role=value["role"],
|
|
193
|
+
content=content,
|
|
194
|
+
name=value.get("name"),
|
|
195
|
+
tool_call_id=value.get("tool_call_id"),
|
|
196
|
+
tool_calls=tuple(normalize_tool_call(call) for call in tool_calls),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def normalize_content_part(value: Any) -> UnifiedContentPart:
|
|
201
|
+
if isinstance(value, UnifiedContentPart):
|
|
202
|
+
return value
|
|
203
|
+
if not isinstance(value, dict):
|
|
204
|
+
raise PolyLLMError("INVALID_CONFIG", "content parts 必须是 UnifiedContentPart 或 dict", {"part": value})
|
|
205
|
+
return UnifiedContentPart(
|
|
206
|
+
type=value["type"],
|
|
207
|
+
text=value.get("text"),
|
|
208
|
+
image_url=value.get("image_url") or value.get("imageUrl"),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def normalize_tool(value: Any) -> UnifiedTool:
|
|
213
|
+
if isinstance(value, UnifiedTool):
|
|
214
|
+
return value
|
|
215
|
+
if not isinstance(value, dict):
|
|
216
|
+
raise PolyLLMError("INVALID_CONFIG", "tools 必须是 UnifiedTool 或 dict", {"tool": value})
|
|
217
|
+
return UnifiedTool(type="function", function=value["function"])
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def normalize_tool_call(value: Any) -> UnifiedToolCall:
|
|
221
|
+
if isinstance(value, UnifiedToolCall):
|
|
222
|
+
return value
|
|
223
|
+
if not isinstance(value, dict):
|
|
224
|
+
raise PolyLLMError("INVALID_CONFIG", "tool_calls 必须是 UnifiedToolCall 或 dict", {"toolCall": value})
|
|
225
|
+
return UnifiedToolCall(id=value["id"], type="function", function=value["function"])
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def resolve_runtime_configs(config: ClientConfig) -> dict[str, ProviderConfig]:
|
|
229
|
+
resolved: dict[str, ProviderConfig] = {}
|
|
230
|
+
for provider, provider_config in config.providers.items():
|
|
231
|
+
runtime = resolve_provider_runtime(provider, provider_config)
|
|
232
|
+
resolved[provider] = ProviderConfig(
|
|
233
|
+
api_key=runtime.api_key,
|
|
234
|
+
api_key_env=None,
|
|
235
|
+
base_url=provider_config.base_url,
|
|
236
|
+
headers=provider_config.headers,
|
|
237
|
+
extra=provider_config.extra,
|
|
238
|
+
)
|
|
239
|
+
return resolved
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Mapping
|
|
5
|
+
|
|
6
|
+
from .errors import PolyLLMError
|
|
7
|
+
from .types import ProviderConfig, ProviderRuntime
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resolve_provider_runtime(
|
|
11
|
+
provider: str,
|
|
12
|
+
config: ProviderConfig,
|
|
13
|
+
environment: Mapping[str, str] | None = None,
|
|
14
|
+
) -> ProviderRuntime:
|
|
15
|
+
environment = os.environ if environment is None else environment
|
|
16
|
+
api_key = config.api_key or (environment.get(config.api_key_env) if config.api_key_env else None)
|
|
17
|
+
if not api_key or not api_key.strip():
|
|
18
|
+
source = (
|
|
19
|
+
"providers[].api_key"
|
|
20
|
+
if config.api_key
|
|
21
|
+
else f"环境变量 {config.api_key_env}"
|
|
22
|
+
if config.api_key_env
|
|
23
|
+
else f"{provider} 配置"
|
|
24
|
+
)
|
|
25
|
+
raise PolyLLMError(
|
|
26
|
+
"MISSING_API_KEY",
|
|
27
|
+
f"PolyLLM 无法解析 {provider} 的 API key,来源: {source}",
|
|
28
|
+
{"provider": provider, "source": source},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return ProviderRuntime(
|
|
32
|
+
api_key=api_key.strip(),
|
|
33
|
+
base_url=config.base_url or "",
|
|
34
|
+
headers=dict(config.headers),
|
|
35
|
+
extra=dict(config.extra),
|
|
36
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
ErrorCode = Literal[
|
|
6
|
+
"AMBIGUOUS_MODEL",
|
|
7
|
+
"INVALID_CONFIG",
|
|
8
|
+
"DUPLICATE_MODEL",
|
|
9
|
+
"DUPLICATE_PROVIDER",
|
|
10
|
+
"MISSING_PROVIDER_CONFIG",
|
|
11
|
+
"MISSING_API_KEY",
|
|
12
|
+
"PROVIDER_ERROR",
|
|
13
|
+
"UNKNOWN_MODEL",
|
|
14
|
+
"UNSUPPORTED_PARAMETER",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PolyLLMError(Exception):
|
|
19
|
+
def __init__(self, code: ErrorCode, message: str, details: dict[str, Any] | None = None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.code = code
|
|
22
|
+
self.details = details or {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UnknownModelError(PolyLLMError):
|
|
26
|
+
def __init__(self, model: str, available_models: list[str]):
|
|
27
|
+
super().__init__(
|
|
28
|
+
"UNKNOWN_MODEL",
|
|
29
|
+
f"未知模型: {model}。可用模型: {', '.join(available_models)}",
|
|
30
|
+
{"model": model, "availableModels": available_models},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AmbiguousModelError(PolyLLMError):
|
|
35
|
+
def __init__(self, model: str, candidates: list[str]):
|
|
36
|
+
super().__init__(
|
|
37
|
+
"AMBIGUOUS_MODEL",
|
|
38
|
+
f"模型标识有歧义: {model}。请使用 provider:model,候选: {', '.join(candidates)}",
|
|
39
|
+
{"model": model, "candidates": candidates},
|
|
40
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
from typing import Any, Mapping
|
|
5
|
+
|
|
6
|
+
from .errors import PolyLLMError
|
|
7
|
+
from .types import ModelSpec, ParamPolicy, UnifiedMessage, UnifiedRequest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ParamValidationResult:
|
|
11
|
+
def __init__(self, request: UnifiedRequest, warnings: list[str]):
|
|
12
|
+
self.request = request
|
|
13
|
+
self.warnings = warnings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def validate_and_map_params(
|
|
17
|
+
model: ModelSpec,
|
|
18
|
+
request: UnifiedRequest,
|
|
19
|
+
policy: ParamPolicy = "strict",
|
|
20
|
+
) -> ParamValidationResult:
|
|
21
|
+
warnings: list[str] = []
|
|
22
|
+
|
|
23
|
+
def unsupported(name: str, reason: str) -> bool:
|
|
24
|
+
if policy == "strict":
|
|
25
|
+
raise PolyLLMError(
|
|
26
|
+
"UNSUPPORTED_PARAMETER",
|
|
27
|
+
f"参数不被目标模型支持: {name}。{reason}",
|
|
28
|
+
{"parameter": name, "reason": reason, "policy": policy},
|
|
29
|
+
)
|
|
30
|
+
warnings.append(f"{name} 已忽略: {reason}")
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
def clamp_number(name: str, value: float, minimum: float, maximum: float) -> float:
|
|
34
|
+
if minimum <= value <= maximum:
|
|
35
|
+
return value
|
|
36
|
+
bounded = min(maximum, max(minimum, value))
|
|
37
|
+
if policy == "auto":
|
|
38
|
+
warnings.append(f"{name}={value} 超出范围 [{minimum}, {maximum}],已调整为 {bounded}")
|
|
39
|
+
return bounded
|
|
40
|
+
if policy == "lenient":
|
|
41
|
+
warnings.append(f"{name}={value} 超出范围 [{minimum}, {maximum}],已忽略")
|
|
42
|
+
return minimum
|
|
43
|
+
raise PolyLLMError(
|
|
44
|
+
"UNSUPPORTED_PARAMETER",
|
|
45
|
+
f"{name}={value} 超出范围 [{minimum}, {maximum}]",
|
|
46
|
+
{"parameter": name, "value": value, "min": minimum, "max": maximum},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
stream = request.stream
|
|
50
|
+
if stream and not model.capabilities.streaming:
|
|
51
|
+
stream = unsupported("stream", "该模型不支持流式输出")
|
|
52
|
+
|
|
53
|
+
json_mode = request.json_mode
|
|
54
|
+
if json_mode and not model.capabilities.json_mode:
|
|
55
|
+
json_mode = unsupported("jsonMode", "该模型不支持 JSON mode")
|
|
56
|
+
|
|
57
|
+
tools = list(request.tools)
|
|
58
|
+
if tools and not model.capabilities.function_calling:
|
|
59
|
+
tools = [] if unsupported("tools", "该模型不支持 function calling") else tools
|
|
60
|
+
|
|
61
|
+
messages = list(request.messages)
|
|
62
|
+
if not model.capabilities.system_prompt and any(message.role == "system" for message in messages):
|
|
63
|
+
keep_system = unsupported("messages[role=system]", "该模型不支持 system prompt")
|
|
64
|
+
messages = [message for message in messages if keep_system or message.role != "system"]
|
|
65
|
+
|
|
66
|
+
if not model.capabilities.vision:
|
|
67
|
+
has_image = any(
|
|
68
|
+
not isinstance(message.content, str) and any(part.type == "image_url" for part in message.content)
|
|
69
|
+
for message in messages
|
|
70
|
+
)
|
|
71
|
+
if has_image:
|
|
72
|
+
keep_images = unsupported("messages[content=image_url]", "该模型不支持视觉输入")
|
|
73
|
+
messages = [
|
|
74
|
+
message
|
|
75
|
+
if isinstance(message.content, str) or keep_images
|
|
76
|
+
else replace(message, content=[part for part in message.content if part.type != "image_url"])
|
|
77
|
+
for message in messages
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
temperature = request.temperature
|
|
81
|
+
temperature_range = model.params.get("temperature")
|
|
82
|
+
if temperature is not None and temperature_range:
|
|
83
|
+
temperature = clamp_number("temperature", temperature, temperature_range.min, temperature_range.max)
|
|
84
|
+
|
|
85
|
+
top_p = request.top_p
|
|
86
|
+
top_p_range = model.params.get("top_p")
|
|
87
|
+
if top_p is not None and top_p_range:
|
|
88
|
+
top_p = clamp_number("topP", top_p, top_p_range.min, top_p_range.max)
|
|
89
|
+
|
|
90
|
+
max_tokens = request.max_tokens
|
|
91
|
+
if max_tokens is not None:
|
|
92
|
+
max_tokens_range = model.params.get("max_tokens")
|
|
93
|
+
maximum = max_tokens_range.max if max_tokens_range else model.max_output
|
|
94
|
+
max_tokens = int(clamp_number("maxTokens", max_tokens, 1, maximum))
|
|
95
|
+
|
|
96
|
+
prepared = replace(
|
|
97
|
+
request,
|
|
98
|
+
model=model.id,
|
|
99
|
+
messages=messages,
|
|
100
|
+
stream=stream,
|
|
101
|
+
json_mode=json_mode,
|
|
102
|
+
tools=tools,
|
|
103
|
+
temperature=temperature,
|
|
104
|
+
top_p=top_p,
|
|
105
|
+
max_tokens=max_tokens,
|
|
106
|
+
)
|
|
107
|
+
return ParamValidationResult(prepared, warnings)
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from .errors import AmbiguousModelError, PolyLLMError, UnknownModelError
|
|
7
|
+
from .types import ModelSpec
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _normalize(value: str) -> str:
|
|
11
|
+
return re.sub(r"[-\s_]", "", value.strip().lower())
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ModelRegistry:
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._models: dict[str, ModelSpec] = {}
|
|
17
|
+
self._snapshots: dict[str, ModelSpec] = {}
|
|
18
|
+
self._aliases: dict[str, ModelSpec] = {}
|
|
19
|
+
|
|
20
|
+
def register(self, spec: ModelSpec) -> None:
|
|
21
|
+
if not spec.id or not spec.provider:
|
|
22
|
+
raise PolyLLMError("DUPLICATE_MODEL", "ModelSpec.id 和 ModelSpec.provider 不能为空", {"spec": spec})
|
|
23
|
+
|
|
24
|
+
key = f"{spec.provider}:{spec.id}"
|
|
25
|
+
if key in self._models:
|
|
26
|
+
raise PolyLLMError("DUPLICATE_MODEL", f"模型重复注册: {key}", {"model": key})
|
|
27
|
+
if spec.snapshot:
|
|
28
|
+
snapshot_key = f"{spec.provider}:{spec.snapshot}"
|
|
29
|
+
if snapshot_key in self._snapshots:
|
|
30
|
+
raise PolyLLMError("DUPLICATE_MODEL", f"模型快照重复注册: {snapshot_key}", {"model": snapshot_key})
|
|
31
|
+
self._snapshots[snapshot_key] = spec
|
|
32
|
+
|
|
33
|
+
self._models[key] = spec
|
|
34
|
+
for alias in spec.aliases:
|
|
35
|
+
alias_key = f"{spec.provider}:{alias}"
|
|
36
|
+
if alias_key in self._aliases:
|
|
37
|
+
raise PolyLLMError("DUPLICATE_MODEL", f"模型别名重复注册: {alias_key}", {"alias": alias_key})
|
|
38
|
+
self._aliases[alias_key] = spec
|
|
39
|
+
|
|
40
|
+
def resolve(self, model: str) -> ModelSpec:
|
|
41
|
+
trimmed = model.strip()
|
|
42
|
+
separator = trimmed.find(":")
|
|
43
|
+
explicit_provider = separator > 0 and trimmed[:separator]
|
|
44
|
+
model_name = trimmed[separator + 1 :] if separator > 0 else trimmed
|
|
45
|
+
if not model_name:
|
|
46
|
+
raise UnknownModelError(model, self.list_ids())
|
|
47
|
+
|
|
48
|
+
if explicit_provider:
|
|
49
|
+
provider = explicit_provider.lower()
|
|
50
|
+
candidates = [
|
|
51
|
+
self._snapshots.get(f"{provider}:{model_name}"),
|
|
52
|
+
self._models.get(f"{provider}:{model_name}"),
|
|
53
|
+
self._aliases.get(f"{provider}:{model_name}"),
|
|
54
|
+
]
|
|
55
|
+
exact = next((item for item in candidates if item is not None), None)
|
|
56
|
+
if exact:
|
|
57
|
+
return exact
|
|
58
|
+
|
|
59
|
+
normalized = self._find_normalized(f"{provider}:{model_name}")
|
|
60
|
+
if len(normalized) == 1:
|
|
61
|
+
return normalized[0]
|
|
62
|
+
if len(normalized) > 1:
|
|
63
|
+
raise AmbiguousModelError(model, [self._model_id(item) for item in normalized])
|
|
64
|
+
raise UnknownModelError(model, self.list_ids())
|
|
65
|
+
|
|
66
|
+
for predicate in (
|
|
67
|
+
lambda spec: spec.snapshot == model_name,
|
|
68
|
+
lambda spec: spec.id == model_name,
|
|
69
|
+
lambda spec: model_name in spec.aliases,
|
|
70
|
+
):
|
|
71
|
+
matches = [spec for spec in self._models.values() if predicate(spec)]
|
|
72
|
+
if len(matches) == 1:
|
|
73
|
+
return matches[0]
|
|
74
|
+
if len(matches) > 1:
|
|
75
|
+
raise AmbiguousModelError(model, [self._model_id(spec) for spec in matches])
|
|
76
|
+
|
|
77
|
+
normalized = self._find_normalized(model_name)
|
|
78
|
+
if len(normalized) == 1:
|
|
79
|
+
return normalized[0]
|
|
80
|
+
if len(normalized) > 1:
|
|
81
|
+
raise AmbiguousModelError(model, [self._model_id(spec) for spec in normalized])
|
|
82
|
+
raise UnknownModelError(model, self.list_ids())
|
|
83
|
+
|
|
84
|
+
def list(self) -> List[ModelSpec]:
|
|
85
|
+
return list(self._models.values())
|
|
86
|
+
|
|
87
|
+
def list_ids(self) -> List[str]:
|
|
88
|
+
return [self._model_id(spec) for spec in self._models.values()]
|
|
89
|
+
|
|
90
|
+
def _find_normalized(self, value: str) -> List[ModelSpec]:
|
|
91
|
+
normalized = _normalize(value)
|
|
92
|
+
return [spec for spec in self._models.values() if _normalize(f"{spec.provider}:{spec.id}") == normalized]
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _model_id(spec: ModelSpec) -> str:
|
|
96
|
+
return f"{spec.provider}:{spec.id}"
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, AsyncIterator, Literal, Mapping, Optional
|
|
6
|
+
|
|
7
|
+
JsonRecord = dict[str, Any]
|
|
8
|
+
Role = Literal["system", "user", "assistant", "tool"]
|
|
9
|
+
FinishReason = Literal["stop", "length", "tool_calls", "content_filter", "other"]
|
|
10
|
+
ParamPolicy = Literal["strict", "lenient", "auto"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class UnifiedContentPart:
|
|
15
|
+
type: Literal["text", "image_url"]
|
|
16
|
+
text: Optional[str] = None
|
|
17
|
+
image_url: Optional[dict[str, Any]] = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class UnifiedToolCall:
|
|
22
|
+
id: str
|
|
23
|
+
type: Literal["function"]
|
|
24
|
+
function: dict[str, str]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class UnifiedMessage:
|
|
29
|
+
role: Role
|
|
30
|
+
content: str | tuple[UnifiedContentPart, ...] | list[UnifiedContentPart]
|
|
31
|
+
name: Optional[str] = None
|
|
32
|
+
tool_call_id: Optional[str] = None
|
|
33
|
+
tool_calls: tuple[UnifiedToolCall, ...] = ()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class UnifiedTool:
|
|
38
|
+
type: Literal["function"]
|
|
39
|
+
function: dict[str, Any]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class UnifiedRequest:
|
|
44
|
+
model: str
|
|
45
|
+
messages: tuple[UnifiedMessage, ...] | list[UnifiedMessage]
|
|
46
|
+
temperature: Optional[float] = None
|
|
47
|
+
max_tokens: Optional[int] = None
|
|
48
|
+
top_p: Optional[float] = None
|
|
49
|
+
stream: bool = False
|
|
50
|
+
json_mode: bool = False
|
|
51
|
+
tools: tuple[UnifiedTool, ...] | list[UnifiedTool] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class UnifiedUsage:
|
|
56
|
+
prompt_tokens: int
|
|
57
|
+
completion_tokens: int
|
|
58
|
+
total_tokens: int
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class UnifiedChoice:
|
|
63
|
+
index: int
|
|
64
|
+
message: UnifiedMessage
|
|
65
|
+
finish_reason: FinishReason
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class UnifiedResponse:
|
|
70
|
+
id: str
|
|
71
|
+
model: str
|
|
72
|
+
choices: tuple[UnifiedChoice, ...] | list[UnifiedChoice]
|
|
73
|
+
usage: UnifiedUsage
|
|
74
|
+
provider_metadata: Optional[JsonRecord] = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class UnifiedStreamChunk:
|
|
79
|
+
id: str
|
|
80
|
+
model: str
|
|
81
|
+
delta: dict[str, Any]
|
|
82
|
+
usage: Optional[UnifiedUsage] = None
|
|
83
|
+
finish_reason: Optional[FinishReason] = None
|
|
84
|
+
provider_metadata: Optional[JsonRecord] = None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class NumericParamRange:
|
|
89
|
+
min: float
|
|
90
|
+
max: float
|
|
91
|
+
default: float
|
|
92
|
+
field: Optional[str] = None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class ModelCapabilities:
|
|
97
|
+
streaming: bool
|
|
98
|
+
vision: bool
|
|
99
|
+
function_calling: bool
|
|
100
|
+
json_mode: bool
|
|
101
|
+
system_prompt: bool
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class ModelSpec:
|
|
106
|
+
id: str
|
|
107
|
+
provider: str
|
|
108
|
+
capabilities: ModelCapabilities
|
|
109
|
+
context_window: int
|
|
110
|
+
max_output: int
|
|
111
|
+
aliases: tuple[str, ...] = ()
|
|
112
|
+
version: Optional[str] = None
|
|
113
|
+
snapshot: Optional[str] = None
|
|
114
|
+
is_latest: bool = False
|
|
115
|
+
deprecated: bool = False
|
|
116
|
+
params: Mapping[str, NumericParamRange] = field(default_factory=dict)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class ProviderConfig:
|
|
121
|
+
api_key: Optional[str] = None
|
|
122
|
+
api_key_env: Optional[str] = None
|
|
123
|
+
base_url: Optional[str] = None
|
|
124
|
+
headers: Mapping[str, str] = field(default_factory=dict)
|
|
125
|
+
extra: JsonRecord = field(default_factory=dict)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class ProviderRuntime:
|
|
130
|
+
api_key: str
|
|
131
|
+
base_url: str
|
|
132
|
+
headers: dict[str, str]
|
|
133
|
+
extra: JsonRecord
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class ProviderModelInfo:
|
|
138
|
+
id: str
|
|
139
|
+
display_name: Optional[str] = None
|
|
140
|
+
owned_by: Optional[str] = None
|
|
141
|
+
created_at: Optional[str | int] = None
|
|
142
|
+
metadata: Optional[JsonRecord] = None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class ChatContext:
|
|
147
|
+
model: ModelSpec
|
|
148
|
+
timeout_ms: Optional[int] = None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class LLMPlugin(ABC):
|
|
152
|
+
provider: str
|
|
153
|
+
models: tuple[ModelSpec, ...]
|
|
154
|
+
|
|
155
|
+
@abstractmethod
|
|
156
|
+
async def chat(
|
|
157
|
+
self,
|
|
158
|
+
request: UnifiedRequest,
|
|
159
|
+
config: ProviderConfig,
|
|
160
|
+
context: ChatContext,
|
|
161
|
+
) -> UnifiedResponse:
|
|
162
|
+
raise NotImplementedError
|
|
163
|
+
|
|
164
|
+
def chat_stream(
|
|
165
|
+
self,
|
|
166
|
+
request: UnifiedRequest,
|
|
167
|
+
config: ProviderConfig,
|
|
168
|
+
context: ChatContext,
|
|
169
|
+
) -> AsyncIterator[UnifiedStreamChunk]:
|
|
170
|
+
raise NotImplementedError
|
|
171
|
+
|
|
172
|
+
async def list_models(self, config: ProviderConfig) -> list[ProviderModelInfo]:
|
|
173
|
+
raise NotImplementedError
|
|
174
|
+
|
|
175
|
+
def create_model_spec(self, model_id: str) -> ModelSpec:
|
|
176
|
+
raise NotImplementedError
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True)
|
|
180
|
+
class ClientConfig:
|
|
181
|
+
plugins: list[LLMPlugin]
|
|
182
|
+
providers: dict[str, ProviderConfig]
|
|
183
|
+
param_policy: ParamPolicy = "strict"
|
|
184
|
+
allow_unlisted_models: bool = False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class LLMClient(ABC):
|
|
188
|
+
@abstractmethod
|
|
189
|
+
async def chat(
|
|
190
|
+
self,
|
|
191
|
+
model: str,
|
|
192
|
+
request: Mapping[str, Any] | None = None,
|
|
193
|
+
**kwargs: Any,
|
|
194
|
+
) -> UnifiedResponse:
|
|
195
|
+
raise NotImplementedError
|
|
196
|
+
|
|
197
|
+
@abstractmethod
|
|
198
|
+
def chat_stream(
|
|
199
|
+
self,
|
|
200
|
+
model: str,
|
|
201
|
+
request: Mapping[str, Any] | None = None,
|
|
202
|
+
**kwargs: Any,
|
|
203
|
+
) -> AsyncIterator[UnifiedStreamChunk]:
|
|
204
|
+
raise NotImplementedError
|
|
205
|
+
|
|
206
|
+
@abstractmethod
|
|
207
|
+
def list_models(self) -> list[ModelSpec]:
|
|
208
|
+
raise NotImplementedError
|
|
209
|
+
|
|
210
|
+
@abstractmethod
|
|
211
|
+
def get_model_info(self, model: str) -> Optional[ModelSpec]:
|
|
212
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: polyllm-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PolyLLM Python core: unified protocol, registry, and parameter policy.
|
|
5
|
+
Author: PolyLLM contributors
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://polyllm.raingpt.top
|
|
8
|
+
Project-URL: Repository, https://github.com/you-want/PolyLLM
|
|
9
|
+
Project-URL: Issues, https://github.com/you-want/PolyLLM/issues
|
|
10
|
+
Keywords: llm,polyllm,plugins
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
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: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# PolyLLM Core for Python
|
|
27
|
+
|
|
28
|
+
PolyLLM Python 版核心包提供统一请求/响应协议、模型注册表、别名解析、参数策略和插件客户端。核心不内置任何厂商逻辑,所有供应商能力都由独立插件提供。
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from polyllm_core import create_llm
|
|
32
|
+
from polyllm_openai import OpenAIPlugin
|
|
33
|
+
|
|
34
|
+
llm = create_llm(
|
|
35
|
+
plugins=[OpenAIPlugin()],
|
|
36
|
+
providers={"openai": {"api_key": "..."}},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
response = await llm.chat(
|
|
40
|
+
"openai:gpt-4o-mini",
|
|
41
|
+
messages=[{"role": "user", "content": "你好"}],
|
|
42
|
+
)
|
|
43
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/polyllm_core/__init__.py
|
|
5
|
+
src/polyllm_core/client.py
|
|
6
|
+
src/polyllm_core/config.py
|
|
7
|
+
src/polyllm_core/errors.py
|
|
8
|
+
src/polyllm_core/params.py
|
|
9
|
+
src/polyllm_core/py.typed
|
|
10
|
+
src/polyllm_core/registry.py
|
|
11
|
+
src/polyllm_core/types.py
|
|
12
|
+
src/polyllm_core.egg-info/PKG-INFO
|
|
13
|
+
src/polyllm_core.egg-info/SOURCES.txt
|
|
14
|
+
src/polyllm_core.egg-info/dependency_links.txt
|
|
15
|
+
src/polyllm_core.egg-info/top_level.txt
|
|
16
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
polyllm_core
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
from polyllm_core import (
|
|
5
|
+
AmbiguousModelError,
|
|
6
|
+
ModelRegistry,
|
|
7
|
+
PolyLLMError,
|
|
8
|
+
UnknownModelError,
|
|
9
|
+
create_llm,
|
|
10
|
+
validate_and_map_params,
|
|
11
|
+
)
|
|
12
|
+
from polyllm_core.types import (
|
|
13
|
+
ChatContext,
|
|
14
|
+
LLMPlugin,
|
|
15
|
+
ModelCapabilities,
|
|
16
|
+
ModelSpec,
|
|
17
|
+
NumericParamRange,
|
|
18
|
+
ProviderConfig,
|
|
19
|
+
UnifiedContentPart,
|
|
20
|
+
UnifiedMessage,
|
|
21
|
+
UnifiedRequest,
|
|
22
|
+
UnifiedResponse,
|
|
23
|
+
UnifiedUsage,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def make_spec(provider="test") -> ModelSpec:
|
|
28
|
+
return ModelSpec(
|
|
29
|
+
id="test-model",
|
|
30
|
+
provider=provider,
|
|
31
|
+
aliases=("test", "mini"),
|
|
32
|
+
snapshot="test-model-v1",
|
|
33
|
+
capabilities=ModelCapabilities(
|
|
34
|
+
streaming=True,
|
|
35
|
+
vision=False,
|
|
36
|
+
function_calling=True,
|
|
37
|
+
json_mode=True,
|
|
38
|
+
system_prompt=True,
|
|
39
|
+
),
|
|
40
|
+
params={
|
|
41
|
+
"temperature": NumericParamRange(0, 1, 1),
|
|
42
|
+
"max_tokens": NumericParamRange(16, 128, 64, field="max_tokens"),
|
|
43
|
+
},
|
|
44
|
+
context_window=4096,
|
|
45
|
+
max_output=128,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestPlugin(LLMPlugin):
|
|
50
|
+
provider = "test"
|
|
51
|
+
models = (make_spec(),)
|
|
52
|
+
received: UnifiedRequest | None = None
|
|
53
|
+
|
|
54
|
+
async def chat(self, request, config, context):
|
|
55
|
+
self.received = request
|
|
56
|
+
return UnifiedResponse(
|
|
57
|
+
id="test-response",
|
|
58
|
+
model=request.model,
|
|
59
|
+
choices=[],
|
|
60
|
+
usage=UnifiedUsage(1, 1, 2),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def create_model_spec(self, model_id):
|
|
64
|
+
return ModelSpec(
|
|
65
|
+
id=model_id,
|
|
66
|
+
provider="test",
|
|
67
|
+
capabilities=self.models[0].capabilities,
|
|
68
|
+
context_window=self.models[0].context_window,
|
|
69
|
+
max_output=self.models[0].max_output,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class CoreTests(unittest.TestCase):
|
|
74
|
+
def test_registry_resolution(self):
|
|
75
|
+
registry = ModelRegistry()
|
|
76
|
+
registry.register(make_spec())
|
|
77
|
+
self.assertEqual(registry.resolve("test:test-model").id, "test-model")
|
|
78
|
+
self.assertEqual(registry.resolve("test:test").id, "test-model")
|
|
79
|
+
self.assertEqual(registry.resolve("test:test-model-v1").id, "test-model")
|
|
80
|
+
self.assertEqual(registry.resolve("test:testmodel").id, "test-model")
|
|
81
|
+
|
|
82
|
+
def test_registry_errors(self):
|
|
83
|
+
registry = ModelRegistry()
|
|
84
|
+
registry.register(make_spec())
|
|
85
|
+
registry.register(make_spec("other"))
|
|
86
|
+
with self.assertRaises(UnknownModelError):
|
|
87
|
+
registry.resolve("test:missing")
|
|
88
|
+
with self.assertRaises(AmbiguousModelError):
|
|
89
|
+
registry.resolve("test-model")
|
|
90
|
+
|
|
91
|
+
def test_auto_policy_clamps(self):
|
|
92
|
+
result = validate_and_map_params(
|
|
93
|
+
make_spec(),
|
|
94
|
+
UnifiedRequest(
|
|
95
|
+
model="test-model",
|
|
96
|
+
messages=[UnifiedMessage(role="user", content="hi")],
|
|
97
|
+
temperature=2,
|
|
98
|
+
max_tokens=999,
|
|
99
|
+
),
|
|
100
|
+
"auto",
|
|
101
|
+
)
|
|
102
|
+
self.assertEqual(result.request.temperature, 1)
|
|
103
|
+
self.assertEqual(result.request.max_tokens, 128)
|
|
104
|
+
self.assertEqual(len(result.warnings), 2)
|
|
105
|
+
|
|
106
|
+
def test_strict_policy_rejects_vision(self):
|
|
107
|
+
with self.assertRaises(PolyLLMError):
|
|
108
|
+
validate_and_map_params(
|
|
109
|
+
make_spec(),
|
|
110
|
+
UnifiedRequest(
|
|
111
|
+
model="test-model",
|
|
112
|
+
messages=[
|
|
113
|
+
UnifiedMessage(
|
|
114
|
+
role="user",
|
|
115
|
+
content=[
|
|
116
|
+
UnifiedContentPart(
|
|
117
|
+
type="image_url",
|
|
118
|
+
image_url={"url": "https://example.com/a.png"},
|
|
119
|
+
)
|
|
120
|
+
],
|
|
121
|
+
)
|
|
122
|
+
],
|
|
123
|
+
),
|
|
124
|
+
"strict",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def test_client_routes_chat(self):
|
|
128
|
+
plugin = TestPlugin()
|
|
129
|
+
client = create_llm(
|
|
130
|
+
plugins=[plugin],
|
|
131
|
+
providers={"test": ProviderConfig(api_key="k")},
|
|
132
|
+
)
|
|
133
|
+
response = asyncio.run(client.chat("test:mini", {"messages": [UnifiedMessage(role="user", content="hi")]}))
|
|
134
|
+
self.assertEqual(response.model, "test-model")
|
|
135
|
+
self.assertEqual(plugin.received.messages[0].content, "hi")
|
|
136
|
+
|
|
137
|
+
def test_client_accepts_pythonic_dicts(self):
|
|
138
|
+
client = create_llm(
|
|
139
|
+
plugins=[TestPlugin()],
|
|
140
|
+
providers={"test": {"api_key": "k"}},
|
|
141
|
+
)
|
|
142
|
+
response = asyncio.run(client.chat("test:mini", messages=[{"role": "user", "content": "hi"}]))
|
|
143
|
+
self.assertEqual(response.model, "test-model")
|
|
144
|
+
|
|
145
|
+
def test_unlisted_model_resolution(self):
|
|
146
|
+
client = create_llm(
|
|
147
|
+
plugins=[TestPlugin()],
|
|
148
|
+
providers={"test": ProviderConfig(api_key="k")},
|
|
149
|
+
allow_unlisted_models=True,
|
|
150
|
+
)
|
|
151
|
+
response = asyncio.run(
|
|
152
|
+
client.chat("test:remote-only", {"messages": [UnifiedMessage(role="user", content="hi")]})
|
|
153
|
+
)
|
|
154
|
+
self.assertEqual(response.model, "remote-only")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
unittest.main()
|