vokit 0.1.4__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.
- vokit/__init__.py +51 -0
- vokit/_api.py +222 -0
- vokit/_client.py +81 -0
- vokit/_config.py +74 -0
- vokit/_errors.py +45 -0
- vokit/_interceptor.py +137 -0
- vokit/_models.py +79 -0
- vokit/_patcher.py +270 -0
- vokit/_providers.py +139 -0
- vokit-0.1.4.dist-info/METADATA +109 -0
- vokit-0.1.4.dist-info/RECORD +13 -0
- vokit-0.1.4.dist-info/WHEEL +5 -0
- vokit-0.1.4.dist-info/top_level.txt +1 -0
vokit/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""vokit — VirtualOffice Agent SDK.
|
|
2
|
+
|
|
3
|
+
一行 ``import vokit`` 完成拦截激活 + API 就绪。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
|
|
12
|
+
# 避免 "No handler found" 警告(Python 库最佳实践)
|
|
13
|
+
logging.getLogger("vokit").addHandler(logging.NullHandler())
|
|
14
|
+
|
|
15
|
+
# 1. 激活 monkey patch + 读取配置
|
|
16
|
+
from vokit._patcher import activate as _activate # noqa: E402
|
|
17
|
+
|
|
18
|
+
_activate()
|
|
19
|
+
|
|
20
|
+
# 2. 暴露平台 API
|
|
21
|
+
from vokit._api import get_resources, report_billing, report_metrics, report_status, submit_output # noqa: E402
|
|
22
|
+
|
|
23
|
+
# 3. 暴露异常
|
|
24
|
+
from vokit._errors import ( # noqa: E402
|
|
25
|
+
VokitAPIError,
|
|
26
|
+
VokitConfigError,
|
|
27
|
+
VokitError,
|
|
28
|
+
VokitHTTPError,
|
|
29
|
+
VokitInterceptError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
# 4. 暴露数据类
|
|
33
|
+
from vokit._models import BillingReport, MetricItem, Resource, TaskStatus # noqa: E402
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"__version__",
|
|
37
|
+
"submit_output",
|
|
38
|
+
"get_resources",
|
|
39
|
+
"report_status",
|
|
40
|
+
"report_metrics",
|
|
41
|
+
"report_billing",
|
|
42
|
+
"Resource",
|
|
43
|
+
"TaskStatus",
|
|
44
|
+
"MetricItem",
|
|
45
|
+
"BillingReport",
|
|
46
|
+
"VokitError",
|
|
47
|
+
"VokitConfigError",
|
|
48
|
+
"VokitAPIError",
|
|
49
|
+
"VokitHTTPError",
|
|
50
|
+
"VokitInterceptError",
|
|
51
|
+
]
|
vokit/_api.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""vokit 平台 API 封装。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from vokit._client import VokitClient
|
|
10
|
+
from vokit._config import get_config
|
|
11
|
+
from vokit._models import BillingReport, MetricItem, Resource, TaskStatus
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("vokit")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _to_task_dict(task: TaskStatus | dict[str, Any]) -> dict[str, Any]:
|
|
17
|
+
"""将 TaskStatus 或 dict 统一转为 dict。"""
|
|
18
|
+
if isinstance(task, TaskStatus):
|
|
19
|
+
return {
|
|
20
|
+
"name": task.name,
|
|
21
|
+
"status": task.status,
|
|
22
|
+
"progress": task.progress,
|
|
23
|
+
}
|
|
24
|
+
return dict(task)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def submit_output(
|
|
28
|
+
output_type: str,
|
|
29
|
+
content: str,
|
|
30
|
+
filename: str | None = None,
|
|
31
|
+
mime_type: str | None = None,
|
|
32
|
+
metadata: dict[str, Any] | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""提交产出物。
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
output_type: 产出物类型,"file" 或 "url"。
|
|
38
|
+
content: 产出物内容。file 类型为 base64 编码的内容,url 类型为资源 URL。
|
|
39
|
+
filename: 文件名,file 类型必填,url 类型可选。
|
|
40
|
+
mime_type: MIME 类型,file 类型必填,url 类型可选。
|
|
41
|
+
metadata: 额外的自定义元信息,会合并到最终 metadata 中。
|
|
42
|
+
"""
|
|
43
|
+
config = get_config()
|
|
44
|
+
|
|
45
|
+
if config.is_dev_mode:
|
|
46
|
+
logger.debug("[Dev] submit_output: type=%s, content=%s", output_type, content[:100])
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
# 校验 file 类型必填参数
|
|
50
|
+
if output_type == "file":
|
|
51
|
+
if not filename:
|
|
52
|
+
raise ValueError("file 类型必须传入 filename")
|
|
53
|
+
if not mime_type:
|
|
54
|
+
raise ValueError("file 类型必须传入 mime_type")
|
|
55
|
+
|
|
56
|
+
# 构建 metadata
|
|
57
|
+
output_metadata: dict[str, Any] = {}
|
|
58
|
+
if output_type == "file":
|
|
59
|
+
output_metadata = {
|
|
60
|
+
"filename": filename,
|
|
61
|
+
"mime_type": mime_type,
|
|
62
|
+
}
|
|
63
|
+
elif output_type == "url":
|
|
64
|
+
if filename:
|
|
65
|
+
output_metadata["filename"] = filename
|
|
66
|
+
if mime_type:
|
|
67
|
+
output_metadata["mime_type"] = mime_type
|
|
68
|
+
|
|
69
|
+
# 合并用户传入的 metadata(函数级参数优先级更高)
|
|
70
|
+
if metadata:
|
|
71
|
+
for k, v in metadata.items():
|
|
72
|
+
output_metadata.setdefault(k, v)
|
|
73
|
+
|
|
74
|
+
body = {
|
|
75
|
+
"output_type": output_type,
|
|
76
|
+
"content": content,
|
|
77
|
+
"metadata": output_metadata,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
with VokitClient() as client:
|
|
81
|
+
client.post("/api/gateway/output/submit", body=body)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_resources() -> list[Resource]:
|
|
85
|
+
"""查询工位资源。"""
|
|
86
|
+
config = get_config()
|
|
87
|
+
|
|
88
|
+
if config.is_dev_mode:
|
|
89
|
+
logger.debug("[Dev] get_resources 返回 mock 数据")
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
with VokitClient() as client:
|
|
93
|
+
data = client.get("/api/gateway/resource")
|
|
94
|
+
|
|
95
|
+
if data is None or not isinstance(data, dict) or "resources" not in data:
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
return [
|
|
99
|
+
Resource(
|
|
100
|
+
id=r["id"],
|
|
101
|
+
resource_type=r["resource_type"],
|
|
102
|
+
name=r["name"],
|
|
103
|
+
description=r.get("description"),
|
|
104
|
+
config=r.get("config"),
|
|
105
|
+
status=r["status"],
|
|
106
|
+
created_at=r["created_at"],
|
|
107
|
+
updated_at=r["updated_at"],
|
|
108
|
+
)
|
|
109
|
+
for r in data["resources"]
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def report_status(tasks: list[TaskStatus | dict[str, Any]]) -> None:
|
|
114
|
+
"""上报任务状态。"""
|
|
115
|
+
config = get_config()
|
|
116
|
+
|
|
117
|
+
if config.is_dev_mode:
|
|
118
|
+
logger.debug("[Dev] report_status: tasks=%s", tasks)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
body = {
|
|
122
|
+
"tasks": [_to_task_dict(t) for t in tasks],
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
with VokitClient() as client:
|
|
126
|
+
client.post("/api/gateway/status/report", body=body)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _to_metric_dict(metric: MetricItem | dict[str, Any]) -> dict[str, Any]:
|
|
130
|
+
"""将 MetricItem 或 dict 统一转为 dict。"""
|
|
131
|
+
if isinstance(metric, MetricItem):
|
|
132
|
+
return {
|
|
133
|
+
"metricType": metric.metricType,
|
|
134
|
+
"metricValue": metric.metricValue,
|
|
135
|
+
"snapshotDate": metric.snapshotDate,
|
|
136
|
+
}
|
|
137
|
+
return dict(metric)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def report_metrics(metrics: list[MetricItem | dict[str, Any]]) -> None:
|
|
141
|
+
"""上报指标数据。"""
|
|
142
|
+
config = get_config()
|
|
143
|
+
if config.is_dev_mode:
|
|
144
|
+
logger.debug("[Dev] report_metrics: metrics=%s", metrics)
|
|
145
|
+
return None
|
|
146
|
+
body = {
|
|
147
|
+
"metrics": [_to_metric_dict(m) for m in metrics],
|
|
148
|
+
}
|
|
149
|
+
with VokitClient() as client:
|
|
150
|
+
client.post("/api/gateway/metric/report", body=body)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def report_billing(
|
|
154
|
+
cost_or_report: int | BillingReport,
|
|
155
|
+
request_id: str | None = None,
|
|
156
|
+
call_date: str | None = None,
|
|
157
|
+
api_type: str | None = None,
|
|
158
|
+
provider: str | None = None,
|
|
159
|
+
model: str | None = None,
|
|
160
|
+
prompt_tokens: int | None = None,
|
|
161
|
+
completion_tokens: int | None = None,
|
|
162
|
+
total_tokens: int | None = None,
|
|
163
|
+
) -> None:
|
|
164
|
+
"""上报费用记录。
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
cost_or_report: 费用金额(int)或 BillingReport 对象。
|
|
168
|
+
request_id: 唯一标识,由 Agent 自行生成,用于下游去重。传入 BillingReport 时可省略。
|
|
169
|
+
call_date: 调用日期,格式 YYYY-MM-DD。不传则默认为当天。
|
|
170
|
+
api_type: 调用类型(如 chat、genImage、genVideo、search_api、translation)。
|
|
171
|
+
provider: 服务提供商(如 google、openai)。
|
|
172
|
+
model: 模型/服务名。
|
|
173
|
+
prompt_tokens: 输入 token 数。
|
|
174
|
+
completion_tokens: 输出 token 数。
|
|
175
|
+
total_tokens: 总 token 数。
|
|
176
|
+
"""
|
|
177
|
+
config = get_config()
|
|
178
|
+
|
|
179
|
+
if config.is_dev_mode:
|
|
180
|
+
logger.debug(
|
|
181
|
+
"[Dev] report_billing: cost_or_report=%s",
|
|
182
|
+
cost_or_report,
|
|
183
|
+
)
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
if isinstance(cost_or_report, BillingReport):
|
|
187
|
+
report = cost_or_report
|
|
188
|
+
request_id = request_id or report.requestId
|
|
189
|
+
call_date = call_date or report.callDate
|
|
190
|
+
api_type = api_type if api_type is not None else report.apiType
|
|
191
|
+
provider = provider if provider is not None else report.provider
|
|
192
|
+
model = model if model is not None else report.model
|
|
193
|
+
prompt_tokens = prompt_tokens if prompt_tokens is not None else report.promptTokens
|
|
194
|
+
completion_tokens = completion_tokens if completion_tokens is not None else report.completionTokens
|
|
195
|
+
total_tokens = total_tokens if total_tokens is not None else report.totalTokens
|
|
196
|
+
cost = report.cost
|
|
197
|
+
else:
|
|
198
|
+
cost = cost_or_report
|
|
199
|
+
|
|
200
|
+
if call_date is None:
|
|
201
|
+
call_date = datetime.date.today().isoformat()
|
|
202
|
+
|
|
203
|
+
body = {
|
|
204
|
+
"requestId": request_id,
|
|
205
|
+
"cost": cost,
|
|
206
|
+
"callDate": call_date,
|
|
207
|
+
}
|
|
208
|
+
if api_type is not None:
|
|
209
|
+
body["apiType"] = api_type
|
|
210
|
+
if provider is not None:
|
|
211
|
+
body["provider"] = provider
|
|
212
|
+
if model is not None:
|
|
213
|
+
body["model"] = model
|
|
214
|
+
if prompt_tokens is not None:
|
|
215
|
+
body["promptTokens"] = prompt_tokens
|
|
216
|
+
if completion_tokens is not None:
|
|
217
|
+
body["completionTokens"] = completion_tokens
|
|
218
|
+
if total_tokens is not None:
|
|
219
|
+
body["totalTokens"] = total_tokens
|
|
220
|
+
|
|
221
|
+
with VokitClient() as client:
|
|
222
|
+
client.post("/api/gateway/billing/report", body=body)
|
vokit/_client.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""内部 HTTP Client。
|
|
2
|
+
|
|
3
|
+
基于 httpx 同步客户端,供平台 API 使用。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from vokit._config import get_config
|
|
14
|
+
from vokit._errors import VokitAPIError, VokitHTTPError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class VokitClient:
|
|
18
|
+
"""内部 HTTP 客户端,自动携带鉴权 headers。"""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
config = get_config()
|
|
22
|
+
self._base_url = config.gateway_url or "http://localhost"
|
|
23
|
+
self._client = httpx.Client(
|
|
24
|
+
base_url=self._base_url,
|
|
25
|
+
timeout=httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0),
|
|
26
|
+
trust_env=False,
|
|
27
|
+
headers={
|
|
28
|
+
"Authorization": f"Bearer {config.access_token}",
|
|
29
|
+
"Content-Type": "application/json",
|
|
30
|
+
},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def _parse_response(self, status_code: int, body: dict[str, Any]) -> dict[str, Any] | list[Any] | None:
|
|
34
|
+
"""解析网关响应。
|
|
35
|
+
|
|
36
|
+
- HTTP 非 200 → VokitHTTPError
|
|
37
|
+
- success==false 或 code!=0 → VokitAPIError
|
|
38
|
+
- 成功 → 返回 data 字段
|
|
39
|
+
"""
|
|
40
|
+
if status_code != 200:
|
|
41
|
+
raise VokitHTTPError(status_code, str(body))
|
|
42
|
+
|
|
43
|
+
success = body.get("success", False)
|
|
44
|
+
code = body.get("code", -1)
|
|
45
|
+
msg = body.get("msg", "unknown error")
|
|
46
|
+
trace_id = body.get("traceId") or body.get("trace_id")
|
|
47
|
+
|
|
48
|
+
if not success or code != 0:
|
|
49
|
+
raise VokitAPIError(code=code, msg=msg, trace_id=trace_id)
|
|
50
|
+
|
|
51
|
+
return body.get("data")
|
|
52
|
+
|
|
53
|
+
def get(self, path: str) -> dict[str, Any] | list[Any] | None:
|
|
54
|
+
"""发起 GET 请求,返回解析后的 data 字段。"""
|
|
55
|
+
try:
|
|
56
|
+
resp = self._client.get(path)
|
|
57
|
+
return self._parse_response(resp.status_code, resp.json())
|
|
58
|
+
except httpx.RequestError as e:
|
|
59
|
+
raise VokitAPIError(code=-1, msg=f"网络请求失败: {e}") from e
|
|
60
|
+
except (json.JSONDecodeError, httpx.DecodingError) as e:
|
|
61
|
+
raise VokitAPIError(code=-1, msg=f"响应解析失败: {e}") from e
|
|
62
|
+
|
|
63
|
+
def post(self, path: str, body: dict[str, Any]) -> dict[str, Any] | list[Any] | None:
|
|
64
|
+
"""发起 POST 请求,返回解析后的 data 字段。"""
|
|
65
|
+
try:
|
|
66
|
+
resp = self._client.post(path, json=body)
|
|
67
|
+
return self._parse_response(resp.status_code, resp.json())
|
|
68
|
+
except httpx.RequestError as e:
|
|
69
|
+
raise VokitAPIError(code=-1, msg=f"网络请求失败: {e}") from e
|
|
70
|
+
except (json.JSONDecodeError, httpx.DecodingError) as e:
|
|
71
|
+
raise VokitAPIError(code=-1, msg=f"响应解析失败: {e}") from e
|
|
72
|
+
|
|
73
|
+
def close(self) -> None:
|
|
74
|
+
"""关闭底层连接。"""
|
|
75
|
+
self._client.close()
|
|
76
|
+
|
|
77
|
+
def __enter__(self) -> "VokitClient":
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
def __exit__(self, *args: Any) -> None:
|
|
81
|
+
self.close()
|
vokit/_config.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""vokit 环境变量读取与校验。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("vokit")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Config:
|
|
13
|
+
"""SDK 配置。import 时自动读取环境变量。"""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self._gateway_url: str | None = os.environ.get("VO_GATEWAY_URL")
|
|
17
|
+
self._access_token: str | None = os.environ.get("VO_ACCESS_TOKEN")
|
|
18
|
+
|
|
19
|
+
# 强制开发模式
|
|
20
|
+
if os.environ.get("VO_DEV_MODE") == "1":
|
|
21
|
+
self._gateway_url = None
|
|
22
|
+
self._access_token = None
|
|
23
|
+
logger.warning("VO_DEV_MODE=1,强制进入开发模式。LLM 请求将直连原始 Provider。")
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
# 缺少必填环境变量 → 开发模式
|
|
27
|
+
if not self._gateway_url or not self._access_token:
|
|
28
|
+
missing = []
|
|
29
|
+
if not self._gateway_url:
|
|
30
|
+
missing.append("VO_GATEWAY_URL")
|
|
31
|
+
if not self._access_token:
|
|
32
|
+
missing.append("VO_ACCESS_TOKEN")
|
|
33
|
+
logger.warning(
|
|
34
|
+
"%s 未配置,进入开发模式。LLM 请求将直连原始 Provider。",
|
|
35
|
+
"、".join(missing),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger.debug(
|
|
39
|
+
"vokit 配置: gateway_url=%s, access_token=%s",
|
|
40
|
+
self._gateway_url,
|
|
41
|
+
f"{self._access_token[:6]}..." if self._access_token else "(未设置)",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def is_dev_mode(self) -> bool:
|
|
46
|
+
return self._gateway_url is None or self._access_token is None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def gateway_url(self) -> str | None:
|
|
50
|
+
return self._gateway_url
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def access_token(self) -> str | None:
|
|
54
|
+
return self._access_token
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_config: Config | None = None
|
|
58
|
+
_config_lock = threading.Lock()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_config() -> Config:
|
|
62
|
+
"""获取全局配置单例。"""
|
|
63
|
+
global _config
|
|
64
|
+
if _config is None:
|
|
65
|
+
with _config_lock:
|
|
66
|
+
if _config is None:
|
|
67
|
+
_config = Config()
|
|
68
|
+
return _config
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def reset_config() -> None:
|
|
72
|
+
"""重置配置单例。仅供测试使用。"""
|
|
73
|
+
global _config
|
|
74
|
+
_config = None
|
vokit/_errors.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""自定义异常类。
|
|
2
|
+
|
|
3
|
+
包含 VokitError, VokitConfigError, VokitAPIError, VokitHTTPError。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class VokitError(Exception):
|
|
8
|
+
"""vokit 所有异常的基类。"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class VokitConfigError(VokitError):
|
|
12
|
+
"""配置错误:环境变量缺失或无效。"""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class VokitAPIError(VokitError):
|
|
16
|
+
"""网关业务错误:success == false 或 code != 0。"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, code: int, msg: str, trace_id: str | None = None) -> None:
|
|
19
|
+
self.code = code
|
|
20
|
+
self.msg = msg
|
|
21
|
+
self.trace_id = trace_id
|
|
22
|
+
super().__init__(f"API error: code={code}, msg={msg}, traceId={trace_id}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class VokitHTTPError(VokitError):
|
|
26
|
+
"""HTTP 错误:非 200 状态码。"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, status_code: int, response_text: str) -> None:
|
|
29
|
+
self.status_code = status_code
|
|
30
|
+
self.response_text = response_text
|
|
31
|
+
super().__init__(f"HTTP error: status={status_code}, body={response_text[:200]}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class VokitInterceptError(VokitError):
|
|
35
|
+
"""拦截错误:拦截匹配但处理失败。
|
|
36
|
+
|
|
37
|
+
拦截和转发是核心需求,匹配到规则后出错不允许降级到原始请求,
|
|
38
|
+
而是抛出此异常由业务代码处理。
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, message: str, original_url: str | None = None) -> None:
|
|
42
|
+
self.message = message
|
|
43
|
+
self.original_url = original_url
|
|
44
|
+
url_info = f", url={original_url}" if original_url else ""
|
|
45
|
+
super().__init__(f"Intercept error: {message}{url_info}")
|
vokit/_interceptor.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""核心拦截逻辑。
|
|
2
|
+
|
|
3
|
+
接收不同 HTTP 库的请求对象,提取 URL、body 和 headers,
|
|
4
|
+
调用 _providers.match_request() 判断是否需要拦截。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urlparse, urlunparse
|
|
12
|
+
|
|
13
|
+
from vokit._config import get_config
|
|
14
|
+
from vokit._providers import match_request
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("vokit")
|
|
17
|
+
|
|
18
|
+
INTERCEPTED_MARKER = "1"
|
|
19
|
+
INTERCEPTED_HEADER = "X-Vokit-Intercepted"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _extract_original_auth(original_headers: dict[str, Any]) -> tuple[str | None, str | None]:
|
|
23
|
+
"""提取原始认证信息,返回 (X-Original-Auth, X-Upstream-Auth-Header)。
|
|
24
|
+
|
|
25
|
+
原始 headers dict 的 key 可能已被 HTTP 库小写化(如 httpx),
|
|
26
|
+
因此使用小写 key 查找。
|
|
27
|
+
"""
|
|
28
|
+
# original_headers 的 key 已在 intercept_request 入口统一小写化
|
|
29
|
+
auth = original_headers.get("authorization")
|
|
30
|
+
if auth:
|
|
31
|
+
return auth, "Authorization"
|
|
32
|
+
api_key = original_headers.get("x-api-key")
|
|
33
|
+
if api_key:
|
|
34
|
+
return api_key, "X-Api-Key"
|
|
35
|
+
return None, None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_intercept_headers(
|
|
39
|
+
original_headers: dict[str, Any],
|
|
40
|
+
original_url: str,
|
|
41
|
+
) -> dict[str, str]:
|
|
42
|
+
"""构建拦截后的新 headers。
|
|
43
|
+
|
|
44
|
+
original_headers 的 key 已在 intercept_request 入口统一小写化。
|
|
45
|
+
"""
|
|
46
|
+
config = get_config()
|
|
47
|
+
new_headers: dict[str, str] = {}
|
|
48
|
+
|
|
49
|
+
# 保留原始 headers 中不需要改写的部分(排除将被覆盖的)
|
|
50
|
+
skip_headers = {"authorization", "x-api-key", "x-vokit-intercepted"}
|
|
51
|
+
for k, v in original_headers.items():
|
|
52
|
+
if k not in skip_headers:
|
|
53
|
+
new_headers[k] = v
|
|
54
|
+
|
|
55
|
+
# 网关鉴权
|
|
56
|
+
if config.access_token:
|
|
57
|
+
new_headers["Authorization"] = f"Bearer {config.access_token}"
|
|
58
|
+
|
|
59
|
+
# 原始 URL
|
|
60
|
+
new_headers["X-Original-Url"] = original_url
|
|
61
|
+
|
|
62
|
+
# 原始认证标准化
|
|
63
|
+
original_auth, auth_header = _extract_original_auth(original_headers)
|
|
64
|
+
if original_auth is not None:
|
|
65
|
+
new_headers["X-Original-Auth"] = original_auth
|
|
66
|
+
if auth_header is not None:
|
|
67
|
+
new_headers["X-Upstream-Auth-Header"] = auth_header
|
|
68
|
+
logger.debug("提取原始认证: auth_header=%s", auth_header)
|
|
69
|
+
else:
|
|
70
|
+
logger.debug("未找到原始认证信息 (Authorization / X-Api-Key)")
|
|
71
|
+
|
|
72
|
+
# 保留原始 X-Api-Key(如存在)
|
|
73
|
+
original_api_key = original_headers.get("x-api-key")
|
|
74
|
+
if original_api_key:
|
|
75
|
+
new_headers["X-API-Key"] = original_api_key
|
|
76
|
+
|
|
77
|
+
# 防重复拦截
|
|
78
|
+
new_headers[INTERCEPTED_HEADER] = INTERCEPTED_MARKER
|
|
79
|
+
|
|
80
|
+
return new_headers
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def intercept_request(
|
|
84
|
+
original_url: str,
|
|
85
|
+
body: bytes | dict[str, Any] | str | None,
|
|
86
|
+
headers: dict[str, Any] | None,
|
|
87
|
+
) -> tuple[str, dict[str, str]] | None:
|
|
88
|
+
"""拦截并重写 URL 和 headers。
|
|
89
|
+
|
|
90
|
+
返回 (new_url, new_headers),不匹配时返回 None。
|
|
91
|
+
"""
|
|
92
|
+
# 统一小写化 headers key(httpx 会将 key 小写化,requests/urllib3 保留原始大小写)
|
|
93
|
+
# 在入口处规范化一次,让后续所有逻辑均使用小写 key
|
|
94
|
+
header_dict = {k.lower(): v for k, v in headers.items()} if headers else {}
|
|
95
|
+
|
|
96
|
+
# 防重复拦截(使用小写 key 查找)
|
|
97
|
+
if header_dict.get(INTERCEPTED_HEADER.lower()):
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
# Path 匹配
|
|
101
|
+
route = match_request(original_url, body, header_dict)
|
|
102
|
+
if route is None:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
# 构建新 URL
|
|
106
|
+
config = get_config()
|
|
107
|
+
if config.gateway_url is None:
|
|
108
|
+
logger.debug("gateway_url 未配置,跳过拦截: %s", original_url)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
parsed_gateway = urlparse(config.gateway_url)
|
|
112
|
+
original_parsed = urlparse(original_url)
|
|
113
|
+
new_url = urlunparse(
|
|
114
|
+
(
|
|
115
|
+
parsed_gateway.scheme,
|
|
116
|
+
parsed_gateway.netloc,
|
|
117
|
+
route.gateway_path,
|
|
118
|
+
"", # params
|
|
119
|
+
original_parsed.query, # preserve query
|
|
120
|
+
"", # fragment
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# 构建新 headers
|
|
125
|
+
new_headers = build_intercept_headers(header_dict, original_url)
|
|
126
|
+
|
|
127
|
+
logger.debug(
|
|
128
|
+
"拦截重写: %s %s → %s %s (stream=%s)",
|
|
129
|
+
original_parsed.scheme,
|
|
130
|
+
original_url,
|
|
131
|
+
parsed_gateway.scheme,
|
|
132
|
+
new_url,
|
|
133
|
+
route.is_stream,
|
|
134
|
+
)
|
|
135
|
+
logger.debug("新 headers: %s", list(new_headers.keys()))
|
|
136
|
+
|
|
137
|
+
return new_url, new_headers
|
vokit/_models.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""数据类定义。
|
|
2
|
+
|
|
3
|
+
包含 Resource, TaskStatus, RouteResult 等数据类。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class RouteResult:
|
|
14
|
+
"""Path 匹配结果。"""
|
|
15
|
+
|
|
16
|
+
gateway_path: str
|
|
17
|
+
is_stream: bool
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Resource:
|
|
22
|
+
"""工位资源数据类。"""
|
|
23
|
+
|
|
24
|
+
id: int
|
|
25
|
+
resource_type: str
|
|
26
|
+
name: str
|
|
27
|
+
description: str | None
|
|
28
|
+
config: dict[str, Any] | None
|
|
29
|
+
status: int
|
|
30
|
+
created_at: str
|
|
31
|
+
updated_at: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class TaskStatus:
|
|
36
|
+
"""任务状态。
|
|
37
|
+
|
|
38
|
+
Python 3.10 不支持 StrEnum,使用类常量字符串。
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
PENDING = "pending"
|
|
42
|
+
RUNNING = "running"
|
|
43
|
+
COMPLETED = "completed"
|
|
44
|
+
FAILED = "failed"
|
|
45
|
+
CANCELLED = "cancelled"
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
status: str
|
|
49
|
+
progress: float
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class MetricItem:
|
|
54
|
+
"""指标数据项。
|
|
55
|
+
|
|
56
|
+
字段使用 camelCase,以便 __dict__ 可直接用于构建 JSON 请求体。
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
metricType: str
|
|
60
|
+
metricValue: int
|
|
61
|
+
snapshotDate: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class BillingReport:
|
|
66
|
+
"""Agent 主动上报的费用记录。
|
|
67
|
+
|
|
68
|
+
字段使用 camelCase,以便 __dict__ 可直接用于构建 JSON 请求体。
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
requestId: str
|
|
72
|
+
cost: int
|
|
73
|
+
callDate: str
|
|
74
|
+
apiType: str | None = None
|
|
75
|
+
provider: str | None = None
|
|
76
|
+
model: str | None = None
|
|
77
|
+
promptTokens: int | None = None
|
|
78
|
+
completionTokens: int | None = None
|
|
79
|
+
totalTokens: int | None = None
|
vokit/_patcher.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""vokit monkey patch 注册逻辑。
|
|
2
|
+
|
|
3
|
+
使用 wrapt.wrap_function_wrapper 拦截四大 HTTP 库的 send 入口。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from vokit._config import get_config
|
|
12
|
+
from vokit._errors import VokitInterceptError
|
|
13
|
+
from vokit._interceptor import intercept_request
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("vokit")
|
|
16
|
+
|
|
17
|
+
_patched = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _handle_sync_request(wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
21
|
+
"""同步请求通用拦截处理(httpx.Client / requests.Session)。
|
|
22
|
+
|
|
23
|
+
拦截匹配但处理失败时抛出 VokitInterceptError,不降级到原始请求。
|
|
24
|
+
"""
|
|
25
|
+
request = args[0]
|
|
26
|
+
original_url = str(request.url)
|
|
27
|
+
body = getattr(request, "content", None) or getattr(request, "body", None)
|
|
28
|
+
headers = dict(getattr(request, "headers", {}))
|
|
29
|
+
|
|
30
|
+
body_size = len(body) if body else 0
|
|
31
|
+
logger.debug("同步请求拦截: url=%s, body_size=%d", original_url, body_size)
|
|
32
|
+
|
|
33
|
+
result = intercept_request(original_url, body, headers if headers else None)
|
|
34
|
+
|
|
35
|
+
if result is not None:
|
|
36
|
+
try:
|
|
37
|
+
new_url, new_headers = result
|
|
38
|
+
logger.debug("应用重写: url=%s", new_url)
|
|
39
|
+
# httpx.Request.url 需要是 httpx.URL 对象,requests.Request.url 是字符串
|
|
40
|
+
if type(request).__module__.startswith("httpx"):
|
|
41
|
+
import httpx
|
|
42
|
+
|
|
43
|
+
request.url = httpx.URL(new_url)
|
|
44
|
+
else:
|
|
45
|
+
request.url = new_url
|
|
46
|
+
request.headers.clear()
|
|
47
|
+
for k, v in new_headers.items():
|
|
48
|
+
request.headers[k] = v
|
|
49
|
+
except Exception as e:
|
|
50
|
+
raise VokitInterceptError(f"拦截重写失败: {e}", original_url=original_url) from e
|
|
51
|
+
else:
|
|
52
|
+
logger.debug("未匹配拦截规则,原样放行: %s", original_url)
|
|
53
|
+
|
|
54
|
+
return wrapped(*args, **kwargs)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _handle_httpx_async(wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
58
|
+
"""httpx.AsyncClient.send() 异步拦截。
|
|
59
|
+
|
|
60
|
+
拦截匹配但处理失败时抛出 VokitInterceptError,不降级到原始请求。
|
|
61
|
+
"""
|
|
62
|
+
request = args[0]
|
|
63
|
+
original_url = str(request.url)
|
|
64
|
+
body = getattr(request, "content", None)
|
|
65
|
+
headers = dict(getattr(request, "headers", {}))
|
|
66
|
+
|
|
67
|
+
body_size = len(body) if body else 0
|
|
68
|
+
logger.debug("异步请求拦截(httpx): url=%s, body_size=%d", original_url, body_size)
|
|
69
|
+
|
|
70
|
+
result = intercept_request(original_url, body, headers if headers else None)
|
|
71
|
+
|
|
72
|
+
if result is not None:
|
|
73
|
+
try:
|
|
74
|
+
new_url, new_headers = result
|
|
75
|
+
logger.debug("应用重写: url=%s", new_url)
|
|
76
|
+
import httpx
|
|
77
|
+
|
|
78
|
+
request.url = httpx.URL(new_url)
|
|
79
|
+
request.headers.clear()
|
|
80
|
+
for k, v in new_headers.items():
|
|
81
|
+
request.headers[k] = v
|
|
82
|
+
except Exception as e:
|
|
83
|
+
raise VokitInterceptError(f"拦截重写失败: {e}", original_url=original_url) from e
|
|
84
|
+
else:
|
|
85
|
+
logger.debug("未匹配拦截规则,原样放行: %s", original_url)
|
|
86
|
+
|
|
87
|
+
return await wrapped(*args, **kwargs)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _handle_aiohttp_async(wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
91
|
+
"""aiohttp.ClientSession._request() 异步拦截。
|
|
92
|
+
|
|
93
|
+
aiohttp 签名: _request(self, method, str_or_url, ...)
|
|
94
|
+
拦截匹配但处理失败时抛出 VokitInterceptError,不降级到原始请求。
|
|
95
|
+
"""
|
|
96
|
+
# args[0] = method (str), args[1] or kwargs['str_or_url'] = url
|
|
97
|
+
str_or_url = args[1] if len(args) > 1 else kwargs.get("str_or_url", "")
|
|
98
|
+
original_url = str(str_or_url)
|
|
99
|
+
|
|
100
|
+
# headers 可能为 CIMultiDict 或 dict,优先从位置参数读取,其次从 kwargs 读取
|
|
101
|
+
if len(args) > 2 and args[2] is not None:
|
|
102
|
+
raw_headers = args[2]
|
|
103
|
+
else:
|
|
104
|
+
raw_headers = kwargs.get("headers", {})
|
|
105
|
+
headers = dict(raw_headers) if raw_headers else {}
|
|
106
|
+
|
|
107
|
+
# body 通过 json= 或 data= 参数传入
|
|
108
|
+
body = kwargs.get("json") or kwargs.get("data")
|
|
109
|
+
|
|
110
|
+
body_desc = str(body)[:100] if body else "(空)"
|
|
111
|
+
logger.debug("异步请求拦截(aiohttp): url=%s, body=%s", original_url, body_desc)
|
|
112
|
+
|
|
113
|
+
result = intercept_request(original_url, body, headers if headers else None)
|
|
114
|
+
|
|
115
|
+
if result is not None:
|
|
116
|
+
try:
|
|
117
|
+
new_url, new_headers = result
|
|
118
|
+
logger.debug("应用重写: url=%s", new_url)
|
|
119
|
+
# 重写 URL 参数
|
|
120
|
+
if len(args) > 1:
|
|
121
|
+
args_list = list(args)
|
|
122
|
+
args_list[1] = new_url
|
|
123
|
+
args = tuple(args_list)
|
|
124
|
+
else:
|
|
125
|
+
kwargs["str_or_url"] = new_url
|
|
126
|
+
# 重写 headers,写回对应位置
|
|
127
|
+
if len(args) > 2:
|
|
128
|
+
args_list = list(args)
|
|
129
|
+
args_list[2] = type(args[2])(new_headers)
|
|
130
|
+
args = tuple(args_list)
|
|
131
|
+
else:
|
|
132
|
+
kwargs["headers"] = new_headers
|
|
133
|
+
except Exception as e:
|
|
134
|
+
raise VokitInterceptError(f"拦截重写失败: {e}", original_url=original_url) from e
|
|
135
|
+
else:
|
|
136
|
+
logger.debug("未匹配拦截规则,原样放行: %s", original_url)
|
|
137
|
+
|
|
138
|
+
return await wrapped(*args, **kwargs)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _handle_urllib3_request(wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
142
|
+
"""urllib3 HTTPConnectionPool.urlopen() 专用拦截。
|
|
143
|
+
|
|
144
|
+
不修改 pool 属性(避免线程安全问题),而是将 url 参数替换为
|
|
145
|
+
完整网关绝对 URL,并设置 assert_same_host=False 使 urllib3
|
|
146
|
+
自动路由到网关对应的连接池。
|
|
147
|
+
拦截匹配但处理失败时抛出 VokitInterceptError,不降级到原始请求。
|
|
148
|
+
"""
|
|
149
|
+
pool = instance
|
|
150
|
+
scheme = getattr(pool, "scheme", "https")
|
|
151
|
+
host = getattr(pool, "host", "")
|
|
152
|
+
port = getattr(pool, "port", None)
|
|
153
|
+
|
|
154
|
+
url = args[0] if args else kwargs.get("url", "")
|
|
155
|
+
body = args[2] if len(args) > 2 else kwargs.get("body")
|
|
156
|
+
headers = dict(args[3]) if len(args) > 3 else dict(kwargs.get("headers", {}))
|
|
157
|
+
|
|
158
|
+
full_url = f"{scheme}://{host}"
|
|
159
|
+
if port and ((scheme == "http" and port != 80) or (scheme == "https" and port != 443)):
|
|
160
|
+
full_url += f":{port}"
|
|
161
|
+
full_url += url
|
|
162
|
+
|
|
163
|
+
body_size = len(body) if body else 0
|
|
164
|
+
logger.debug("请求拦截(urllib3): full_url=%s, body_size=%d", full_url, body_size)
|
|
165
|
+
|
|
166
|
+
result = intercept_request(full_url, body, headers if headers else None)
|
|
167
|
+
|
|
168
|
+
if result is not None:
|
|
169
|
+
try:
|
|
170
|
+
new_url, new_headers = result
|
|
171
|
+
logger.debug("应用重写: url=%s", new_url)
|
|
172
|
+
|
|
173
|
+
# 将 url 参数替换为完整网关绝对 URL
|
|
174
|
+
if args:
|
|
175
|
+
args_list = list(args)
|
|
176
|
+
args_list[0] = new_url
|
|
177
|
+
args = tuple(args_list)
|
|
178
|
+
else:
|
|
179
|
+
kwargs["url"] = new_url
|
|
180
|
+
|
|
181
|
+
# 设置 assert_same_host=False 允许跨 host 转发
|
|
182
|
+
kwargs["assert_same_host"] = False
|
|
183
|
+
|
|
184
|
+
# 重写 headers
|
|
185
|
+
if len(args) > 3:
|
|
186
|
+
args_list = list(args)
|
|
187
|
+
args_list[3] = type(args[3])(new_headers)
|
|
188
|
+
args = tuple(args_list)
|
|
189
|
+
else:
|
|
190
|
+
kwargs["headers"] = new_headers
|
|
191
|
+
except Exception as e:
|
|
192
|
+
raise VokitInterceptError(f"拦截重写失败: {e}", original_url=full_url) from e
|
|
193
|
+
else:
|
|
194
|
+
logger.debug("未匹配拦截规则,原样放行: %s", full_url)
|
|
195
|
+
|
|
196
|
+
return wrapped(*args, **kwargs)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _try_patch(module_path: str, attr: str, wrapper: Any, required: bool = False) -> bool:
|
|
200
|
+
"""尝试对指定模块的属性进行 wrapt patch。
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
required: True 表示核心依赖,patch 失败时抛异常;
|
|
204
|
+
False 表示可选依赖,patch 失败时静默跳过。
|
|
205
|
+
"""
|
|
206
|
+
try:
|
|
207
|
+
import wrapt
|
|
208
|
+
except ImportError:
|
|
209
|
+
if required:
|
|
210
|
+
logger.error("wrapt 未安装,无法注册 monkey patch(核心依赖)")
|
|
211
|
+
raise
|
|
212
|
+
logger.debug("Monkey patch skipped (optional): %s — wrapt 未安装", module_path)
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
# wrapt.wrap_function_wrapper 的第一个参数如果是字符串,会尝试将其作为模块导入。
|
|
216
|
+
# 对于 "httpx.Client" 这样的路径,__import__("httpx.Client") 会失败。
|
|
217
|
+
# 因此我们先自行解析路径,获取实际对象后再传给 wrapt。
|
|
218
|
+
try:
|
|
219
|
+
parts = module_path.split(".")
|
|
220
|
+
module_name = parts[0]
|
|
221
|
+
import importlib
|
|
222
|
+
|
|
223
|
+
mod = importlib.import_module(module_name)
|
|
224
|
+
target = mod
|
|
225
|
+
for part in parts[1:]:
|
|
226
|
+
target = getattr(target, part)
|
|
227
|
+
except (ModuleNotFoundError, AttributeError, ImportError) as e:
|
|
228
|
+
if required:
|
|
229
|
+
logger.error("Monkey patch 失败(核心依赖): %s.%s — %s", module_path, attr, e)
|
|
230
|
+
raise
|
|
231
|
+
logger.debug("Monkey patch skipped (optional): %s.%s — %s", module_path, attr, e)
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
wrapt.wrap_function_wrapper(target, attr, wrapper)
|
|
236
|
+
logger.debug("Monkey patch registered: %s.%s", module_path, attr)
|
|
237
|
+
return True
|
|
238
|
+
except (AttributeError, ModuleNotFoundError) as e:
|
|
239
|
+
if required:
|
|
240
|
+
logger.error("Monkey patch 失败(核心依赖): %s.%s — %s", module_path, attr, e)
|
|
241
|
+
raise
|
|
242
|
+
logger.debug("Monkey patch skipped (optional): %s.%s — %s", module_path, attr, e)
|
|
243
|
+
return False
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def activate() -> None:
|
|
247
|
+
"""激活 monkey patch。幂等。"""
|
|
248
|
+
global _patched
|
|
249
|
+
if _patched:
|
|
250
|
+
return
|
|
251
|
+
|
|
252
|
+
config = get_config()
|
|
253
|
+
if config.is_dev_mode:
|
|
254
|
+
logger.debug("开发模式,不激活 monkey patch")
|
|
255
|
+
_patched = True
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
# httpx 同步(核心依赖)
|
|
259
|
+
_try_patch("httpx.Client", "send", _handle_sync_request, required=True)
|
|
260
|
+
# httpx 异步(核心依赖)
|
|
261
|
+
_try_patch("httpx.AsyncClient", "send", _handle_httpx_async, required=True)
|
|
262
|
+
# requests 同步(可选)
|
|
263
|
+
_try_patch("requests.Session", "send", _handle_sync_request)
|
|
264
|
+
# urllib3 同步(可选)
|
|
265
|
+
_try_patch("urllib3.HTTPConnectionPool", "urlopen", _handle_urllib3_request)
|
|
266
|
+
# aiohttp 异步(可选)
|
|
267
|
+
_try_patch("aiohttp.client", "ClientSession._request", _handle_aiohttp_async)
|
|
268
|
+
|
|
269
|
+
_patched = True
|
|
270
|
+
logger.info("vokit monkey patch 已激活")
|
vokit/_providers.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Path 规则引擎。
|
|
2
|
+
|
|
3
|
+
定义 Path 匹配模式和规范 Path 映射,提供 match_request() 核心函数。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
from vokit._models import RouteResult
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("vokit")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Path 匹配 -> 请求类型
|
|
19
|
+
_CHAT_PATHS = {
|
|
20
|
+
"/v1/chat/completions",
|
|
21
|
+
"/compatible-mode/v1/chat/completions",
|
|
22
|
+
"/v4/chat/completions",
|
|
23
|
+
"/api/paas/v4/chat/completions",
|
|
24
|
+
"/api/v3/chat/completions",
|
|
25
|
+
"/v1/messages",
|
|
26
|
+
"/apps/anthropic/v1/messages",
|
|
27
|
+
"/api/v1/services/aigc/text-generation/generation",
|
|
28
|
+
"/v1/completions",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_IMAGE_PATHS = {
|
|
32
|
+
"/v1/images/generations",
|
|
33
|
+
"/api/v1/services/aigc/text2image/image-synthesis",
|
|
34
|
+
"/api/v3/images/generations",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
_VIDEO_PATHS = {
|
|
38
|
+
"/api/v1/services/aigc/video-generation/video-synthesis",
|
|
39
|
+
"/v4/videos/generations",
|
|
40
|
+
"/api/paas/v4/videos/generations",
|
|
41
|
+
"/api/v3/videos/generations",
|
|
42
|
+
"/v1/contents/generations",
|
|
43
|
+
"/api/v3/contents/generations",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# 需要前缀匹配的路径(子路径也属于同一类型)
|
|
47
|
+
_VIDEO_PATH_PREFIXES = ("/api/v3/contents/generations/tasks",)
|
|
48
|
+
|
|
49
|
+
# 请求类型 -> 网关规范 Path
|
|
50
|
+
_GATEWAY_PATH_MAP: dict[str, tuple[str, str]] = {
|
|
51
|
+
# request_type: (normal_path, stream_path)
|
|
52
|
+
"chat": ("/api/gateway/llm/chat", "/api/gateway/llm/chat/stream"),
|
|
53
|
+
"image": ("/api/gateway/multimodal/images/generations", "/api/gateway/multimodal/images/generations"),
|
|
54
|
+
"video": ("/api/gateway/multimodal/videos/generations/tasks", "/api/gateway/multimodal/videos/generations/tasks"),
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _decode_body(body: bytes | dict[str, Any] | str | None) -> dict[str, Any] | None:
|
|
59
|
+
"""将 body 统一解码为 dict。非 JSON 或解析失败返回 None。"""
|
|
60
|
+
if body is None:
|
|
61
|
+
return None
|
|
62
|
+
if isinstance(body, dict):
|
|
63
|
+
return body
|
|
64
|
+
if isinstance(body, bytes):
|
|
65
|
+
try:
|
|
66
|
+
result: Any = json.loads(body)
|
|
67
|
+
return result if isinstance(result, dict) else None
|
|
68
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
69
|
+
return None
|
|
70
|
+
if isinstance(body, str):
|
|
71
|
+
try:
|
|
72
|
+
result = json.loads(body)
|
|
73
|
+
return result if isinstance(result, dict) else None
|
|
74
|
+
except json.JSONDecodeError:
|
|
75
|
+
return None
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _detect_stream(body: bytes | dict[str, Any] | str | None, headers: dict[str, Any] | None) -> bool:
|
|
80
|
+
"""检测是否流式请求。
|
|
81
|
+
|
|
82
|
+
headers 的 key 可能已经被小写化(来自 intercept_request 入口),
|
|
83
|
+
也可能保留原始大小写(来自直接调用)。Accept 检查使用 case-insensitive 查找。
|
|
84
|
+
"""
|
|
85
|
+
body_dict = _decode_body(body)
|
|
86
|
+
if body_dict and body_dict.get("stream") is True:
|
|
87
|
+
return True
|
|
88
|
+
# DashScope 通过 Accept: text/event-stream 标识流式(I2 修复:case-insensitive 查找)
|
|
89
|
+
if headers:
|
|
90
|
+
accept_value = next((v for k, v in headers.items() if k.lower() == "accept"), "")
|
|
91
|
+
if "text/event-stream" in accept_value:
|
|
92
|
+
return True
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _classify_path(path: str) -> str | None:
|
|
97
|
+
"""根据 Path 判断请求类型。"""
|
|
98
|
+
if path in _CHAT_PATHS:
|
|
99
|
+
return "chat"
|
|
100
|
+
if path in _IMAGE_PATHS:
|
|
101
|
+
return "image"
|
|
102
|
+
if path in _VIDEO_PATHS:
|
|
103
|
+
return "video"
|
|
104
|
+
# 前缀匹配
|
|
105
|
+
if path.startswith(_VIDEO_PATH_PREFIXES):
|
|
106
|
+
return "video"
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def match_request(
|
|
111
|
+
url: str,
|
|
112
|
+
body: bytes | dict[str, Any] | str | None,
|
|
113
|
+
headers: dict[str, Any] | None,
|
|
114
|
+
) -> RouteResult | None:
|
|
115
|
+
"""匹配 URL Path,返回路由结果或 None。"""
|
|
116
|
+
parsed = urlparse(url)
|
|
117
|
+
path = parsed.path
|
|
118
|
+
|
|
119
|
+
request_type = _classify_path(path)
|
|
120
|
+
if request_type is None:
|
|
121
|
+
logger.debug("Path 不匹配,原样放行: %s", url)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
gateway_paths = _GATEWAY_PATH_MAP[request_type]
|
|
125
|
+
normal_path, stream_path = gateway_paths
|
|
126
|
+
|
|
127
|
+
# 只有 chat 类型支持流式
|
|
128
|
+
is_stream = False
|
|
129
|
+
if request_type == "chat":
|
|
130
|
+
is_stream = _detect_stream(body, headers)
|
|
131
|
+
|
|
132
|
+
gateway_path = stream_path if is_stream else normal_path
|
|
133
|
+
logger.debug(
|
|
134
|
+
"Path 匹配: request_type=%s, is_stream=%s, gateway_path=%s",
|
|
135
|
+
request_type,
|
|
136
|
+
is_stream,
|
|
137
|
+
gateway_path,
|
|
138
|
+
)
|
|
139
|
+
return RouteResult(gateway_path=gateway_path, is_stream=is_stream)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vokit
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: VirtualOffice Agent SDK — 透明拦截转发 + 平台 API 封装
|
|
5
|
+
Author: VirtualOffice Team
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: wrapt>=1.15
|
|
10
|
+
Requires-Dist: httpx>=0.24
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
15
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
16
|
+
Requires-Dist: mypy>=1.0; extra == "dev"
|
|
17
|
+
Requires-Dist: aiohttp>=3.8; extra == "dev"
|
|
18
|
+
Requires-Dist: requests>=2.28; extra == "dev"
|
|
19
|
+
Requires-Dist: urllib3>=1.26; extra == "dev"
|
|
20
|
+
Requires-Dist: respx>=0.21; extra == "dev"
|
|
21
|
+
|
|
22
|
+
# vokit — VirtualOffice Agent SDK
|
|
23
|
+
|
|
24
|
+
一行 `import vokit` 完成拦截激活 + API 就绪。
|
|
25
|
+
|
|
26
|
+
## 安装
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
# 预发布版本
|
|
30
|
+
pip install git+http://oauth2:YOUR_TOKEN@gitlab.deepleaper.com/deepbrain/vokit-sdk.git@prerelease
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 快速开始
|
|
34
|
+
|
|
35
|
+
### 1. 拦截 LLM 请求
|
|
36
|
+
|
|
37
|
+
在 Agent 的**入口文件最顶部** import 一次即可:
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import vokit
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- **只需 import 一次**:patch 注册在进程级别的 HTTP 库入口,全局生效。
|
|
44
|
+
- **放在最顶部**:确保在所有 LLM SDK 被 import 之前 patch 已注册好。
|
|
45
|
+
- **幂等安全**:多次 import 不会重复注册。
|
|
46
|
+
|
|
47
|
+
### 2. 使用平台 API
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import vokit
|
|
51
|
+
|
|
52
|
+
# 提交产出物
|
|
53
|
+
vokit.submit_output(
|
|
54
|
+
output_type="text",
|
|
55
|
+
content="分析报告内容..."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# 查询工位资源
|
|
59
|
+
resources = vokit.get_resources()
|
|
60
|
+
|
|
61
|
+
# 上报任务状态
|
|
62
|
+
vokit.report_status(tasks=[
|
|
63
|
+
{"name": "task-1", "status": "running", "progress": 0.75},
|
|
64
|
+
])
|
|
65
|
+
|
|
66
|
+
# 上报指标数据
|
|
67
|
+
vokit.report_metrics(metrics=[
|
|
68
|
+
{"metricType": "tokens_used", "metricValue": 1500, "snapshotDate": "2026-04-14"},
|
|
69
|
+
])
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## 运行模式
|
|
73
|
+
|
|
74
|
+
### 开发模式
|
|
75
|
+
|
|
76
|
+
不设置 `VO_GATEWAY_URL` 和 `VO_ACCESS_TOKEN` 时自动进入:
|
|
77
|
+
- LLM 请求**直连** Provider,不被拦截
|
|
78
|
+
- 平台 API 返回 Mock 数据
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
export OPENAI_API_KEY="your-key"
|
|
82
|
+
python your_agent.py
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### 生产模式
|
|
86
|
+
|
|
87
|
+
设置环境变量后自动进入:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
export VO_GATEWAY_URL=http://localhost:8888
|
|
91
|
+
export VO_ACCESS_TOKEN=<your-jwt-token>
|
|
92
|
+
export OPENAI_API_KEY="your-key"
|
|
93
|
+
python your_agent.py
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## 环境变量
|
|
97
|
+
|
|
98
|
+
| 变量 | 说明 |
|
|
99
|
+
|------|------|
|
|
100
|
+
| `VO_GATEWAY_URL` | 内网网关地址,缺失则进入开发模式 |
|
|
101
|
+
| `VO_ACCESS_TOKEN` | 网关 Bearer Token,缺失则进入开发模式 |
|
|
102
|
+
|
|
103
|
+
## Python 版本
|
|
104
|
+
|
|
105
|
+
要求 Python 3.10+
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
vokit/__init__.py,sha256=_hP00T2Q80lafv7t-_0-bv4-naWbR6FO9-Z7rLRPhp8,1156
|
|
2
|
+
vokit/_api.py,sha256=a_paY5kkbCmJpOB9tLlnr_5hwQwpc130vYyhv6ioJ4A,7036
|
|
3
|
+
vokit/_client.py,sha256=scYstgb_Evpj0H-wzRfU1Pp1Fte8t4SYfYZ2J897IM8,2856
|
|
4
|
+
vokit/_config.py,sha256=2jKGLIgbELOJYK6Qq1swnuVgvXJq2WlUIvix-kcjD8s,2107
|
|
5
|
+
vokit/_errors.py,sha256=yYnOkoEhJ3Js6qKrH2gTW3opWWyAXknwXM_at70Xw7c,1470
|
|
6
|
+
vokit/_interceptor.py,sha256=5rrAOdQFeeUyBB49HtcbkC06_BxXqzKyE8ARODcyRAg,4296
|
|
7
|
+
vokit/_models.py,sha256=bZ2q20sCs2tm6wFUJJNqIjnEqCyL1bRH_0uZanWYxMk,1463
|
|
8
|
+
vokit/_patcher.py,sha256=IBk-KgvtTVqUw4oYQwe0cvyuK2jv9SD8jMzcKym3lX8,10250
|
|
9
|
+
vokit/_providers.py,sha256=1ZoLSKvlx3SeCxBBGDOrUihiWeN2TMA9kjAvO3f4QCc,4372
|
|
10
|
+
vokit-0.1.4.dist-info/METADATA,sha256=xROuYtHrTD12ZQEIdJJnQt8Va8qGO5mqXPOCGNYUZng,2497
|
|
11
|
+
vokit-0.1.4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
vokit-0.1.4.dist-info/top_level.txt,sha256=dR9DTs661y7ViJn87GRzmwWsmQaJiKuDPszIn2Mb0CM,6
|
|
13
|
+
vokit-0.1.4.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vokit
|