bkai-init 0.1.0rc10__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,398 @@
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 .uploads import upload_archive
14
+ from .. import settings
15
+ from ..utils.exceptions import APIError, ConfigurationError
16
+
17
+
18
+ class AidevClient:
19
+ """Small API client with application authentication and response unwrapping."""
20
+
21
+ def __init__(
22
+ self,
23
+ base_url: str,
24
+ app_code: str,
25
+ app_secret: str,
26
+ *,
27
+ tenant_id: str = settings.DEFAULT_TENANT_ID,
28
+ access_token: str | None = None,
29
+ username: str | None = None,
30
+ timeout: float = settings.DEFAULT_TIMEOUT,
31
+ session: requests.Session | None = None,
32
+ ) -> None:
33
+ if not base_url:
34
+ raise ValueError("base_url 不能为空")
35
+ self.api_root = _api_root(base_url)
36
+ self.timeout = timeout
37
+ self.tenant_id = tenant_id
38
+ self._secrets = tuple(value for value in (app_secret, access_token) if value)
39
+ self.session = session or requests.Session()
40
+ authorization = {"bk_app_code": app_code, "bk_app_secret": app_secret}
41
+ if access_token:
42
+ authorization["access_token"] = access_token
43
+ headers = {
44
+ "Accept": "application/json",
45
+ "X-Bk-Tenant-Id": tenant_id,
46
+ "X-Bkapi-Authorization": json.dumps(authorization),
47
+ }
48
+ if username:
49
+ headers["X-BKAIDEV-USER"] = username
50
+ self.session.headers.update({name: _latin1_header(name, value) for name, value in headers.items()})
51
+
52
+ def upload_skill(self, archive: Path, space_id: str) -> dict[str, Any]:
53
+ with archive.open("rb") as file_obj:
54
+ return self._request(
55
+ "POST",
56
+ "/upload/",
57
+ data={"module": "skill", "space_id": space_id},
58
+ files={"file": (archive.name, file_obj, "application/zip")},
59
+ )
60
+
61
+ def upsert_skill(self, payload: dict[str, Any]) -> dict[str, Any]:
62
+ return self._request("POST", "/skills/upsert/", json=payload)
63
+
64
+ def upload_knowledge(self, archive: Path, space_id: str) -> dict[str, Any]:
65
+ with archive.open("rb") as content:
66
+ sha256 = hashlib.file_digest(content, "sha256").hexdigest()
67
+ size = archive.stat().st_size
68
+ grant = self._request(
69
+ "POST",
70
+ "/upload/url/",
71
+ json={
72
+ "space_id": space_id,
73
+ "module": "knowledge",
74
+ "file_name": archive.name,
75
+ },
76
+ )
77
+ if (
78
+ not isinstance(grant, dict)
79
+ or not isinstance(grant.get("url"), str)
80
+ or not grant["url"].startswith("bkrepo://")
81
+ ):
82
+ raise self._http_error(
83
+ "平台返回的知识库直传授权不完整",
84
+ "POST",
85
+ "/upload/url/",
86
+ {"json": {"space_id": space_id, "module": "knowledge", "file_name": archive.name}},
87
+ grant,
88
+ )
89
+ try:
90
+ upload_archive(archive, grant, self.timeout)
91
+ except APIError:
92
+ # A timeout may mean the upload succeeded. Never blindly repeat a PUT.
93
+ self.knowledge_upload_status(grant["url"], space_id, size, sha256)
94
+ else:
95
+ self.knowledge_upload_status(grant["url"], space_id, size, sha256)
96
+ return {"url": grant["url"], "file_name": archive.stem, "file_type": "zip", "file_size": size}
97
+
98
+ def knowledge_upload_status(self, url: str, space_id: str, size: int, sha256: str) -> dict[str, Any]:
99
+ payload = {"space_id": space_id, "url": url}
100
+ data = self._request("POST", "/upload/status/", json=payload)
101
+ if (
102
+ not isinstance(data, dict)
103
+ or data.get("uploaded") is not True
104
+ or data.get("file_size") != size
105
+ or data.get("sha256") != sha256
106
+ ):
107
+ raise self._http_error(
108
+ "知识 ZIP 上传状态校验失败", "POST", "/upload/status/", {"json": payload}, data
109
+ )
110
+ return data
111
+
112
+ def import_knowledge_archive(self, payload: dict[str, Any]) -> dict[str, Any]:
113
+ return self._request("POST", "/knowledges/archive/import/", json=payload)
114
+
115
+ def knowledge_status(self, space_id: str, anchor_path: str) -> dict[str, Any]:
116
+ return self._request(
117
+ "GET", "/knowledges/status_info/", params={"space_id": space_id, "anchor_path": anchor_path}
118
+ )
119
+
120
+ def list_skills(self, space_id: str, code: str) -> list[dict[str, Any]]:
121
+ return self._listed(
122
+ "GET",
123
+ "/skills/",
124
+ params={"space_id": space_id, "fuzzy": code, "page": 1, "page_size": 200},
125
+ )
126
+
127
+ def get_skill(self, skill_id: int, space_id: str, version: str | None = None) -> dict[str, Any]:
128
+ params: dict[str, Any] = {"space_id": space_id}
129
+ if version:
130
+ params["version"] = version
131
+ return self._request("GET", f"/skills/{skill_id}/", params=params)
132
+
133
+ def list_agents(self, space_id: str, code: str) -> list[dict[str, Any]]:
134
+ return self._listed(
135
+ "GET",
136
+ "/agents/",
137
+ params={"space_id": space_id, "agent_code": code, "page": 1, "page_size": 200},
138
+ )
139
+
140
+ def get_agent(self, agent_id: int, space_id: str, version: str | None = None) -> dict[str, Any]:
141
+ params: dict[str, Any] = {"space_id": space_id}
142
+ if version:
143
+ params["version"] = version
144
+ return self._request("GET", f"/agents/{agent_id}/", params=params)
145
+
146
+ def create_agent(self, payload: dict[str, Any]) -> dict[str, Any]:
147
+ return self._request("POST", "/agents/", json=payload)
148
+
149
+ def update_agent(self, agent_id: int, payload: dict[str, Any]) -> dict[str, Any]:
150
+ return self._request("PUT", f"/agents/{agent_id}/update/", json=payload)
151
+
152
+ def publish_agent(self, agent_id: int, space_id: str, *, publish_config_only: bool = True) -> dict[str, Any]:
153
+ return self._request(
154
+ "POST",
155
+ f"/agents/{agent_id}/publish/",
156
+ json={
157
+ "space_id": space_id,
158
+ "publish_config_only": publish_config_only,
159
+ },
160
+ )
161
+
162
+ def list_mcps(self, space_id: str, code: str, mcp_type: str) -> list[dict[str, Any]]:
163
+ return self._listed(
164
+ "GET",
165
+ "/mcps/",
166
+ params={
167
+ "space_id": space_id,
168
+ "mcp_code": code,
169
+ "mcp_type": mcp_type,
170
+ "page": 1,
171
+ "page_size": 200,
172
+ },
173
+ )
174
+
175
+ def get_mcp_by_code(
176
+ self, space_id: str | None, code: str, *, mcp_type: str = "apigw", agent_code: str | None = None
177
+ ) -> dict[str, Any]:
178
+ if not code or any(
179
+ char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" for char in code
180
+ ):
181
+ raise APIError("MCP code 只能包含字母、数字、下划线和减号")
182
+ if mcp_type not in {"apigw", "resource"}:
183
+ raise APIError("mcp_type 必须为 apigw 或 resource")
184
+ params = {"mcp_type": mcp_type}
185
+ if space_id is not None:
186
+ params["space_id"] = space_id
187
+ if agent_code is not None:
188
+ params["agent_code"] = agent_code
189
+ return self._request("GET", f"/mcps/by-code/{code}/", params=params)
190
+
191
+ def list_collections(self, space_id: str, code: str) -> list[dict[str, Any]]:
192
+ return self._listed(
193
+ "GET",
194
+ "/collections/",
195
+ params={
196
+ "space_id": space_id,
197
+ "collection_code": code,
198
+ "page": 1,
199
+ "page_size": 200,
200
+ },
201
+ )
202
+
203
+ def get_collection(self, collection_id: int, space_id: str) -> dict[str, Any]:
204
+ return self._request("GET", f"/collections/{collection_id}/", params={"space_id": space_id})
205
+
206
+ def upsert_collection(self, payload: dict[str, Any]) -> dict[str, Any]:
207
+ return self._request("POST", "/collections/upsert/", json=payload)
208
+
209
+ def list_knowledgebases(self, space_id: str, code: str) -> list[dict[str, Any]]:
210
+ return self._listed(
211
+ "POST",
212
+ "/knowledgebase/list/",
213
+ json={
214
+ "space_id": space_id,
215
+ "knowledgebase_code": code,
216
+ "generate_type": "all",
217
+ "page": 1,
218
+ "page_size": 200,
219
+ },
220
+ )
221
+
222
+ def create_knowledgebase(self, payload: dict[str, Any]) -> dict[str, Any]:
223
+ return self._request("POST", "/knowledgebase/", json=payload)
224
+
225
+ def update_knowledgebase(self, knowledgebase_id: int, payload: dict[str, Any]) -> dict[str, Any]:
226
+ return self._request("PATCH", f"/knowledgebase/{knowledgebase_id}/update/", json=payload)
227
+
228
+ def _listed(self, method: str, path: str, **kwargs: Any) -> list[dict[str, Any]]:
229
+ data = self._request(method, path, **kwargs)
230
+ try:
231
+ return _results(data)
232
+ except APIError as exc:
233
+ raise self._http_error(exc.args[0], method, path, kwargs, data) from None
234
+
235
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
236
+ if not path.startswith("/") or any(token in path for token in ("..", ":", "?", "#", "%", "//")):
237
+ raise APIError("仅允许调用 app 下的相对资源路径")
238
+ url = f"{self.api_root}{path}"
239
+ try:
240
+ response = self.session.request(method, url, timeout=self.timeout, allow_redirects=False, **kwargs)
241
+ except requests.RequestException as exc:
242
+ raise self._http_error(
243
+ f"请求 AIDEV app OpenAPI 失败:{type(exc).__name__}",
244
+ method,
245
+ path,
246
+ kwargs,
247
+ str(exc),
248
+ ) from None
249
+
250
+ if 300 <= response.status_code < 400:
251
+ raise self._http_error(
252
+ "app OpenAPI 返回重定向,已拒绝携带凭据跳转",
253
+ method,
254
+ path,
255
+ kwargs,
256
+ {"status": response.status_code},
257
+ status=response.status_code,
258
+ )
259
+
260
+ try:
261
+ payload = response.json()
262
+ except ValueError:
263
+ payload = None
264
+ if not response.ok:
265
+ detail = self._redact(_error_detail(payload) or response.text[:500])
266
+ raise self._http_error(
267
+ f"AIDEV OpenAPI 返回 {response.status_code}:{detail}",
268
+ method,
269
+ path,
270
+ kwargs,
271
+ payload if payload is not None else response.text[:500],
272
+ status=response.status_code,
273
+ )
274
+ if isinstance(payload, dict) and payload.get("result") is False:
275
+ raise self._http_error(
276
+ f"AIDEV OpenAPI 业务失败:{self._redact(_error_detail(payload))}",
277
+ method,
278
+ path,
279
+ kwargs,
280
+ payload,
281
+ )
282
+ if isinstance(payload, dict) and "data" in payload:
283
+ return payload["data"]
284
+ return payload
285
+
286
+ def _http_error(
287
+ self,
288
+ message: str,
289
+ method: str,
290
+ path: str,
291
+ kwargs: dict[str, Any],
292
+ output: Any,
293
+ *,
294
+ status: int | None = None,
295
+ ) -> APIError:
296
+ return APIError(
297
+ message,
298
+ url=f"{self.api_root}{path}",
299
+ method=method,
300
+ params=self._redact_value(_request_params(kwargs)),
301
+ output=self._redact_value(output),
302
+ hint=_error_hint(path, status, message),
303
+ )
304
+
305
+ def _redact_value(self, value: Any) -> Any:
306
+ if isinstance(value, str):
307
+ return self._redact(value)
308
+ if isinstance(value, dict):
309
+ return {key: self._redact_value(item) for key, item in value.items()}
310
+ if isinstance(value, list):
311
+ return [self._redact_value(item) for item in value]
312
+ return value
313
+
314
+ def _redact(self, message: str) -> str:
315
+ for secret in self._secrets:
316
+ message = message.replace(secret, "[REDACTED]")
317
+ return message
318
+
319
+
320
+ def _latin1_header(name: str, value: str) -> str:
321
+ try:
322
+ value.encode("latin-1")
323
+ except UnicodeEncodeError:
324
+ raise ConfigurationError(f"请求头 {name} 包含非 Latin-1 字符") from None
325
+ return value
326
+
327
+
328
+ def _api_root(base_url: str) -> str:
329
+ normalized = base_url.rstrip("/")
330
+ parsed = urlsplit(normalized)
331
+ if (
332
+ parsed.scheme not in {"http", "https"}
333
+ or not parsed.netloc
334
+ or parsed.username
335
+ or parsed.query
336
+ or parsed.fragment
337
+ ):
338
+ raise ValueError("base_url 必须是无凭据、查询参数和片段的 HTTP(S) 地址")
339
+ if "/private" in parsed.path or ".." in parsed.path or "%" in parsed.path:
340
+ raise ValueError("base_url 不允许 private 接口或路径跳转")
341
+ if normalized.endswith(settings.DEFAULT_API_PREFIX):
342
+ return normalized
343
+ return f"{normalized}{settings.DEFAULT_API_PREFIX}"
344
+
345
+
346
+ def _results(data: Any) -> list[dict[str, Any]]:
347
+ if isinstance(data, dict) and isinstance(data.get("results"), list):
348
+ items = data["results"]
349
+ count = data.get("count", len(items))
350
+ if type(count) is not int or count < 0:
351
+ raise APIError("列表接口 count 无效")
352
+ if count > len(items):
353
+ raise APIError("列表超过单页上限,无法保证 code 唯一;已停止操作")
354
+ if any(not isinstance(item, dict) for item in items):
355
+ raise APIError("列表响应包含无效资源对象")
356
+ return items
357
+ if isinstance(data, list):
358
+ if any(not isinstance(item, dict) for item in data):
359
+ raise APIError("列表响应包含无效资源对象")
360
+ return data
361
+ raise APIError("列表接口响应结构无效,不能按资源不存在处理")
362
+
363
+
364
+ def _error_detail(payload: Any) -> str:
365
+ if not isinstance(payload, dict):
366
+ return ""
367
+ error = payload.get("error")
368
+ if isinstance(error, dict) and error.get("message"):
369
+ return str(error["message"])
370
+ return str(payload.get("message") or error or payload)
371
+
372
+
373
+ def _request_params(kwargs: dict[str, Any]) -> Any:
374
+ snapshot = {key: kwargs[key] for key in ("params", "json", "data") if kwargs.get(key) is not None}
375
+ if len(snapshot) == 1:
376
+ return next(iter(snapshot.values()))
377
+ return snapshot or None
378
+
379
+
380
+ def _error_hint(path: str, status: int | None, detail: str) -> str | None:
381
+ lowered = detail.lower()
382
+ if "/mcps/by-code/" in path:
383
+ code = path.rstrip("/").rsplit("/", 1)[-1]
384
+ if status == 404 or "not found" in lowered:
385
+ return (
386
+ f"bkai-init 不会创建 MCP。请先在平台创建并发布 APIGW MCP {code},"
387
+ "完成当前应用授权,并确认 Agent YAML 中的 code 与线上 mcp_code 一致后再重试。"
388
+ )
389
+ if status in {401, 403}:
390
+ return (
391
+ "查询 MCP 需要应用已获目标空间授权。"
392
+ "非公开 MCP 还需智能体已存在并完成网关授权;plan 不会为此提前创建智能体。"
393
+ )
394
+ if status in {401, 403}:
395
+ return "请检查应用凭据,以及应用是否已获目标空间和相关资源授权。"
396
+ if status is not None and 300 <= status < 400:
397
+ return "请求被重定向,已拒绝跟随以免泄露凭据。请使用可直接访问的 app OpenAPI 地址。"
398
+ return None
@@ -0,0 +1,63 @@
1
+ """Raw BKRepo transport. Never reuse the application-authenticated session."""
2
+
3
+ from pathlib import Path
4
+ from urllib.parse import urlsplit, urlunsplit
5
+
6
+ import requests
7
+
8
+ from ..utils.exceptions import APIError
9
+
10
+
11
+ def upload_archive(archive: Path, grant: dict, timeout: float) -> None:
12
+ try:
13
+ url = grant["upload_url"]
14
+ parsed = urlsplit(url)
15
+ headers = grant["headers"]
16
+ if (
17
+ grant.get("method") != "PUT"
18
+ or parsed.scheme not in {"http", "https"}
19
+ or not parsed.hostname
20
+ or parsed.username
21
+ or parsed.password
22
+ or parsed.fragment
23
+ or any(ord(c) < 32 for c in url)
24
+ or not isinstance(headers, dict)
25
+ or set(headers) - {"Content-Type", "X-BKREPO-OVERWRITE", "X-Checksum-Sha256"}
26
+ ):
27
+ raise ValueError
28
+ except (KeyError, TypeError, ValueError):
29
+ raise APIError("平台返回的直传授权不合法") from None
30
+ public_url = urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
31
+ # trust_env=False prevents implicit .netrc authentication and proxy credentials.
32
+ with requests.Session() as session:
33
+ session.trust_env = False
34
+ try:
35
+ with archive.open("rb") as content:
36
+ response = session.put(url, data=content, headers=headers, timeout=timeout, allow_redirects=False)
37
+ if not 200 <= response.status_code < 300:
38
+ raise APIError(
39
+ f"BKRepo 直传失败(HTTP {response.status_code})",
40
+ url=public_url,
41
+ method="PUT",
42
+ output={"status": response.status_code},
43
+ hint="直传失败。请查询上传状态确认是否已成功,不要直接重试 PUT。",
44
+ )
45
+ if response.content:
46
+ payload = response.json()
47
+ if not isinstance(payload, dict) or payload.get("code") != 0:
48
+ raise APIError(
49
+ "BKRepo 直传业务失败",
50
+ url=public_url,
51
+ method="PUT",
52
+ output=payload,
53
+ hint="直传业务失败。请查询上传状态确认是否已成功,不要直接重试 PUT。",
54
+ )
55
+ except APIError:
56
+ raise
57
+ except (requests.RequestException, ValueError):
58
+ raise APIError(
59
+ "BKRepo 直传失败或结果未知,请查询上传状态",
60
+ url=public_url,
61
+ method="PUT",
62
+ hint="请查询上传状态确认是否已成功,不要直接重试 PUT。",
63
+ ) from None