neobot-chat 1.0.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.
Files changed (41) hide show
  1. neobot_chat-1.0.0/PKG-INFO +82 -0
  2. neobot_chat-1.0.0/README.md +70 -0
  3. neobot_chat-1.0.0/pyproject.toml +26 -0
  4. neobot_chat-1.0.0/pyproject.toml.orig +22 -0
  5. neobot_chat-1.0.0/src/neobot_chat/__init__.py +73 -0
  6. neobot_chat-1.0.0/src/neobot_chat/graph/__init__.py +6 -0
  7. neobot_chat-1.0.0/src/neobot_chat/graph/constants.py +1 -0
  8. neobot_chat-1.0.0/src/neobot_chat/graph/executor.py +53 -0
  9. neobot_chat-1.0.0/src/neobot_chat/graph/graph.py +60 -0
  10. neobot_chat-1.0.0/src/neobot_chat/graph/nodes.py +22 -0
  11. neobot_chat-1.0.0/src/neobot_chat/graph/types.py +8 -0
  12. neobot_chat-1.0.0/src/neobot_chat/models.py +192 -0
  13. neobot_chat-1.0.0/src/neobot_chat/providers/__init__.py +7 -0
  14. neobot_chat-1.0.0/src/neobot_chat/providers/anthropic.py +322 -0
  15. neobot_chat-1.0.0/src/neobot_chat/providers/base.py +304 -0
  16. neobot_chat-1.0.0/src/neobot_chat/providers/deepseek_offical.py +440 -0
  17. neobot_chat-1.0.0/src/neobot_chat/providers/native_vision.py +221 -0
  18. neobot_chat-1.0.0/src/neobot_chat/providers/openai.py +230 -0
  19. neobot_chat-1.0.0/src/neobot_chat/providers/vision.py +128 -0
  20. neobot_chat-1.0.0/src/neobot_chat/py.typed +0 -0
  21. neobot_chat-1.0.0/src/neobot_chat/runtime/__init__.py +4 -0
  22. neobot_chat-1.0.0/src/neobot_chat/runtime/agent.py +355 -0
  23. neobot_chat-1.0.0/src/neobot_chat/runtime/prompt.py +164 -0
  24. neobot_chat-1.0.0/src/neobot_chat/runtime/workflow.py +53 -0
  25. neobot_chat-1.0.0/src/neobot_chat/schema/__init__.py +55 -0
  26. neobot_chat-1.0.0/src/neobot_chat/schema/exceptions.py +25 -0
  27. neobot_chat-1.0.0/src/neobot_chat/schema/protocol.py +40 -0
  28. neobot_chat-1.0.0/src/neobot_chat/schema/types.py +92 -0
  29. neobot_chat-1.0.0/src/neobot_chat/skills/__init__.py +4 -0
  30. neobot_chat-1.0.0/src/neobot_chat/skills/inject.py +42 -0
  31. neobot_chat-1.0.0/src/neobot_chat/skills/registry.py +457 -0
  32. neobot_chat-1.0.0/src/neobot_chat/tools/__init__.py +15 -0
  33. neobot_chat-1.0.0/src/neobot_chat/tools/builtin.py +379 -0
  34. neobot_chat-1.0.0/src/neobot_chat/tools/composite.py +45 -0
  35. neobot_chat-1.0.0/src/neobot_chat/tools/registry.py +998 -0
  36. neobot_chat-1.0.0/src/neobot_chat/tools/shell.py +101 -0
  37. neobot_chat-1.0.0/src/neobot_chat/tools/toolset.py +92 -0
  38. neobot_chat-1.0.0/src/neobot_chat/utils/__init__.py +4 -0
  39. neobot_chat-1.0.0/src/neobot_chat/utils/preprocessors.py +21 -0
  40. neobot_chat-1.0.0/src/neobot_chat/utils/tools.py +9 -0
  41. neobot_chat-1.0.0/src/neobot_chat/utils/xml.py +122 -0
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.3
2
+ Name: neobot-chat
3
+ Version: 1.0.0
4
+ Summary: Add your description here
5
+ Author: wsrsq, tangtian
6
+ Author-email: wsrsq <wsrsq001@163.com>, tangtian <a14b@126.com>
7
+ Requires-Dist: httpx>=0.27.0
8
+ Requires-Dist: neobot-contracts
9
+ Requires-Dist: pyyaml>=6.0
10
+ Requires-Python: >=3.13
11
+ Description-Content-Type: text/markdown
12
+
13
+ # neobot-chat
14
+
15
+ ## Main-model native vision
16
+
17
+ Native image support is explicit and defaults off; merely selecting a model name
18
+ never enables it. Example application configuration:
19
+
20
+ ```toml
21
+ [models.primary_chat_model]
22
+ provider = "DeepSeek"
23
+ model_name = "deepseek-v4-flash-vision-exp"
24
+ native_vision = true
25
+
26
+ [agent_model]
27
+ main_agent = 0
28
+ ```
29
+
30
+ The fallback route is always `models.vision_model`; no fallback model slot has
31
+ to be selected by hand. Existing configurations keep the text-only behavior
32
+ (`ModelRegistration.native_vision = false`).
33
+
34
+ `RegisteredModel.native_vision` is propagated to DeepSeek/OpenAI/Anthropic
35
+ providers. `build_main_provider` wraps the visual main provider in
36
+ `NativeVisionFallbackProvider`; its read-only `native_vision` and `model`
37
+ properties always describe the active route. A provider/configuration reload
38
+ creates a fresh instance and re-enables the configured visual route.
39
+
40
+ ### Images and fallback
41
+
42
+ The shared message format uses user-role content arrays with text and
43
+ `{"type": "image_url", "image_url": {"url": "https://... or data:image/...;base64,..."}}`.
44
+ DeepSeek requires user-role images. OpenAI-compatible providers retain image
45
+ blocks, and Anthropic converts URL/base64 blocks to its native `image.source`
46
+ format, including images nested inside tool results. The chat layer does not
47
+ upload images through a Files API.
48
+
49
+ A visual request is retried on the configured text provider **only** for an
50
+ explicit HTTP 400/422 image-capability rejection (including content-array
51
+ incompatibility) or a typed local `NativeVisionUnsupportedError`. Invalid image
52
+ URLs/data, size limits, generic validation failures, authentication errors,
53
+ rate limits, transport failures, and server errors do not cause degradation.
54
+ Streaming can retry only before any output has been emitted.
55
+
56
+ Degradation is persistent for the provider instance. Two modes:
57
+
58
+ - **vision-capable fallback** (default when the fallback declares
59
+ `native_vision=True`, i.e. `models.vision_model`): messages and tools pass
60
+ through unchanged, so the fallback still sees the images; logs at **warning**
61
+ level and `provider.native_vision` stays `True`;
62
+ - **text-only fallback** (`strip_images=True`): every fallback request replaces
63
+ image blocks with visible text placeholders, adds a system notice stating the
64
+ model did not see the images, removes `image_context__*` tools, and logs at
65
+ **error** level.
66
+
67
+ Input messages are never mutated. The agent receives structured metadata:
68
+
69
+ ```python
70
+ provider.native_vision # active route capability (True with a vision fallback)
71
+ provider.vision_degradation # reason/from_model/to_model/notice, or None
72
+ response["extensions"]["native_vision_fallback"] # same metadata on fallback responses
73
+ ```
74
+
75
+ Agents should refresh their dynamically available tools after this signal and
76
+ use a separate image-parsing tool when visual evidence is still needed; they
77
+ must not claim the text-only model viewed omitted images. Configuration-time
78
+ failure to create the visual provider also selects the configured fallback with
79
+ an explicit error and the same metadata. An invalid or unavailable fallback
80
+ route is a startup configuration error, not an implicit model selection.
81
+
82
+ DeepSeek protocol reference: <https://api-docs.deepseek.com/zh-cn/guides/vision>.
@@ -0,0 +1,70 @@
1
+ # neobot-chat
2
+
3
+ ## Main-model native vision
4
+
5
+ Native image support is explicit and defaults off; merely selecting a model name
6
+ never enables it. Example application configuration:
7
+
8
+ ```toml
9
+ [models.primary_chat_model]
10
+ provider = "DeepSeek"
11
+ model_name = "deepseek-v4-flash-vision-exp"
12
+ native_vision = true
13
+
14
+ [agent_model]
15
+ main_agent = 0
16
+ ```
17
+
18
+ The fallback route is always `models.vision_model`; no fallback model slot has
19
+ to be selected by hand. Existing configurations keep the text-only behavior
20
+ (`ModelRegistration.native_vision = false`).
21
+
22
+ `RegisteredModel.native_vision` is propagated to DeepSeek/OpenAI/Anthropic
23
+ providers. `build_main_provider` wraps the visual main provider in
24
+ `NativeVisionFallbackProvider`; its read-only `native_vision` and `model`
25
+ properties always describe the active route. A provider/configuration reload
26
+ creates a fresh instance and re-enables the configured visual route.
27
+
28
+ ### Images and fallback
29
+
30
+ The shared message format uses user-role content arrays with text and
31
+ `{"type": "image_url", "image_url": {"url": "https://... or data:image/...;base64,..."}}`.
32
+ DeepSeek requires user-role images. OpenAI-compatible providers retain image
33
+ blocks, and Anthropic converts URL/base64 blocks to its native `image.source`
34
+ format, including images nested inside tool results. The chat layer does not
35
+ upload images through a Files API.
36
+
37
+ A visual request is retried on the configured text provider **only** for an
38
+ explicit HTTP 400/422 image-capability rejection (including content-array
39
+ incompatibility) or a typed local `NativeVisionUnsupportedError`. Invalid image
40
+ URLs/data, size limits, generic validation failures, authentication errors,
41
+ rate limits, transport failures, and server errors do not cause degradation.
42
+ Streaming can retry only before any output has been emitted.
43
+
44
+ Degradation is persistent for the provider instance. Two modes:
45
+
46
+ - **vision-capable fallback** (default when the fallback declares
47
+ `native_vision=True`, i.e. `models.vision_model`): messages and tools pass
48
+ through unchanged, so the fallback still sees the images; logs at **warning**
49
+ level and `provider.native_vision` stays `True`;
50
+ - **text-only fallback** (`strip_images=True`): every fallback request replaces
51
+ image blocks with visible text placeholders, adds a system notice stating the
52
+ model did not see the images, removes `image_context__*` tools, and logs at
53
+ **error** level.
54
+
55
+ Input messages are never mutated. The agent receives structured metadata:
56
+
57
+ ```python
58
+ provider.native_vision # active route capability (True with a vision fallback)
59
+ provider.vision_degradation # reason/from_model/to_model/notice, or None
60
+ response["extensions"]["native_vision_fallback"] # same metadata on fallback responses
61
+ ```
62
+
63
+ Agents should refresh their dynamically available tools after this signal and
64
+ use a separate image-parsing tool when visual evidence is still needed; they
65
+ must not claim the text-only model viewed omitted images. Configuration-time
66
+ failure to create the visual provider also selects the configured fallback with
67
+ an explicit error and the same metadata. An invalid or unavailable fallback
68
+ route is a startup configuration error, not an implicit model selection.
69
+
70
+ DeepSeek protocol reference: <https://api-docs.deepseek.com/zh-cn/guides/vision>.
@@ -0,0 +1,26 @@
1
+ [project]
2
+ name = "neobot-chat"
3
+ version = "1.0.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "httpx>=0.27.0",
9
+ "neobot-contracts",
10
+ "pyyaml>=6.0",
11
+ ]
12
+
13
+ [[project.authors]]
14
+ name = "wsrsq"
15
+ email = "wsrsq001@163.com"
16
+
17
+ [[project.authors]]
18
+ name = "tangtian"
19
+ email = "a14b@126.com"
20
+
21
+ [tool.uv.sources.neobot-contracts]
22
+ workspace = true
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.9.27,<0.10.0"]
26
+ build-backend = "uv_build"
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "neobot-chat"
3
+ version = "1.0.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "wsrsq", email = "wsrsq001@163.com" },
8
+ { name = "tangtian", email = "a14b@126.com" },
9
+ ]
10
+ requires-python = ">=3.13"
11
+ dependencies = [
12
+ "httpx>=0.27.0",
13
+ "neobot-contracts",
14
+ "pyyaml>=6.0",
15
+ ]
16
+
17
+ [tool.uv.sources]
18
+ neobot-contracts = { workspace = true }
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.9.27,<0.10.0"]
22
+ build-backend = "uv_build"
@@ -0,0 +1,73 @@
1
+ from neobot_chat.runtime.agent import Agent
2
+ from neobot_chat.graph import END, CompiledGraph, StateGraph, skill_node
3
+ from neobot_chat.schema.protocol import (
4
+ AgentLike,
5
+ ChatService,
6
+ Runnable,
7
+ StatePreprocessor,
8
+ StreamableRunnable,
9
+ ToolExecutor,
10
+ )
11
+ from neobot_chat.skills import SkillRegistry, build_skill_preprocessor, inject_skills
12
+ from neobot_chat.tools import AgentRegistry, BuiltinTools, CompositeToolExecutor, Toolset, build_builtin_toolset
13
+ from neobot_chat.models import (
14
+ ModelPricing,
15
+ ModelRegistry,
16
+ ModelSettings,
17
+ RegisteredModel,
18
+ create_provider,
19
+ get_model_registry,
20
+ get_registered_model,
21
+ register_model,
22
+ )
23
+ from neobot_chat.schema.types import (
24
+ ChatChunk,
25
+ MessageContent,
26
+ OnEvent,
27
+ State,
28
+ ToolAccessPolicy,
29
+ ToolAccessRule,
30
+ ToolGuardContext,
31
+ )
32
+ from neobot_chat.utils import compose_preprocessors, parse_tool_args
33
+ from neobot_chat.runtime.workflow import Workflow
34
+
35
+ __all__ = [
36
+ "Agent",
37
+ "AgentRegistry",
38
+ "AgentLike",
39
+ "BuiltinTools",
40
+ "build_builtin_toolset",
41
+ "CompositeToolExecutor",
42
+ "ChatService",
43
+ "ChatChunk",
44
+ "MessageContent",
45
+ "ModelPricing",
46
+ "ModelRegistry",
47
+ "ModelSettings",
48
+ "RegisteredModel",
49
+ "CompiledGraph",
50
+ "compose_preprocessors",
51
+ "create_provider",
52
+ "END",
53
+ "build_skill_preprocessor",
54
+ "get_model_registry",
55
+ "get_registered_model",
56
+ "inject_skills",
57
+ "OnEvent",
58
+ "parse_tool_args",
59
+ "register_model",
60
+ "Runnable",
61
+ "StatePreprocessor",
62
+ "skill_node",
63
+ "SkillRegistry",
64
+ "State",
65
+ "StateGraph",
66
+ "ToolAccessPolicy",
67
+ "ToolAccessRule",
68
+ "ToolGuardContext",
69
+ "Toolset",
70
+ "StreamableRunnable",
71
+ "ToolExecutor",
72
+ "Workflow",
73
+ ]
@@ -0,0 +1,6 @@
1
+ from neobot_chat.graph.constants import END
2
+ from neobot_chat.graph.executor import CompiledGraph
3
+ from neobot_chat.graph.graph import StateGraph
4
+ from neobot_chat.graph.nodes import skill_node
5
+
6
+ __all__ = ["END", "CompiledGraph", "StateGraph", "skill_node"]
@@ -0,0 +1 @@
1
+ END = "__end__"
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from neobot_chat.schema.exceptions import GraphError
4
+ from neobot_chat.graph.constants import END
5
+ from neobot_chat.graph.types import StateCondition, StateNode
6
+ from neobot_chat.schema.types import State
7
+
8
+
9
+ class CompiledGraph:
10
+ """编译后的可执行图"""
11
+
12
+ def __init__(
13
+ self,
14
+ nodes: dict[str, StateNode],
15
+ edges: dict[str, str],
16
+ conditional_edges: dict[str, tuple[StateCondition, dict[str, str]]],
17
+ entry_point: str,
18
+ max_steps: int = 100,
19
+ ):
20
+ self._nodes = nodes
21
+ self._edges = edges
22
+ self._conditional_edges = conditional_edges
23
+ self._entry_point = entry_point
24
+ self._max_steps = max_steps
25
+
26
+ def _resolve_next(self, current: str, state: State) -> str | None:
27
+ if current in self._conditional_edges:
28
+ condition, mapping = self._conditional_edges[current]
29
+ key = condition(state)
30
+ if key not in mapping:
31
+ raise GraphError(
32
+ f"Condition on '{current}' returned unmapped key '{key}', "
33
+ f"expected one of {list(mapping)}"
34
+ )
35
+ return mapping[key]
36
+ return self._edges.get(current)
37
+
38
+ async def invoke(self, state: State) -> State:
39
+ current: str | None = self._entry_point
40
+
41
+ for _ in range(self._max_steps):
42
+ if current is None or current == END:
43
+ break
44
+ if current not in self._nodes:
45
+ raise GraphError(f"Unknown node '{current}'")
46
+ state = await self._nodes[current](state)
47
+ current = self._resolve_next(current, state)
48
+ else:
49
+ raise GraphError(
50
+ f"Graph exceeded max steps ({self._max_steps}), possible infinite loop"
51
+ )
52
+
53
+ return state
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from neobot_chat.schema.exceptions import GraphError
4
+ from neobot_chat.graph.constants import END
5
+ from neobot_chat.graph.executor import CompiledGraph
6
+ from neobot_chat.graph.types import StateCondition, StateNode
7
+
8
+
9
+ class StateGraph:
10
+ """状态图构建器"""
11
+
12
+ def __init__(self):
13
+ self._nodes: dict[str, StateNode] = {}
14
+ self._edges: dict[str, str] = {}
15
+ self._conditional_edges: dict[str, tuple[StateCondition, dict[str, str]]] = {}
16
+ self._entry_point: str | None = None
17
+
18
+ def _assert_no_outgoing(self, node: str) -> None:
19
+ if node in self._edges or node in self._conditional_edges:
20
+ raise GraphError(f"Node '{node}' already has outgoing edges")
21
+
22
+ def add_node(self, name: str, func: StateNode) -> None:
23
+ if name in self._nodes:
24
+ raise GraphError(f"Node '{name}' already exists")
25
+ self._nodes[name] = func
26
+
27
+ def add_edge(self, from_node: str, to_node: str) -> None:
28
+ self._assert_no_outgoing(from_node)
29
+ self._edges[from_node] = to_node
30
+
31
+ def add_conditional_edges(
32
+ self, from_node: str, condition: StateCondition, mapping: dict[str, str]
33
+ ) -> None:
34
+ self._assert_no_outgoing(from_node)
35
+ self._conditional_edges[from_node] = (condition, mapping)
36
+
37
+ def set_entry_point(self, node: str) -> None:
38
+ self._entry_point = node
39
+
40
+ def compile(self) -> CompiledGraph:
41
+ if self._entry_point is None:
42
+ raise GraphError("Entry point not set")
43
+ if self._entry_point not in self._nodes:
44
+ raise GraphError(f"Entry point '{self._entry_point}' not found in nodes")
45
+ valid = set(self._nodes) | {END}
46
+ for src, dst in self._edges.items():
47
+ if dst not in valid:
48
+ raise GraphError(f"Edge '{src}' -> '{dst}': target node not found")
49
+ for src, (_, mapping) in self._conditional_edges.items():
50
+ for dst in mapping.values():
51
+ if dst not in valid:
52
+ raise GraphError(
53
+ f"Conditional edge '{src}' -> '{dst}': target node not found"
54
+ )
55
+ return CompiledGraph(
56
+ nodes=self._nodes,
57
+ edges=self._edges,
58
+ conditional_edges=self._conditional_edges,
59
+ entry_point=self._entry_point,
60
+ )
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from neobot_chat.graph.types import StateNode
4
+ from neobot_chat.skills.inject import inject_skills
5
+ from neobot_chat.skills.registry import SkillRegistry
6
+ from neobot_chat.schema.types import State
7
+
8
+
9
+ def skill_node(skills: SkillRegistry) -> StateNode:
10
+ """创建一个 Graph 内置节点,自动匹配 skills 并注入 system prompt
11
+
12
+ 用法::
13
+
14
+ graph.add_node("skills", skill_node(registry))
15
+ graph.add_edge("skills", "agent")
16
+ graph.set_entry_point("skills")
17
+ """
18
+
19
+ async def _node(state: State) -> State:
20
+ return inject_skills(skills, state)
21
+
22
+ return _node
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable
4
+
5
+ from neobot_chat.schema.types import State
6
+
7
+ StateNode = Callable[[State], Awaitable[State]]
8
+ StateCondition = Callable[[State], str]
@@ -0,0 +1,192 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+ from neobot_chat.providers import (
7
+ AnthropicProvider,
8
+ DeepSeekOfficialProvider,
9
+ OpenAIProvider,
10
+ Provider,
11
+ )
12
+ from neobot_chat.schema.exceptions import ValidationError
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ModelPricing:
17
+ """模型价格信息。"""
18
+
19
+ input_price_per_mtokens: float = 0.0
20
+ output_price_per_mtokens: float = 0.0
21
+ cache_hit_price_per_mtokens: float = 0.0
22
+ billing_metric: str = ""
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ModelSettings:
27
+ """模型运行设置。"""
28
+
29
+ temperature: float | None = None
30
+ max_output_tokens: int | None = None
31
+ timeout_seconds: float = 120.0
32
+ top_p: float | None = None
33
+ frequency_penalty: float | None = None
34
+ presence_penalty: float | None = None
35
+ extra_body: dict[str, Any] = field(default_factory=dict)
36
+ #: 生图接口形态:auto / edits / generations(仅生图模型使用)
37
+ image_api: str = "auto"
38
+ #: generations 模式下参考图的 JSON 字段名
39
+ image_reference_param: str = "image"
40
+
41
+
42
+ def _normalize_provider_kind(provider_name: str) -> str:
43
+ normalized = provider_name.strip().casefold().replace("-", "_")
44
+ if normalized in {"anthropic"}:
45
+ return "anthropic"
46
+ if normalized in {"deepseek", "deepseek_offical", "deepseek_official"}:
47
+ return "deepseek"
48
+ if normalized in {"openai"}:
49
+ return "openai"
50
+ # 默认按 OpenAI 兼容接口处理,便于接入自定义平台代理。
51
+ return "openai"
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class RegisteredModel:
56
+ """已注册模型。"""
57
+
58
+ name: str
59
+ description: str
60
+ provider_name: str
61
+ model_name: str
62
+ base_url: str
63
+ api_key: str
64
+ pricing: ModelPricing = field(default_factory=ModelPricing)
65
+ settings: ModelSettings = field(default_factory=ModelSettings)
66
+ native_vision: bool = False
67
+ #: 模型类型(chat / image / vision / tts / other),仅用于展示与分组
68
+ model_type: str = "chat"
69
+ #: 是否跟随系统/环境变量代理;默认 False = 直连
70
+ use_system_proxy: bool = False
71
+ #: 计价脚本名(<数据目录>/Billing/<name>.py);留空 = 走固定计费(spec(4) Part A)
72
+ billing_script: str = ""
73
+ #: 传给计价脚本的参数表(脚本内 ctx["billing_config"])
74
+ billing_config: dict[str, Any] = field(default_factory=dict)
75
+
76
+ @property
77
+ def provider_kind(self) -> str:
78
+ return _normalize_provider_kind(self.provider_name)
79
+
80
+ def create_provider(self) -> Provider:
81
+ """根据注册信息创建 Provider 实例。
82
+
83
+ 返回前注入 ``registered_key``:用量记账必须能区分同一 ``model_name`` 的不同
84
+ 注册条目(spec(4) §4.1.1 / D19);provider 是普通类,可直接挂属性。
85
+ """
86
+ provider = self._build_provider()
87
+ # provider 是普通类(无 __slots__),可直接挂属性
88
+ setattr(provider, "registered_key", self.name)
89
+ return provider
90
+
91
+ def _build_provider(self) -> Provider:
92
+ """按供应商类型构造 Provider(不含 registered_key 注入)。"""
93
+ if not self.base_url:
94
+ raise ValidationError(f"Model '{self.name}' is missing base_url")
95
+ if not self.api_key:
96
+ raise ValidationError(f"Model '{self.name}' is missing api_key")
97
+
98
+ if self.provider_kind == "anthropic":
99
+ # AnthropicProvider 把 max_tokens 收窄成 int,与 Provider 基类的 int|None 不兼容
100
+ return AnthropicProvider( # type: ignore[return-value]
101
+ api_key=self.api_key,
102
+ model=self.model_name,
103
+ native_vision=self.native_vision,
104
+ base_url=self.base_url,
105
+ max_tokens=self.settings.max_output_tokens or 4096,
106
+ timeout=self.settings.timeout_seconds,
107
+ temperature=self.settings.temperature,
108
+ top_p=self.settings.top_p,
109
+ extra_body=self.settings.extra_body,
110
+ use_system_proxy=self.use_system_proxy,
111
+ )
112
+
113
+ if self.provider_kind == "deepseek":
114
+ return DeepSeekOfficialProvider(
115
+ api_key=self.api_key,
116
+ model=self.model_name,
117
+ native_vision=self.native_vision,
118
+ base_url=self.base_url,
119
+ timeout=self.settings.timeout_seconds,
120
+ temperature=self.settings.temperature,
121
+ max_tokens=self.settings.max_output_tokens,
122
+ top_p=self.settings.top_p,
123
+ frequency_penalty=self.settings.frequency_penalty,
124
+ presence_penalty=self.settings.presence_penalty,
125
+ extra_body=self.settings.extra_body,
126
+ use_system_proxy=self.use_system_proxy,
127
+ )
128
+
129
+ return OpenAIProvider(
130
+ api_key=self.api_key,
131
+ model=self.model_name,
132
+ native_vision=self.native_vision,
133
+ base_url=self.base_url,
134
+ timeout=self.settings.timeout_seconds,
135
+ temperature=self.settings.temperature,
136
+ max_tokens=self.settings.max_output_tokens,
137
+ top_p=self.settings.top_p,
138
+ frequency_penalty=self.settings.frequency_penalty,
139
+ presence_penalty=self.settings.presence_penalty,
140
+ extra_body=self.settings.extra_body,
141
+ use_system_proxy=self.use_system_proxy,
142
+ )
143
+
144
+
145
+ class ModelRegistry:
146
+ """模型注册中心。"""
147
+
148
+ def __init__(self) -> None:
149
+ self._models: dict[str, RegisteredModel] = {}
150
+
151
+ @property
152
+ def names(self) -> tuple[str, ...]:
153
+ return tuple(self._models.keys())
154
+
155
+ def clear(self) -> None:
156
+ self._models.clear()
157
+
158
+ def register(self, model: RegisteredModel, *, replace: bool = True) -> None:
159
+ if not replace and model.name in self._models:
160
+ raise ValidationError(f"Model '{model.name}' is already registered")
161
+ self._models[model.name] = model
162
+
163
+ def get(self, name: str) -> RegisteredModel:
164
+ try:
165
+ return self._models[name]
166
+ except KeyError as exc:
167
+ raise ValidationError(f"Model '{name}' is not registered") from exc
168
+
169
+ def create_provider(self, name: str) -> Provider:
170
+ return self.get(name).create_provider()
171
+
172
+ def items(self) -> tuple[tuple[str, RegisteredModel], ...]:
173
+ return tuple(self._models.items())
174
+
175
+
176
+ model_registry = ModelRegistry()
177
+
178
+
179
+ def get_model_registry() -> ModelRegistry:
180
+ return model_registry
181
+
182
+
183
+ def register_model(model: RegisteredModel, *, replace: bool = True) -> None:
184
+ model_registry.register(model, replace=replace)
185
+
186
+
187
+ def get_registered_model(name: str) -> RegisteredModel:
188
+ return model_registry.get(name)
189
+
190
+
191
+ def create_provider(name: str) -> Provider:
192
+ return model_registry.create_provider(name)
@@ -0,0 +1,7 @@
1
+ from neobot_chat.providers.anthropic import AnthropicProvider
2
+ from neobot_chat.providers.base import Provider
3
+ from neobot_chat.providers.deepseek_offical import DeepSeekOfficalProvider, DeepSeekOfficialProvider
4
+ from neobot_chat.providers.openai import OpenAIProvider
5
+ from neobot_chat.providers.native_vision import NativeVisionFallbackProvider
6
+
7
+ __all__ = ["AnthropicProvider", "DeepSeekOfficalProvider", "DeepSeekOfficialProvider", "OpenAIProvider", "Provider", "NativeVisionFallbackProvider"]