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 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)
@@ -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)