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 +19 -0
- bkai_init/__main__.py +5 -0
- bkai_init/api/__init__.py +5 -0
- bkai_init/api/client.py +465 -0
- bkai_init/api/logging.py +105 -0
- bkai_init/api/retry.py +31 -0
- bkai_init/api/uploads.py +84 -0
- bkai_init/api/users.py +105 -0
- bkai_init/cli.py +317 -0
- bkai_init/enums.py +67 -0
- bkai_init/services/__init__.py +20 -0
- bkai_init/services/initializer.py +159 -0
- bkai_init/services/inspection.py +521 -0
- bkai_init/services/knowledge.py +38 -0
- bkai_init/services/planning.py +194 -0
- bkai_init/services/progress.py +234 -0
- bkai_init/services/references.py +138 -0
- bkai_init/services/scaffolding.py +168 -0
- bkai_init/services/sync.py +830 -0
- bkai_init/settings.py +180 -0
- bkai_init/utils/__init__.py +1 -0
- bkai_init/utils/archive.py +84 -0
- bkai_init/utils/codes.py +37 -0
- bkai_init/utils/exceptions.py +66 -0
- bkai_init/utils/knowledge_archive.py +51 -0
- bkai_init/utils/manifest.py +352 -0
- bkai_init/utils/protocol.py +258 -0
- bkai_init/utils/selection.py +37 -0
- bkai_init/utils/variables.py +151 -0
- bkai_init-0.1.0.dist-info/METADATA +387 -0
- bkai_init-0.1.0.dist-info/RECORD +34 -0
- bkai_init-0.1.0.dist-info/WHEEL +5 -0
- bkai_init-0.1.0.dist-info/entry_points.txt +2 -0
- bkai_init-0.1.0.dist-info/top_level.txt +1 -0
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
bkai_init/api/client.py
ADDED
|
@@ -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
|
bkai_init/api/logging.py
ADDED
|
@@ -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)
|