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.
- neobot_chat-1.0.0/PKG-INFO +82 -0
- neobot_chat-1.0.0/README.md +70 -0
- neobot_chat-1.0.0/pyproject.toml +26 -0
- neobot_chat-1.0.0/pyproject.toml.orig +22 -0
- neobot_chat-1.0.0/src/neobot_chat/__init__.py +73 -0
- neobot_chat-1.0.0/src/neobot_chat/graph/__init__.py +6 -0
- neobot_chat-1.0.0/src/neobot_chat/graph/constants.py +1 -0
- neobot_chat-1.0.0/src/neobot_chat/graph/executor.py +53 -0
- neobot_chat-1.0.0/src/neobot_chat/graph/graph.py +60 -0
- neobot_chat-1.0.0/src/neobot_chat/graph/nodes.py +22 -0
- neobot_chat-1.0.0/src/neobot_chat/graph/types.py +8 -0
- neobot_chat-1.0.0/src/neobot_chat/models.py +192 -0
- neobot_chat-1.0.0/src/neobot_chat/providers/__init__.py +7 -0
- neobot_chat-1.0.0/src/neobot_chat/providers/anthropic.py +322 -0
- neobot_chat-1.0.0/src/neobot_chat/providers/base.py +304 -0
- neobot_chat-1.0.0/src/neobot_chat/providers/deepseek_offical.py +440 -0
- neobot_chat-1.0.0/src/neobot_chat/providers/native_vision.py +221 -0
- neobot_chat-1.0.0/src/neobot_chat/providers/openai.py +230 -0
- neobot_chat-1.0.0/src/neobot_chat/providers/vision.py +128 -0
- neobot_chat-1.0.0/src/neobot_chat/py.typed +0 -0
- neobot_chat-1.0.0/src/neobot_chat/runtime/__init__.py +4 -0
- neobot_chat-1.0.0/src/neobot_chat/runtime/agent.py +355 -0
- neobot_chat-1.0.0/src/neobot_chat/runtime/prompt.py +164 -0
- neobot_chat-1.0.0/src/neobot_chat/runtime/workflow.py +53 -0
- neobot_chat-1.0.0/src/neobot_chat/schema/__init__.py +55 -0
- neobot_chat-1.0.0/src/neobot_chat/schema/exceptions.py +25 -0
- neobot_chat-1.0.0/src/neobot_chat/schema/protocol.py +40 -0
- neobot_chat-1.0.0/src/neobot_chat/schema/types.py +92 -0
- neobot_chat-1.0.0/src/neobot_chat/skills/__init__.py +4 -0
- neobot_chat-1.0.0/src/neobot_chat/skills/inject.py +42 -0
- neobot_chat-1.0.0/src/neobot_chat/skills/registry.py +457 -0
- neobot_chat-1.0.0/src/neobot_chat/tools/__init__.py +15 -0
- neobot_chat-1.0.0/src/neobot_chat/tools/builtin.py +379 -0
- neobot_chat-1.0.0/src/neobot_chat/tools/composite.py +45 -0
- neobot_chat-1.0.0/src/neobot_chat/tools/registry.py +998 -0
- neobot_chat-1.0.0/src/neobot_chat/tools/shell.py +101 -0
- neobot_chat-1.0.0/src/neobot_chat/tools/toolset.py +92 -0
- neobot_chat-1.0.0/src/neobot_chat/utils/__init__.py +4 -0
- neobot_chat-1.0.0/src/neobot_chat/utils/preprocessors.py +21 -0
- neobot_chat-1.0.0/src/neobot_chat/utils/tools.py +9 -0
- 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 @@
|
|
|
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,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"]
|