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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ 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()