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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ vokit