bkai-init 0.1.0__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.
bkai_init/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """AIDEV Agent Package initialization CLI."""
2
+
3
+ import logging
4
+
5
+ from .services import BkaiInit
6
+ from .utils.exceptions import APIError, BkaiCliError, ConfigurationError, ManifestError
7
+
8
+ logging.getLogger(__name__).addHandler(logging.NullHandler())
9
+
10
+ __version__ = "0.1.0"
11
+
12
+ __all__ = [
13
+ "APIError",
14
+ "BkaiCliError",
15
+ "BkaiInit",
16
+ "ConfigurationError",
17
+ "ManifestError",
18
+ "__version__",
19
+ ]
bkai_init/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Run ``bkai-init`` with ``python -m bkai_init``."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,5 @@
1
+ """AIDEV application OpenAPI client."""
2
+
3
+ from .client import AidevClient
4
+
5
+ __all__ = ["AidevClient"]
@@ -0,0 +1,465 @@
1
+ """HTTP client for the BKAIDEV application OpenAPI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any
9
+ from urllib.parse import urlsplit
10
+
11
+ import requests
12
+
13
+ from .logging import log_exchange, log_mcp_query, log_result, redact, request_diagnostics
14
+ from .retry import request_with_retry
15
+ from .uploads import upload_archive
16
+ from .users import lookup_username
17
+ from .. import settings
18
+ from ..utils.exceptions import APIError, ConfigurationError
19
+
20
+
21
+ class AidevClient:
22
+ """Small API client with application authentication and response unwrapping."""
23
+
24
+ def __init__(
25
+ self,
26
+ base_url: str,
27
+ app_code: str,
28
+ app_secret: str,
29
+ *,
30
+ tenant_id: str = settings.DEFAULT_TENANT_ID,
31
+ access_token: str | None = None,
32
+ username: str | None = None,
33
+ timeout: float = settings.DEFAULT_TIMEOUT,
34
+ session: requests.Session | None = None,
35
+ ) -> None:
36
+ if not base_url:
37
+ raise ValueError("base_url 不能为空")
38
+ self.api_root = _api_root(base_url)
39
+ self.timeout = timeout
40
+ self.tenant_id = tenant_id
41
+ self._app_code, self._app_secret = app_code, app_secret
42
+ self._login_name = username or settings.DEFAULT_ADMIN_LOGIN_NAME
43
+ self._username_resolved = False
44
+ self._secrets = tuple(value for value in (app_secret, access_token) if value)
45
+ self.session = session or requests.Session()
46
+ authorization = {"bk_app_code": app_code, "bk_app_secret": app_secret}
47
+ if access_token:
48
+ authorization["access_token"] = access_token
49
+ headers = {
50
+ "Accept": "application/json",
51
+ "X-Bk-Tenant-Id": tenant_id,
52
+ "X-Bkapi-Authorization": json.dumps(authorization),
53
+ }
54
+ if username:
55
+ headers["X-BKAIDEV-USER"] = username
56
+ self.session.headers.update({name: _latin1_header(name, value) for name, value in headers.items()})
57
+
58
+ def resolve_username(self, bk_user_base_url: str | None) -> None:
59
+ if not self._username_resolved:
60
+ self.session.headers["X-BKAIDEV-USER"] = lookup_username(
61
+ bk_user_base_url, self._app_code, self._app_secret, self.tenant_id, self.timeout, self._login_name
62
+ )
63
+ self._username_resolved = True
64
+
65
+ def add_agent_admins(self, agent_id: int, space_id: str, admins: list[str]) -> dict[str, Any]:
66
+ return self._request("POST", f"/agents/{agent_id}/admins/", json={"space_id": space_id, "admins": admins})
67
+
68
+ def upload_skill(self, archive: Path, space_id: str) -> dict[str, Any]:
69
+ with archive.open("rb") as file_obj:
70
+ return self._request(
71
+ "POST",
72
+ "/upload/",
73
+ data={"module": "skill", "space_id": space_id},
74
+ files={"file": (archive.name, file_obj, "application/zip")},
75
+ )
76
+
77
+ def upsert_skill(self, payload: dict[str, Any]) -> dict[str, Any]:
78
+ return self._request("POST", "/skills/upsert/", json=payload)
79
+
80
+ def upload_knowledge(self, archive: Path, space_id: str) -> dict[str, Any]:
81
+ with archive.open("rb") as content:
82
+ sha256 = hashlib.file_digest(content, "sha256").hexdigest()
83
+ size = archive.stat().st_size
84
+ grant = self._request(
85
+ "POST",
86
+ "/upload/url/",
87
+ json={
88
+ "space_id": space_id,
89
+ "module": "knowledge",
90
+ "file_name": archive.name,
91
+ },
92
+ )
93
+ if (
94
+ not isinstance(grant, dict)
95
+ or not isinstance(grant.get("url"), str)
96
+ or not grant["url"].startswith("bkrepo://")
97
+ ):
98
+ raise self._http_error(
99
+ "平台返回的知识库直传授权不完整",
100
+ "POST",
101
+ "/upload/url/",
102
+ {"json": {"space_id": space_id, "module": "knowledge", "file_name": archive.name}},
103
+ grant,
104
+ )
105
+ try:
106
+ upload_archive(archive, grant, self.timeout)
107
+ except APIError:
108
+ # A timeout may mean the upload succeeded. Never blindly repeat a PUT.
109
+ self.knowledge_upload_status(grant["url"], space_id, size, sha256)
110
+ else:
111
+ self.knowledge_upload_status(grant["url"], space_id, size, sha256)
112
+ return {"url": grant["url"], "file_name": archive.stem, "file_type": "zip", "file_size": size}
113
+
114
+ def knowledge_upload_status(self, url: str, space_id: str, size: int, sha256: str) -> dict[str, Any]:
115
+ payload = {"space_id": space_id, "url": url}
116
+ data = self._request("POST", "/upload/status/", json=payload)
117
+ if (
118
+ not isinstance(data, dict)
119
+ or data.get("uploaded") is not True
120
+ or data.get("file_size") != size
121
+ or data.get("sha256") != sha256
122
+ ):
123
+ raise self._http_error("知识 ZIP 上传状态校验失败", "POST", "/upload/status/", {"json": payload}, data)
124
+ return data
125
+
126
+ def import_knowledge_archive(self, payload: dict[str, Any]) -> dict[str, Any]:
127
+ return self._request("POST", "/knowledges/archive/import/", json=payload)
128
+
129
+ def knowledge_status(self, space_id: str, anchor_path: str) -> dict[str, Any]:
130
+ return self._request(
131
+ "GET", "/knowledges/status_info/", params={"space_id": space_id, "anchor_path": anchor_path}
132
+ )
133
+
134
+ def list_skills(self, space_id: str, code: str) -> list[dict[str, Any]]:
135
+ return self._listed(
136
+ "GET",
137
+ "/skills/",
138
+ params={"space_id": space_id, "fuzzy": code, "page": 1, "page_size": 200},
139
+ )
140
+
141
+ def get_skill(self, skill_id: int, space_id: str, version: str | None = None) -> dict[str, Any]:
142
+ params: dict[str, Any] = {"space_id": space_id}
143
+ if version:
144
+ params["version"] = version
145
+ return self._request("GET", f"/skills/{skill_id}/", params=params)
146
+
147
+ def list_agents(self, space_id: str, code: str) -> list[dict[str, Any]]:
148
+ return self._listed(
149
+ "GET",
150
+ "/agents/",
151
+ params={"space_id": space_id, "agent_code": code, "page": 1, "page_size": 200},
152
+ )
153
+
154
+ def get_agent(self, agent_id: int, space_id: str, version: str | None = None) -> dict[str, Any]:
155
+ params: dict[str, Any] = {"space_id": space_id}
156
+ if version:
157
+ params["version"] = version
158
+ return self._request("GET", f"/agents/{agent_id}/", params=params)
159
+
160
+ def create_agent(self, payload: dict[str, Any]) -> dict[str, Any]:
161
+ return self._request("POST", "/agents/", json=payload)
162
+
163
+ def update_agent(self, agent_id: int, payload: dict[str, Any]) -> dict[str, Any]:
164
+ return self._request("PUT", f"/agents/{agent_id}/update/", json=payload)
165
+
166
+ def publish_agent(
167
+ self, agent_id: int, space_id: str, *, publish_config_only: bool = settings.DEFAULT_PUBLISH_CONFIG_ONLY
168
+ ) -> dict[str, Any]:
169
+ return self._request(
170
+ "POST",
171
+ f"/agents/{agent_id}/publish/",
172
+ json={
173
+ "space_id": space_id,
174
+ "publish_config_only": publish_config_only,
175
+ },
176
+ )
177
+
178
+ def list_mcps(self, space_id: str, code: str, mcp_type: str) -> list[dict[str, Any]]:
179
+ return self._listed(
180
+ "GET",
181
+ "/mcps/",
182
+ params={
183
+ "space_id": space_id,
184
+ "mcp_code": code,
185
+ "mcp_type": mcp_type,
186
+ "page": 1,
187
+ "page_size": 200,
188
+ },
189
+ )
190
+
191
+ def get_mcp_by_code(
192
+ self, space_id: str | None, code: str, *, mcp_type: str = "apigw", agent_code: str | None = None
193
+ ) -> dict[str, Any]:
194
+ if not code or any(
195
+ char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" for char in code
196
+ ):
197
+ raise APIError("MCP code 只能包含字母、数字、下划线和减号")
198
+ if mcp_type not in {"apigw", "resource"}:
199
+ raise APIError("mcp_type 必须为 apigw 或 resource")
200
+ params = {"mcp_type": mcp_type}
201
+ if space_id is not None:
202
+ params["space_id"] = space_id
203
+ if agent_code is not None:
204
+ params["agent_code"] = agent_code
205
+ try:
206
+ result = self._request("GET", f"/mcps/by-code/{code}/", show_url=False, params=params)
207
+ except APIError as exc:
208
+ exc.request_logged = False
209
+ raise
210
+ log_mcp_query(code, agent_code, secrets=self._secrets)
211
+ return result
212
+
213
+ def list_collections(self, space_id: str, code: str) -> list[dict[str, Any]]:
214
+ return self._listed(
215
+ "GET",
216
+ "/collections/",
217
+ params={
218
+ "space_id": space_id,
219
+ "collection_code": code,
220
+ "page": 1,
221
+ "page_size": 200,
222
+ },
223
+ )
224
+
225
+ def get_collection(self, collection_id: int, space_id: str) -> dict[str, Any]:
226
+ return self._request("GET", f"/collections/{collection_id}/", params={"space_id": space_id})
227
+
228
+ def upsert_collection(self, payload: dict[str, Any]) -> dict[str, Any]:
229
+ return self._request("POST", "/collections/upsert/", json=payload)
230
+
231
+ def list_knowledgebases(self, space_id: str, code: str) -> list[dict[str, Any]]:
232
+ return self._listed(
233
+ "POST",
234
+ "/knowledgebase/list/",
235
+ json={
236
+ "space_id": space_id,
237
+ "knowledgebase_code": code,
238
+ "generate_type": "all",
239
+ "page": 1,
240
+ "page_size": 200,
241
+ },
242
+ )
243
+
244
+ def create_knowledgebase(self, payload: dict[str, Any]) -> dict[str, Any]:
245
+ return self._request("POST", "/knowledgebase/", json=payload)
246
+
247
+ def update_knowledgebase(self, knowledgebase_id: int, payload: dict[str, Any]) -> dict[str, Any]:
248
+ return self._request("PATCH", f"/knowledgebase/{knowledgebase_id}/update/", json=payload)
249
+
250
+ def _listed(self, method: str, path: str, **kwargs: Any) -> list[dict[str, Any]]:
251
+ data = self._request(method, path, **kwargs)
252
+ try:
253
+ return _results(data)
254
+ except APIError as exc:
255
+ raise self._http_error(exc.args[0], method, path, kwargs, data) from None
256
+
257
+ def _request(self, method: str, path: str, *, show_url: bool = True, **kwargs: Any) -> Any:
258
+ if not path.startswith("/") or any(token in path for token in ("..", ":", "?", "#", "%", "//")):
259
+ raise APIError("仅允许调用 app 下的相对资源路径")
260
+ url = f"{self.api_root}{path}"
261
+ inputs = {key: kwargs[key] for key in ("params", "json", "data") if key in kwargs}
262
+ if kwargs.get("files"):
263
+ inputs["files"] = {key: item[0] for key, item in kwargs["files"].items()}
264
+ log_exchange(
265
+ "请求",
266
+ method,
267
+ url,
268
+ next(iter(inputs.values())) if len(inputs) == 1 else inputs,
269
+ secrets=self._secrets,
270
+ show_url=show_url,
271
+ )
272
+ try:
273
+ response = request_with_retry(
274
+ lambda: self.session.request(method, url, timeout=self.timeout, allow_redirects=False, **kwargs),
275
+ query=method == "GET" or (method == "POST" and path == "/upload/status/"),
276
+ )
277
+ except requests.RequestException as exc:
278
+ log_exchange("失败", method, url, {"error": type(exc).__name__}, secrets=self._secrets)
279
+ raise self._http_error(
280
+ f"请求 AIDEV app OpenAPI 失败:{type(exc).__name__}",
281
+ method,
282
+ path,
283
+ kwargs,
284
+ str(exc),
285
+ ) from None
286
+
287
+ if 300 <= response.status_code < 400:
288
+ log_exchange(
289
+ "响应", method, url, {"error": "拒绝重定向"}, status=response.status_code, secrets=self._secrets
290
+ )
291
+ log_result(False, "接口返回重定向,已拒绝跟随", secrets=self._secrets)
292
+ raise self._http_error(
293
+ "app OpenAPI 返回重定向,已拒绝携带凭据跳转",
294
+ method,
295
+ path,
296
+ kwargs,
297
+ {"status": response.status_code},
298
+ status=response.status_code,
299
+ response=response,
300
+ )
301
+
302
+ try:
303
+ payload = response.json()
304
+ except ValueError:
305
+ payload = None
306
+ log_exchange(
307
+ "响应",
308
+ method,
309
+ url,
310
+ payload if payload is not None else response.text[:500],
311
+ status=response.status_code,
312
+ secrets=self._secrets,
313
+ success=response.ok and not (isinstance(payload, dict) and payload.get("result") is False),
314
+ )
315
+ if not response.ok:
316
+ detail = self._redact(_error_detail(self._redact_value(payload)) or response.text[:500])
317
+ log_result(False, f"HTTP {response.status_code}:{detail}", secrets=self._secrets)
318
+ raise self._http_error(
319
+ f"AIDEV OpenAPI 返回 {response.status_code}:{detail}",
320
+ method,
321
+ path,
322
+ kwargs,
323
+ payload if payload is not None else response.text[:500],
324
+ status=response.status_code,
325
+ response=response,
326
+ )
327
+ if isinstance(payload, dict) and payload.get("result") is False:
328
+ log_result(False, _error_detail(self._redact_value(payload)), secrets=self._secrets)
329
+ raise self._http_error(
330
+ f"AIDEV OpenAPI 业务失败:{self._redact(_error_detail(payload))}",
331
+ method,
332
+ path,
333
+ kwargs,
334
+ payload,
335
+ response=response,
336
+ )
337
+ log_result(True)
338
+ if isinstance(payload, dict) and "data" in payload:
339
+ return payload["data"]
340
+ return payload
341
+
342
+ def _http_error(
343
+ self,
344
+ message: str,
345
+ method: str,
346
+ path: str,
347
+ kwargs: dict[str, Any],
348
+ output: Any,
349
+ *,
350
+ status: int | None = None,
351
+ response=None,
352
+ ) -> APIError:
353
+ return APIError(
354
+ message,
355
+ url=f"{self.api_root}{path}",
356
+ method=method,
357
+ params=self._redact_value(_request_params(kwargs)),
358
+ output=self._redact_value(output),
359
+ hint=_error_hint(path, status, message, output),
360
+ diagnostics=self._redact_value(request_diagnostics(response, output)),
361
+ status=status if status is not None else getattr(response, "status_code", None),
362
+ request_logged=True,
363
+ )
364
+
365
+ def _redact_value(self, value: Any) -> Any:
366
+ return redact(value, self._secrets)
367
+
368
+ def _redact(self, message: str) -> str:
369
+ for secret in self._secrets:
370
+ message = message.replace(secret, "[REDACTED]")
371
+ return message
372
+
373
+
374
+ def _latin1_header(name: str, value: str) -> str:
375
+ try:
376
+ value.encode("latin-1")
377
+ except UnicodeEncodeError:
378
+ raise ConfigurationError(f"请求头 {name} 包含非 Latin-1 字符") from None
379
+ return value
380
+
381
+
382
+ def _api_root(base_url: str) -> str:
383
+ normalized = base_url.rstrip("/")
384
+ parsed = urlsplit(normalized)
385
+ if (
386
+ parsed.scheme not in {"http", "https"}
387
+ or not parsed.netloc
388
+ or parsed.username
389
+ or parsed.query
390
+ or parsed.fragment
391
+ ):
392
+ raise ValueError("base_url 必须是无凭据、查询参数和片段的 HTTP(S) 地址")
393
+ if "/private" in parsed.path or ".." in parsed.path or "%" in parsed.path:
394
+ raise ValueError("base_url 不允许 private 接口或路径跳转")
395
+ if normalized.endswith(settings.DEFAULT_API_PREFIX):
396
+ return normalized
397
+ return f"{normalized}{settings.DEFAULT_API_PREFIX}"
398
+
399
+
400
+ def _results(data: Any) -> list[dict[str, Any]]:
401
+ if isinstance(data, dict) and isinstance(data.get("results"), list):
402
+ items = data["results"]
403
+ count = data.get("count", len(items))
404
+ if type(count) is not int or count < 0:
405
+ raise APIError("列表接口 count 无效")
406
+ if count > len(items):
407
+ raise APIError("列表超过单页上限,无法保证 code 唯一;已停止操作")
408
+ if any(not isinstance(item, dict) for item in items):
409
+ raise APIError("列表响应包含无效资源对象")
410
+ return items
411
+ if isinstance(data, list):
412
+ if any(not isinstance(item, dict) for item in data):
413
+ raise APIError("列表响应包含无效资源对象")
414
+ return data
415
+ raise APIError("列表接口响应结构无效,不能按资源不存在处理")
416
+
417
+
418
+ def _error_detail(payload: Any) -> str:
419
+ if not isinstance(payload, dict):
420
+ return ""
421
+ error = payload.get("error")
422
+ if isinstance(error, dict) and error.get("message"):
423
+ return str(error["message"])
424
+ return str(payload.get("message") or error or payload)
425
+
426
+
427
+ def _request_params(kwargs: dict[str, Any]) -> Any:
428
+ snapshot = {key: kwargs[key] for key in ("params", "json", "data") if kwargs.get(key) is not None}
429
+ if len(snapshot) == 1:
430
+ return next(iter(snapshot.values()))
431
+ return snapshot or None
432
+
433
+
434
+ def _error_hint(path: str, status: int | None, detail: str, output: Any = None) -> str | None:
435
+ lowered = detail.lower()
436
+ route_missing = isinstance(output, dict) and (
437
+ output.get("code_name") == "API_NOT_FOUND" or str(output.get("code")) == "1640401"
438
+ )
439
+ if route_missing or (status == 404 and "api not found" in lowered):
440
+ return (
441
+ "网关未找到接口路由,尚未到达业务服务,不能据此判断 MCP 或其他资源是否存在。"
442
+ "请检查 BKAI_BASE_URL / BK_API_URL_TMPL 中的网关名称和 stage,"
443
+ "并确认请求路径及方法已同步、发布到该 APIGW stage。"
444
+ )
445
+ if "/mcps/by-code/" in path:
446
+ code = path.rstrip("/").rsplit("/", 1)[-1]
447
+ if status == 404 or "not found" in lowered:
448
+ return (
449
+ f"bkai-init 不会创建 MCP。请先在平台创建并发布 APIGW MCP {code},"
450
+ "完成当前应用授权,并确认 Agent YAML 中的 code 与线上 mcp_code 一致后再重试。"
451
+ )
452
+ if status in {401, 403}:
453
+ return (
454
+ "查询 MCP 需要应用已获目标空间授权。"
455
+ "非公开 MCP 还需智能体已存在并完成网关授权;plan 不会为此提前创建智能体。"
456
+ )
457
+ if "incorrect string value" in lowered and "skill_markdown" in lowered:
458
+ return "请检查平台 Skill Markdown 字段及数据库连接字符集是否支持 utf8mb4,并提供请求标识排查。"
459
+ if "初始化应用成员信息失败" in detail:
460
+ return "请联系平台管理员检查 BK IAM 用户组创建接口,并提供请求标识与错误详情。"
461
+ if status in {401, 403}:
462
+ return "请检查应用凭据,以及应用是否已获目标空间和相关资源授权。"
463
+ if status is not None and 300 <= status < 400:
464
+ return "请求被重定向,已拒绝跟随以免泄露凭据。请使用可直接访问的 app OpenAPI 地址。"
465
+ return None
@@ -0,0 +1,105 @@
1
+ """Readable transport logs with credentials removed before formatting."""
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ from collections.abc import Mapping
7
+ from textwrap import indent
8
+ from urllib.parse import urlsplit, urlunsplit
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def _is_sensitive_key(key):
14
+ return any(
15
+ word in str(key).lower() for word in ("secret", "token", "password", "authorization", "cookie", "signature")
16
+ )
17
+
18
+
19
+ def redact(value, secrets=()):
20
+ if isinstance(value, dict):
21
+ return {
22
+ key: "[REDACTED]"
23
+ if _is_sensitive_key(key) or (key == "value" and _is_sensitive_key(value.get("key", "")))
24
+ else redact(item, secrets)
25
+ for key, item in value.items()
26
+ }
27
+ if isinstance(value, (list, tuple)):
28
+ return [redact(item, secrets) for item in value]
29
+ if isinstance(value, str):
30
+ for secret in secrets:
31
+ if secret:
32
+ value = value.replace(secret, "[REDACTED]")
33
+
34
+ def public_url(match):
35
+ try:
36
+ url = urlsplit(match.group())
37
+ host = url.netloc.rsplit("@", 1)[-1]
38
+ return urlunsplit((url.scheme, host, url.path, "[REDACTED]" if url.query else "", ""))
39
+ except ValueError:
40
+ return "[REDACTED URL]"
41
+
42
+ return re.sub(r"https?://[^\s\"<>]+", public_url, value)
43
+ return value
44
+
45
+
46
+ def log_result(success, reason="", *, secrets=()):
47
+ outcome = "成功" if success else "失败"
48
+ suffix = f":{redact(str(reason), secrets)}" if reason else ""
49
+ logger.debug(" - 处理结果:%s%s\n", outcome, suffix)
50
+
51
+
52
+ def log_exchange(label, method, url, payload, *, status=None, secrets=(), success=False, show_url=True):
53
+ if label == "响应" and success:
54
+ logger.debug(" - 接口响应: [HTTP %s]\n", status)
55
+ return
56
+ detail = json.dumps(redact(payload, secrets), ensure_ascii=False, indent=2, default=lambda _: "<binary/file>")
57
+ formatted = indent(detail, " ")
58
+ if label == "请求":
59
+ from ..services.progress import current_progress
60
+ from ..services.progress import logger as progress_logger
61
+
62
+ target_logger = progress_logger if current_progress() else logger
63
+ if show_url:
64
+ target_logger.info(" 接口请求:%s %s", method, redact(url, secrets))
65
+ logger.debug("%s", formatted)
66
+ elif label == "失败":
67
+ reason = payload.get("error", "请求失败") if isinstance(payload, dict) else payload
68
+ log_result(False, reason, secrets=secrets)
69
+ else:
70
+ http = f" [HTTP {status}]" if status is not None else ""
71
+ logger.debug(" - 接口%s:%s\n%s\n", label, http, formatted)
72
+
73
+
74
+ def request_diagnostics(response, output):
75
+ result = {}
76
+ headers = getattr(response, "headers", {})
77
+ if isinstance(headers, Mapping):
78
+ result.update(
79
+ {
80
+ key: value
81
+ for key, value in headers.items()
82
+ if key.lower() in {"x-request-id", "x-bkapi-request-id", "x-bk-request-id", "x-trace-id", "traceparent"}
83
+ }
84
+ )
85
+ if isinstance(output, dict):
86
+ for body in (output, output.get("error"), output.get("data")):
87
+ if isinstance(body, dict):
88
+ result.update(
89
+ {
90
+ key: body[key]
91
+ for key in ("request_id", "trace_id", "code", "code_name")
92
+ if body.get(key) is not None
93
+ }
94
+ )
95
+ return result
96
+
97
+
98
+ def log_mcp_query(code, agent_code, *, secrets=()):
99
+ from ..services.progress import current_progress
100
+ from ..services.progress import logger as progress_logger
101
+
102
+ target_logger = progress_logger if current_progress() else logger
103
+ target_logger.info(
104
+ " 【正在查询 MCP】%s agent_code: %s 成功", redact(code, secrets), redact(agent_code or "-", secrets)
105
+ )
bkai_init/api/retry.py ADDED
@@ -0,0 +1,31 @@
1
+ """Bounded retries for read-only requests."""
2
+
3
+ import time
4
+
5
+ import requests
6
+
7
+ from .logging import logger
8
+
9
+
10
+ def request_with_retry(send, *, query=False):
11
+ delays = (1, 2, 4) if query else ()
12
+ for attempt in range(len(delays) + 1):
13
+ try:
14
+ response = send()
15
+ except (requests.ConnectionError, requests.Timeout) as exc:
16
+ if attempt == len(delays):
17
+ raise
18
+ reason = type(exc).__name__
19
+ else:
20
+ if attempt == len(delays) or not (response.status_code == 429 or 500 <= response.status_code < 600):
21
+ return response
22
+ reason = f"HTTP {response.status_code}"
23
+ response.close()
24
+ from ..services.progress import current_progress
25
+
26
+ progress = current_progress()
27
+ if progress:
28
+ progress.retries += 1
29
+ delay = delays[attempt]
30
+ logger.info(" - 查询重试:%s,%s 秒后重试(%s/3)", reason, delay, attempt + 1)
31
+ time.sleep(delay)