uss-aigent 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aigent/__init__.py +68 -0
- aigent/agent.py +192 -0
- aigent/backends/__init__.py +33 -0
- aigent/backends/anthropic.py +190 -0
- aigent/backends/base.py +55 -0
- aigent/backends/openai.py +147 -0
- aigent/exceptions.py +50 -0
- aigent/message.py +48 -0
- aigent/response.py +37 -0
- aigent/session.py +326 -0
- aigent/tool.py +158 -0
- uss_aigent-0.1.1.dist-info/METADATA +12 -0
- uss_aigent-0.1.1.dist-info/RECORD +16 -0
- uss_aigent-0.1.1.dist-info/WHEEL +4 -0
- uss_aigent-0.1.1.dist-info/licenses/LICENSE +201 -0
- uss_aigent-0.1.1.dist-info/licenses/NOTICE +4 -0
aigent/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Copyright 2026 樊少冰
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Aigent — 渐进式 LLM API 库
|
|
17
|
+
|
|
18
|
+
用法速览:
|
|
19
|
+
|
|
20
|
+
from aigent import Aigent, tool, Message
|
|
21
|
+
|
|
22
|
+
# ── 第一层:聊天 ──
|
|
23
|
+
agent = Aigent(api_type="openai", system="你是专业翻译")
|
|
24
|
+
with agent.session() as s:
|
|
25
|
+
s.chat("翻成英文")
|
|
26
|
+
|
|
27
|
+
# ── 第二层:工具 ──
|
|
28
|
+
@tool
|
|
29
|
+
def get_weather(city: str) -> str:
|
|
30
|
+
\"\"\"查询天气\"\"\"
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
with agent.session(tools=[get_weather]) as s:
|
|
34
|
+
s.chat("北京天气怎么样?")
|
|
35
|
+
|
|
36
|
+
# ── 第三层:完全控制 ──
|
|
37
|
+
resp = agent.raw([Message.user("你好")], max_tokens=100)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from .agent import Aigent
|
|
41
|
+
from .tool import tool, Tool, ToolParam, ToolCall
|
|
42
|
+
from .message import Message
|
|
43
|
+
from .response import Response, Usage
|
|
44
|
+
from .exceptions import (
|
|
45
|
+
LLMError,
|
|
46
|
+
AuthenticationError,
|
|
47
|
+
RateLimitError,
|
|
48
|
+
APIError,
|
|
49
|
+
ConnectionError,
|
|
50
|
+
TimeoutError,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"Aigent",
|
|
55
|
+
"tool",
|
|
56
|
+
"Tool",
|
|
57
|
+
"ToolParam",
|
|
58
|
+
"ToolCall",
|
|
59
|
+
"Message",
|
|
60
|
+
"Response",
|
|
61
|
+
"Usage",
|
|
62
|
+
"LLMError",
|
|
63
|
+
"AuthenticationError",
|
|
64
|
+
"RateLimitError",
|
|
65
|
+
"APIError",
|
|
66
|
+
"ConnectionError",
|
|
67
|
+
"TimeoutError",
|
|
68
|
+
]
|
aigent/agent.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Copyright 2026 樊少冰
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Aigent 入口类
|
|
17
|
+
|
|
18
|
+
from aigent import Aigent
|
|
19
|
+
|
|
20
|
+
agent = Aigent(api_type="openai")
|
|
21
|
+
with agent.session() as s:
|
|
22
|
+
s.chat("你好")
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import json
|
|
27
|
+
from typing import Iterator
|
|
28
|
+
|
|
29
|
+
import httpx
|
|
30
|
+
|
|
31
|
+
from .session import Session
|
|
32
|
+
from .response import Response
|
|
33
|
+
from .backends import get_backend
|
|
34
|
+
from .exceptions import ConnectionError, TimeoutError
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Aigent:
|
|
38
|
+
"""LLM 助手入口。
|
|
39
|
+
|
|
40
|
+
持有 API 配置(api_type、api_key、model 等),
|
|
41
|
+
通过 session() 创建对话,通过 raw() 发送底层请求。
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
api_type: str = "openai",
|
|
47
|
+
api_key: str | None = None,
|
|
48
|
+
base_url: str | None = None,
|
|
49
|
+
model: str | None = None,
|
|
50
|
+
system: str | None = None,
|
|
51
|
+
timeout: float = 60.0,
|
|
52
|
+
max_retries: int = 2,
|
|
53
|
+
default_headers: dict | None = None,
|
|
54
|
+
):
|
|
55
|
+
"""
|
|
56
|
+
参数:
|
|
57
|
+
- api_type: "openai" | "anthropic"
|
|
58
|
+
- api_key: 不传时自动从环境变量读取(OPENAI_API_KEY / ANTHROPIC_API_KEY)
|
|
59
|
+
- base_url: 不传时按 api_type 取默认值
|
|
60
|
+
- model: 不传时按 api_type 取默认值
|
|
61
|
+
- system: 系统提示词,绑定到 agent 级别,所有 session 继承
|
|
62
|
+
- timeout: HTTP 请求超时(秒)
|
|
63
|
+
- max_retries: 失败重试次数
|
|
64
|
+
- default_headers: 附加的 HTTP 请求头(如 Azure 的 api-key)
|
|
65
|
+
"""
|
|
66
|
+
self._api_type = api_type
|
|
67
|
+
self._backend_cls = get_backend(api_type)
|
|
68
|
+
env_map = self._backend_cls.env_key_map
|
|
69
|
+
|
|
70
|
+
self._api_key = api_key or os.environ.get(env_map["api_key"])
|
|
71
|
+
self._base_url = (
|
|
72
|
+
base_url
|
|
73
|
+
or os.environ.get(env_map.get("base_url", ""))
|
|
74
|
+
or self._backend_cls.default_base_url
|
|
75
|
+
)
|
|
76
|
+
self._model = (
|
|
77
|
+
model
|
|
78
|
+
or os.environ.get(env_map.get("model", ""))
|
|
79
|
+
or self._backend_cls.default_model
|
|
80
|
+
)
|
|
81
|
+
self._system = system
|
|
82
|
+
self._timeout = timeout
|
|
83
|
+
self._max_retries = max_retries
|
|
84
|
+
self._default_headers = dict(default_headers or {})
|
|
85
|
+
|
|
86
|
+
self._client = httpx.Client(timeout=self._timeout)
|
|
87
|
+
|
|
88
|
+
def session(self, **overrides) -> Session:
|
|
89
|
+
"""创建对话 Session,返回上下文管理器。
|
|
90
|
+
|
|
91
|
+
用法:
|
|
92
|
+
with agent.session() as s:
|
|
93
|
+
s.chat("你好")
|
|
94
|
+
|
|
95
|
+
with agent.session(temperature=0.7, tools=[...]) as s:
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
可通过 overrides 覆盖 agent 级别的任何配置。
|
|
99
|
+
"""
|
|
100
|
+
system = overrides.pop("system", self._system)
|
|
101
|
+
return Session(self, system=system, **overrides)
|
|
102
|
+
|
|
103
|
+
def raw(self, messages: list[dict], **kwargs) -> Response:
|
|
104
|
+
"""直接发送消息列表,返回结构化 Response。
|
|
105
|
+
|
|
106
|
+
供高级用户手动编排消息时使用:
|
|
107
|
+
resp = agent.raw([Message.user("你好")], max_tokens=100)
|
|
108
|
+
"""
|
|
109
|
+
backend = self._backend_cls()
|
|
110
|
+
tools = kwargs.pop("_tools", None)
|
|
111
|
+
body = backend.build_request(messages, tools=tools, model=self._model, **kwargs)
|
|
112
|
+
resp_data = self._send(body)
|
|
113
|
+
return backend.parse_response(resp_data)
|
|
114
|
+
|
|
115
|
+
def _build_headers(self) -> dict:
|
|
116
|
+
"""构造 HTTP 请求头"""
|
|
117
|
+
headers = {"Content-Type": "application/json"}
|
|
118
|
+
headers.update(self._default_headers)
|
|
119
|
+
if self._api_type == "openai":
|
|
120
|
+
if self._api_key:
|
|
121
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
122
|
+
elif self._api_type == "anthropic":
|
|
123
|
+
if self._api_key:
|
|
124
|
+
headers["x-api-key"] = self._api_key
|
|
125
|
+
headers["anthropic-version"] = "2023-06-01"
|
|
126
|
+
return headers
|
|
127
|
+
|
|
128
|
+
def _send(self, body: dict) -> dict:
|
|
129
|
+
"""发送非流式 HTTP 请求,返回解析后的 JSON"""
|
|
130
|
+
backend = self._backend_cls()
|
|
131
|
+
url = f"{self._base_url.rstrip('/')}{backend.endpoint}"
|
|
132
|
+
headers = self._build_headers()
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
resp = self._client.post(url, json=body, headers=headers)
|
|
136
|
+
except httpx.TimeoutException:
|
|
137
|
+
raise TimeoutError("请求超时")
|
|
138
|
+
except httpx.NetworkError as e:
|
|
139
|
+
raise ConnectionError(str(e))
|
|
140
|
+
|
|
141
|
+
if resp.status_code >= 400:
|
|
142
|
+
err_body = {}
|
|
143
|
+
try:
|
|
144
|
+
err_body = resp.json()
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
raise backend.map_error(resp.status_code, err_body)
|
|
148
|
+
|
|
149
|
+
return resp.json()
|
|
150
|
+
|
|
151
|
+
def _send_stream(self, body: dict) -> Iterator[str]:
|
|
152
|
+
"""发送流式 HTTP 请求,逐 token yield"""
|
|
153
|
+
backend = self._backend_cls()
|
|
154
|
+
url = f"{self._base_url.rstrip('/')}{backend.endpoint}"
|
|
155
|
+
headers = self._build_headers()
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
with self._client.stream("POST", url, json=body, headers=headers) as resp:
|
|
159
|
+
if resp.status_code >= 400:
|
|
160
|
+
err_body = {}
|
|
161
|
+
try:
|
|
162
|
+
err_body = resp.json()
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
raise backend.map_error(resp.status_code, err_body)
|
|
166
|
+
|
|
167
|
+
for line in resp.iter_lines():
|
|
168
|
+
line = line.strip()
|
|
169
|
+
if not line:
|
|
170
|
+
continue
|
|
171
|
+
# OpenAI: "data: {...}" or "data: [DONE]"
|
|
172
|
+
# Anthropic: "data: {...}" (event type in JSON)
|
|
173
|
+
if not line.startswith("data: "):
|
|
174
|
+
continue
|
|
175
|
+
data = line[6:]
|
|
176
|
+
if data == "[DONE]":
|
|
177
|
+
break
|
|
178
|
+
try:
|
|
179
|
+
chunk = json.loads(data)
|
|
180
|
+
except json.JSONDecodeError:
|
|
181
|
+
continue
|
|
182
|
+
token = backend.parse_stream_chunk(chunk)
|
|
183
|
+
if token:
|
|
184
|
+
yield token
|
|
185
|
+
except httpx.TimeoutException:
|
|
186
|
+
raise TimeoutError("请求超时")
|
|
187
|
+
except httpx.NetworkError as e:
|
|
188
|
+
raise ConnectionError(str(e))
|
|
189
|
+
|
|
190
|
+
def close(self) -> None:
|
|
191
|
+
"""关闭 HTTP 客户端"""
|
|
192
|
+
self._client.close()
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Copyright 2026 樊少冰
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""后端适配器注册表"""
|
|
17
|
+
|
|
18
|
+
from .openai import OpenAIBackend
|
|
19
|
+
from .anthropic import AnthropicBackend
|
|
20
|
+
|
|
21
|
+
_registry: dict[str, type] = {
|
|
22
|
+
"openai": OpenAIBackend,
|
|
23
|
+
"anthropic": AnthropicBackend,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_backend(api_type: str):
|
|
28
|
+
"""根据 api_type 获取后端类;未注册时抛出 ValueError"""
|
|
29
|
+
if api_type not in _registry:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
f"不支持的 api_type: '{api_type}',可选值: {list(_registry.keys())}"
|
|
32
|
+
)
|
|
33
|
+
return _registry[api_type]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Copyright 2026 樊少冰
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Anthropic Messages API 适配器"""
|
|
17
|
+
|
|
18
|
+
from .base import BaseBackend
|
|
19
|
+
from ..response import Response, Usage
|
|
20
|
+
from ..tool import ToolCall
|
|
21
|
+
from ..exceptions import (
|
|
22
|
+
AuthenticationError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
APIError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AnthropicBackend(BaseBackend):
|
|
29
|
+
"""适配 Anthropic Messages API"""
|
|
30
|
+
|
|
31
|
+
api_type = "anthropic"
|
|
32
|
+
default_base_url = "https://api.anthropic.com/v1"
|
|
33
|
+
default_model = "claude-sonnet-4-6"
|
|
34
|
+
env_key_map = {
|
|
35
|
+
"api_key": "ANTHROPIC_API_KEY",
|
|
36
|
+
"base_url": "ANTHROPIC_BASE_URL",
|
|
37
|
+
"model": "ANTHROPIC_MODEL",
|
|
38
|
+
}
|
|
39
|
+
endpoint = "/messages"
|
|
40
|
+
|
|
41
|
+
def build_request(self, messages, tools=None, **kwargs) -> dict:
|
|
42
|
+
"""构造 /messages 请求体(需转换消息格式)"""
|
|
43
|
+
anthropic_messages: list[dict] = []
|
|
44
|
+
system_parts: list[str] = []
|
|
45
|
+
|
|
46
|
+
for msg in messages:
|
|
47
|
+
role = msg.get("role", "")
|
|
48
|
+
content = msg.get("content", "")
|
|
49
|
+
|
|
50
|
+
if role == "system":
|
|
51
|
+
system_parts.append(content)
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
if role == "tool":
|
|
55
|
+
# 工具结果 → user message with tool_result content block
|
|
56
|
+
anthropic_messages.append({
|
|
57
|
+
"role": "user",
|
|
58
|
+
"content": [{
|
|
59
|
+
"type": "tool_result",
|
|
60
|
+
"tool_use_id": msg.get("tool_call_id", ""),
|
|
61
|
+
"content": content,
|
|
62
|
+
}],
|
|
63
|
+
})
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
if role == "assistant" and msg.get("tool_calls"):
|
|
67
|
+
# Assistant 请求工具调用 → content blocks with tool_use
|
|
68
|
+
content_blocks: list[dict] = []
|
|
69
|
+
# 保留 thinking 块(扩展思考模式必须回传)
|
|
70
|
+
for tb in msg.get("thinking_blocks", []):
|
|
71
|
+
content_blocks.append(tb)
|
|
72
|
+
if content:
|
|
73
|
+
content_blocks.append({"type": "text", "text": content})
|
|
74
|
+
for tc in msg["tool_calls"]:
|
|
75
|
+
content_blocks.append({
|
|
76
|
+
"type": "tool_use",
|
|
77
|
+
"id": tc["id"],
|
|
78
|
+
"name": tc["name"],
|
|
79
|
+
"input": tc["arguments"],
|
|
80
|
+
})
|
|
81
|
+
anthropic_messages.append({"role": "assistant", "content": content_blocks})
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
if role == "assistant":
|
|
85
|
+
content_blocks = []
|
|
86
|
+
for tb in msg.get("thinking_blocks", []):
|
|
87
|
+
content_blocks.append(tb)
|
|
88
|
+
content_blocks.append({"type": "text", "text": content})
|
|
89
|
+
anthropic_messages.append({
|
|
90
|
+
"role": "assistant",
|
|
91
|
+
"content": content_blocks,
|
|
92
|
+
})
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
# user or other roles — pass through
|
|
96
|
+
anthropic_messages.append({"role": role, "content": content})
|
|
97
|
+
|
|
98
|
+
body: dict = {
|
|
99
|
+
"model": kwargs.pop("model", self.default_model),
|
|
100
|
+
"max_tokens": kwargs.pop("max_tokens", 4096),
|
|
101
|
+
"messages": anthropic_messages,
|
|
102
|
+
**kwargs,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if system_parts:
|
|
106
|
+
body["system"] = "\n\n".join(system_parts)
|
|
107
|
+
|
|
108
|
+
if tools:
|
|
109
|
+
body["tools"] = [self._convert_tool(t) for t in tools]
|
|
110
|
+
|
|
111
|
+
return body
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def _convert_tool(t) -> dict:
|
|
115
|
+
"""将 Tool.to_dict() (OpenAI 格式) 转为 Anthropic 格式"""
|
|
116
|
+
od = t.to_dict()
|
|
117
|
+
func = od["function"]
|
|
118
|
+
params = func["parameters"]
|
|
119
|
+
return {
|
|
120
|
+
"name": func["name"],
|
|
121
|
+
"description": func["description"],
|
|
122
|
+
"input_schema": {
|
|
123
|
+
"type": "object",
|
|
124
|
+
"properties": params.get("properties", {}),
|
|
125
|
+
"required": params.get("required", []),
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
def parse_response(self, raw: dict) -> Response:
|
|
130
|
+
"""解析 Anthropic message 响应为 Response"""
|
|
131
|
+
content_blocks = raw.get("content", [])
|
|
132
|
+
text_parts: list[str] = []
|
|
133
|
+
tool_calls: list[ToolCall] = []
|
|
134
|
+
thinking_blocks: list[dict] = []
|
|
135
|
+
|
|
136
|
+
for block in content_blocks:
|
|
137
|
+
block_type = block.get("type", "")
|
|
138
|
+
if block_type == "text":
|
|
139
|
+
text_parts.append(block.get("text", ""))
|
|
140
|
+
elif block_type == "thinking":
|
|
141
|
+
thinking_blocks.append(block)
|
|
142
|
+
elif block_type == "tool_use":
|
|
143
|
+
tool_calls.append(
|
|
144
|
+
ToolCall(
|
|
145
|
+
id=block.get("id", ""),
|
|
146
|
+
name=block.get("name", ""),
|
|
147
|
+
arguments=block.get("input", {}),
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
content = "\n".join(text_parts) if text_parts else None
|
|
152
|
+
|
|
153
|
+
usage = None
|
|
154
|
+
if "usage" in raw:
|
|
155
|
+
u = raw["usage"]
|
|
156
|
+
usage = Usage(
|
|
157
|
+
prompt_tokens=u.get("input_tokens", 0),
|
|
158
|
+
completion_tokens=u.get("output_tokens", 0),
|
|
159
|
+
total_tokens=u.get("input_tokens", 0) + u.get("output_tokens", 0),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return Response(
|
|
163
|
+
content=content,
|
|
164
|
+
tool_calls=tool_calls,
|
|
165
|
+
thinking_blocks=thinking_blocks,
|
|
166
|
+
usage=usage,
|
|
167
|
+
model=raw.get("model", ""),
|
|
168
|
+
finish_reason=raw.get("stop_reason"),
|
|
169
|
+
raw=raw,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def parse_stream_chunk(self, chunk: dict) -> str | None:
|
|
173
|
+
"""从 SSE event 中提取 text delta"""
|
|
174
|
+
event_type = chunk.get("type", "")
|
|
175
|
+
if event_type == "content_block_delta":
|
|
176
|
+
delta = chunk.get("delta", {})
|
|
177
|
+
if delta.get("type") == "text_delta":
|
|
178
|
+
return delta.get("text")
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
def map_error(self, status_code: int, body: dict) -> Exception:
|
|
182
|
+
"""映射 Anthropic 错误码"""
|
|
183
|
+
err = body.get("error", {})
|
|
184
|
+
msg = err.get("message", str(body))
|
|
185
|
+
if status_code == 401:
|
|
186
|
+
return AuthenticationError(msg)
|
|
187
|
+
elif status_code == 429:
|
|
188
|
+
return RateLimitError(msg)
|
|
189
|
+
else:
|
|
190
|
+
return APIError(status_code, msg, body)
|
aigent/backends/base.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Copyright 2026 樊少冰
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""后端适配器抽象基类"""
|
|
17
|
+
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
|
|
20
|
+
from ..response import Response
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BaseBackend(ABC):
|
|
24
|
+
"""API 后端的抽象基类。
|
|
25
|
+
|
|
26
|
+
每个子类负责:
|
|
27
|
+
- 将统一的消息格式转换为 API 特定的请求格式
|
|
28
|
+
- 将 API 特定的响应转换为统一的 Response
|
|
29
|
+
- 将 API 特定的错误码转换为 LLMError 子类
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
api_type: str
|
|
33
|
+
default_base_url: str
|
|
34
|
+
default_model: str
|
|
35
|
+
env_key_map: dict # {"api_key": "OPENAI_API_KEY", "base_url": "OPENAI_BASE_URL", "model": "OPENAI_MODEL"}
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def build_request(self, messages: list[dict], tools: list | None = None, **kwargs) -> dict:
|
|
39
|
+
"""将统一消息列表 + 工具转换为 API 请求体"""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def parse_response(self, raw: dict) -> Response:
|
|
44
|
+
"""将 API 原始响应转换为统一的 Response"""
|
|
45
|
+
...
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def parse_stream_chunk(self, chunk: dict) -> str | None:
|
|
49
|
+
"""从 SSE chunk 中提取 token 文本;无内容时返回 None"""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
@abstractmethod
|
|
53
|
+
def map_error(self, status_code: int, body: dict) -> Exception:
|
|
54
|
+
"""将 HTTP 状态码 + 响应体映射为对应的 LLMError 子类"""
|
|
55
|
+
...
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Copyright 2026 樊少冰
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""OpenAI Chat Completions API 适配器"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from .base import BaseBackend
|
|
20
|
+
from ..response import Response, Usage
|
|
21
|
+
from ..tool import ToolCall
|
|
22
|
+
from ..exceptions import (
|
|
23
|
+
AuthenticationError,
|
|
24
|
+
RateLimitError,
|
|
25
|
+
APIError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OpenAIBackend(BaseBackend):
|
|
30
|
+
"""适配 OpenAI Chat Completions API(及所有兼容服务,如 Ollama、DeepSeek 等)"""
|
|
31
|
+
|
|
32
|
+
api_type = "openai"
|
|
33
|
+
default_base_url = "https://api.openai.com/v1"
|
|
34
|
+
default_model = "gpt-4o"
|
|
35
|
+
env_key_map = {
|
|
36
|
+
"api_key": "OPENAI_API_KEY",
|
|
37
|
+
"base_url": "OPENAI_BASE_URL",
|
|
38
|
+
"model": "OPENAI_MODEL",
|
|
39
|
+
}
|
|
40
|
+
endpoint = "/chat/completions"
|
|
41
|
+
|
|
42
|
+
def build_request(self, messages, tools=None, **kwargs) -> dict:
|
|
43
|
+
"""构造 /chat/completions 请求体(需转换消息格式)"""
|
|
44
|
+
openai_messages: list[dict] = []
|
|
45
|
+
|
|
46
|
+
for msg in messages:
|
|
47
|
+
role = msg.get("role", "")
|
|
48
|
+
content = msg.get("content", "")
|
|
49
|
+
|
|
50
|
+
if role == "assistant" and msg.get("tool_calls"):
|
|
51
|
+
# 转换 tool_calls:内部格式 → OpenAI 格式
|
|
52
|
+
openai_tool_calls: list[dict] = []
|
|
53
|
+
for tc in msg["tool_calls"]:
|
|
54
|
+
openai_tool_calls.append({
|
|
55
|
+
"id": tc["id"],
|
|
56
|
+
"type": "function",
|
|
57
|
+
"function": {
|
|
58
|
+
"name": tc["name"],
|
|
59
|
+
"arguments": json.dumps(tc["arguments"], ensure_ascii=False),
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
assistant_msg: dict = {
|
|
63
|
+
"role": "assistant",
|
|
64
|
+
"content": content or None,
|
|
65
|
+
"tool_calls": openai_tool_calls,
|
|
66
|
+
}
|
|
67
|
+
if msg.get("reasoning_content"):
|
|
68
|
+
assistant_msg["reasoning_content"] = msg["reasoning_content"]
|
|
69
|
+
openai_messages.append(assistant_msg)
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
# 剥离 Anthropic 专属字段
|
|
73
|
+
clean_msg = dict(msg)
|
|
74
|
+
clean_msg.pop("thinking_blocks", None)
|
|
75
|
+
openai_messages.append(clean_msg)
|
|
76
|
+
|
|
77
|
+
body: dict = {
|
|
78
|
+
"model": kwargs.pop("model", self.default_model),
|
|
79
|
+
"messages": openai_messages,
|
|
80
|
+
**kwargs,
|
|
81
|
+
}
|
|
82
|
+
if tools:
|
|
83
|
+
body["tools"] = [t.to_dict() for t in tools]
|
|
84
|
+
return body
|
|
85
|
+
|
|
86
|
+
def parse_response(self, raw: dict) -> Response:
|
|
87
|
+
"""解析 OpenAI chat completion 响应为 Response"""
|
|
88
|
+
choice = raw.get("choices", [{}])[0]
|
|
89
|
+
message = choice.get("message", {})
|
|
90
|
+
|
|
91
|
+
content = message.get("content")
|
|
92
|
+
finish_reason = choice.get("finish_reason")
|
|
93
|
+
|
|
94
|
+
tool_calls: list[ToolCall] = []
|
|
95
|
+
raw_tool_calls = message.get("tool_calls", [])
|
|
96
|
+
for tc in raw_tool_calls:
|
|
97
|
+
func = tc.get("function", {})
|
|
98
|
+
try:
|
|
99
|
+
arguments = json.loads(func.get("arguments", "{}"))
|
|
100
|
+
except json.JSONDecodeError:
|
|
101
|
+
arguments = {}
|
|
102
|
+
tool_calls.append(
|
|
103
|
+
ToolCall(
|
|
104
|
+
id=tc.get("id", ""),
|
|
105
|
+
name=func.get("name", ""),
|
|
106
|
+
arguments=arguments,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
usage = None
|
|
111
|
+
if "usage" in raw:
|
|
112
|
+
u = raw["usage"]
|
|
113
|
+
usage = Usage(
|
|
114
|
+
prompt_tokens=u.get("prompt_tokens", 0),
|
|
115
|
+
completion_tokens=u.get("completion_tokens", 0),
|
|
116
|
+
total_tokens=u.get("total_tokens", 0),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
reasoning_content = message.get("reasoning_content")
|
|
120
|
+
|
|
121
|
+
return Response(
|
|
122
|
+
content=content,
|
|
123
|
+
tool_calls=tool_calls,
|
|
124
|
+
reasoning_content=reasoning_content,
|
|
125
|
+
usage=usage,
|
|
126
|
+
model=raw.get("model", ""),
|
|
127
|
+
finish_reason=finish_reason,
|
|
128
|
+
raw=raw,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def parse_stream_chunk(self, chunk: dict) -> str | None:
|
|
132
|
+
"""从 SSE delta 中提取 content"""
|
|
133
|
+
choices = chunk.get("choices", [])
|
|
134
|
+
if not choices:
|
|
135
|
+
return None
|
|
136
|
+
delta = choices[0].get("delta", {})
|
|
137
|
+
return delta.get("content")
|
|
138
|
+
|
|
139
|
+
def map_error(self, status_code: int, body: dict) -> Exception:
|
|
140
|
+
"""映射 OpenAI 错误码"""
|
|
141
|
+
msg = body.get("error", {}).get("message", str(body))
|
|
142
|
+
if status_code == 401:
|
|
143
|
+
return AuthenticationError(msg)
|
|
144
|
+
elif status_code == 429:
|
|
145
|
+
return RateLimitError(msg)
|
|
146
|
+
else:
|
|
147
|
+
return APIError(status_code, msg, body)
|