tinet-agent-cli 0.1.0.dev36__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.
Files changed (104) hide show
  1. taco/__init__.py +1 -0
  2. taco/agent_openapi/__init__.py +1 -0
  3. taco/agent_openapi/catalog.py +198 -0
  4. taco/agent_openapi/models.py +47 -0
  5. taco/agent_openapi/parameters.py +111 -0
  6. taco/agent_openapi/service.py +73 -0
  7. taco/aikb/__init__.py +4 -0
  8. taco/aikb/catalog.py +380 -0
  9. taco/aikb/executors.py +157 -0
  10. taco/aikb/file_input.py +55 -0
  11. taco/aikb/models.py +42 -0
  12. taco/aikb/parameters.py +135 -0
  13. taco/aikb/service.py +68 -0
  14. taco/assets/__init__.py +1 -0
  15. taco/assets/agent_openapi/catalog.json +14 -0
  16. taco/assets/agent_openapi/operations/conversation/list-conversations.json +67 -0
  17. taco/assets/agent_openapi/operations/message/list-conversation-messages.json +80 -0
  18. taco/assets/agent_openapi/operations/trace/list-message-traces.json +72 -0
  19. taco/assets/aikb/catalog.json +41 -0
  20. taco/assets/aikb/operations/conversation/chat-conversation-on-open.json +99 -0
  21. taco/assets/aikb/operations/directory/delete-directory.json +81 -0
  22. taco/assets/aikb/operations/directory/edit-directory.json +97 -0
  23. taco/assets/aikb/operations/directory/list-directory-tree.json +106 -0
  24. taco/assets/aikb/operations/directory/save-directory.json +109 -0
  25. taco/assets/aikb/operations/faq/create-faq.json +193 -0
  26. taco/assets/aikb/operations/faq/delete-faq.json +81 -0
  27. taco/assets/aikb/operations/faq/describe-faq.json +82 -0
  28. taco/assets/aikb/operations/faq/edit-faq.json +193 -0
  29. taco/assets/aikb/operations/faq/list-faqs.json +136 -0
  30. taco/assets/aikb/operations/file/create-file.json +145 -0
  31. taco/assets/aikb/operations/file/delete-files.json +110 -0
  32. taco/assets/aikb/operations/file/describe-file.json +82 -0
  33. taco/assets/aikb/operations/file/get-file-upload-url.json +154 -0
  34. taco/assets/aikb/operations/file/list-files.json +146 -0
  35. taco/assets/aikb/operations/media/describe-faq-media-url.json +98 -0
  36. taco/assets/aikb/operations/media/describe-file-media-url.json +90 -0
  37. taco/assets/aikb/operations/oss/signature-for-upload.json +85 -0
  38. taco/assets/aikb/operations/recycle-bin/list-recycled-items.json +151 -0
  39. taco/assets/aikb/operations/repository/describe-bot-repository.json +71 -0
  40. taco/assets/aikb/operations/repository/list-private-repositories.json +75 -0
  41. taco/assets/aikb/operations/repository/list-public-repositories.json +75 -0
  42. taco/assets/aikb/operations/search/search-knowledge-on-open.json +180 -0
  43. taco/assets/examples/README.md +3 -0
  44. taco/assets/examples/agent-basic.json +16 -0
  45. taco/assets/examples/agent-builtin-tool.json +28 -0
  46. taco/assets/examples/agent-workflow-tool.json +30 -0
  47. taco/assets/examples/chatflow-basic.json +24 -0
  48. taco/assets/examples/workflow-http.json +34 -0
  49. taco/assets/manifest.json +12 -0
  50. taco/assets/scenarios/README.md +3 -0
  51. taco/assets/scenarios/agent-basic.json +80 -0
  52. taco/assets/scenarios/agent-builtin-tool.json +116 -0
  53. taco/assets/scenarios/agent-workflow-tool.json +277 -0
  54. taco/assets/schemas/README.md +3 -0
  55. taco/assets/schemas/agent-tool.json +71 -0
  56. taco/assets/schemas/agent.json +199 -0
  57. taco/assets/schemas/chatflow.json +1048 -0
  58. taco/assets/schemas/workflow.http.json +1052 -0
  59. taco/assets/schemas/workflow.json +1052 -0
  60. taco/assets/skills/README.md +3 -0
  61. taco/assets/skills/taco/SKILL.md +68 -0
  62. taco/assets/skills/taco/manifest.json +10 -0
  63. taco/cli.py +127 -0
  64. taco/commands/__init__.py +1 -0
  65. taco/commands/agent.py +760 -0
  66. taco/commands/aikb.py +132 -0
  67. taco/commands/app.py +151 -0
  68. taco/commands/category.py +37 -0
  69. taco/commands/chatflow.py +183 -0
  70. taco/commands/credential.py +222 -0
  71. taco/commands/discovery.py +409 -0
  72. taco/commands/model.py +55 -0
  73. taco/commands/profile.py +124 -0
  74. taco/commands/tool.py +61 -0
  75. taco/commands/workflow.py +714 -0
  76. taco/core/__init__.py +1 -0
  77. taco/core/access_token_manager.py +98 -0
  78. taco/core/api_client.py +263 -0
  79. taco/core/assets.py +81 -0
  80. taco/core/config_store.py +209 -0
  81. taco/core/context.py +19 -0
  82. taco/core/developer_center_client.py +165 -0
  83. taco/core/errors.py +19 -0
  84. taco/core/file_lock.py +80 -0
  85. taco/core/http_headers.py +21 -0
  86. taco/core/openapi_signer.py +221 -0
  87. taco/core/output.py +72 -0
  88. taco/core/profile_manager.py +217 -0
  89. taco/core/profile_resolver.py +151 -0
  90. taco/core/secure_file.py +76 -0
  91. taco/release.py +363 -0
  92. taco/services/__init__.py +1 -0
  93. taco/services/agent_service.py +314 -0
  94. taco/services/app_service.py +153 -0
  95. taco/services/category_service.py +57 -0
  96. taco/services/model_service.py +117 -0
  97. taco/services/tool_service.py +482 -0
  98. taco/services/workflow_compiler.py +1665 -0
  99. taco/services/workflow_service.py +652 -0
  100. taco/services/workflow_tool_service.py +439 -0
  101. tinet_agent_cli-0.1.0.dev36.dist-info/METADATA +158 -0
  102. tinet_agent_cli-0.1.0.dev36.dist-info/RECORD +104 -0
  103. tinet_agent_cli-0.1.0.dev36.dist-info/WHEEL +4 -0
  104. tinet_agent_cli-0.1.0.dev36.dist-info/entry_points.txt +2 -0
taco/aikb/catalog.py ADDED
@@ -0,0 +1,380 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from importlib import resources
6
+ from pathlib import Path, PurePosixPath
7
+ from types import MappingProxyType
8
+ from typing import Any
9
+ from urllib.parse import urlsplit
10
+
11
+ from jsonschema import Draft202012Validator
12
+
13
+ from taco.aikb.models import AikbCatalog, AikbOperation, freeze
14
+ from taco.core.errors import TacoError
15
+
16
+
17
+ SUPPORTED_CATALOG_MAJOR = "1"
18
+ SUPPORTED_SCHEMA_MAJOR = "1"
19
+ EXECUTION_KINDS = frozenset({"http", "presigned-put-v1"})
20
+ HTTP_METHODS = frozenset({"GET", "POST"})
21
+ RESERVED_PARAMETERS = frozenset(
22
+ {"accesskeyid", "expires", "timestamp", "signature", "authorization"}
23
+ )
24
+ _ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
25
+ _ZH_PATTERN = re.compile(r"[\u3400-\u9fff]")
26
+
27
+
28
+ def load_catalog(root: Path | None = None) -> AikbCatalog:
29
+ try:
30
+ if root is None:
31
+ package_root = resources.files("taco.assets").joinpath("aikb")
32
+ payload = json.loads(package_root.joinpath("catalog.json").read_text("utf-8"))
33
+ operation_payloads = {
34
+ entry: json.loads(
35
+ package_root.joinpath("operations", f"{entry}.json").read_text("utf-8")
36
+ )
37
+ for entry in _catalog_entries(payload)
38
+ }
39
+ actual_entries = sorted(
40
+ _resource_json_entries(package_root.joinpath("operations"))
41
+ )
42
+ else:
43
+ payload = json.loads((root / "catalog.json").read_text(encoding="utf-8"))
44
+ entries = _catalog_entries(payload)
45
+ operation_payloads = {
46
+ entry: json.loads(
47
+ (root / "operations" / f"{entry}.json").read_text(encoding="utf-8")
48
+ )
49
+ for entry in entries
50
+ }
51
+ operations_root = root / "operations"
52
+ actual_entries = sorted(
53
+ path.relative_to(operations_root).as_posix()[:-5]
54
+ for path in operations_root.rglob("*.json")
55
+ ) if operations_root.exists() else []
56
+ return _build_catalog(payload, operation_payloads, actual_entries)
57
+ except TacoError:
58
+ raise
59
+ except (OSError, ValueError, TypeError, KeyError) as error:
60
+ raise _catalog_error(str(error)) from error
61
+
62
+
63
+ def _catalog_entries(payload: Any) -> list[str]:
64
+ if not isinstance(payload, dict):
65
+ raise _catalog_error("catalog 必须是对象")
66
+ version = payload.get("catalog_version")
67
+ if not isinstance(version, str) or version.split(".", 1)[0] != SUPPORTED_CATALOG_MAJOR:
68
+ raise _catalog_error("不支持的 catalog_version")
69
+ if payload.get("json_schema_dialect") != "https://json-schema.org/draft/2020-12/schema":
70
+ raise _catalog_error("不支持的 JSON Schema 方言")
71
+ entries = payload.get("operations")
72
+ if not isinstance(entries, list) or any(not isinstance(item, str) for item in entries):
73
+ raise _catalog_error("operations 必须是字符串数组")
74
+ if len(entries) != len(set(entries)):
75
+ raise _catalog_error("catalog operation 重复")
76
+ for entry in entries:
77
+ path = PurePosixPath(entry)
78
+ if path.is_absolute() or len(path.parts) != 2 or ".." in path.parts:
79
+ raise _catalog_error(f"非法 operation 路径:{entry}")
80
+ if not all(_ID_PATTERN.fullmatch(part) for part in path.parts):
81
+ raise _catalog_error(f"非法 operation 路径:{entry}")
82
+ return entries
83
+
84
+
85
+ def _resource_json_entries(root: Any, prefix: str = "") -> list[str]:
86
+ if not root.is_dir():
87
+ return []
88
+ result: list[str] = []
89
+ for child in root.iterdir():
90
+ relative = f"{prefix}/{child.name}" if prefix else child.name
91
+ if child.is_dir():
92
+ result.extend(_resource_json_entries(child, relative))
93
+ elif child.name.endswith(".json"):
94
+ result.append(relative[:-5])
95
+ return result
96
+
97
+
98
+ def _build_catalog(
99
+ payload: dict[str, Any],
100
+ operation_payloads: dict[str, Any],
101
+ actual_entries: list[str],
102
+ ) -> AikbCatalog:
103
+ entries = _catalog_entries(payload)
104
+ if sorted(entries) != actual_entries:
105
+ raise _catalog_error("catalog 登记项与 operation 文件不一致")
106
+ operations = tuple(
107
+ _build_operation(entry, operation_payloads[entry]) for entry in entries
108
+ )
109
+ ids = [operation.id for operation in operations]
110
+ if len(ids) != len(set(ids)):
111
+ raise _catalog_error("operation id 重复")
112
+ known_ids = set(ids)
113
+ for operation in operations:
114
+ for schema in (operation.http["query_schema"], operation.http["body_schema"]):
115
+ for source in _source_operations(schema):
116
+ if source not in known_ids:
117
+ raise _catalog_error(f"未知来源 operation:{source}")
118
+ by_id = MappingProxyType({operation.id: operation for operation in operations})
119
+ return AikbCatalog(
120
+ catalog_version=payload["catalog_version"],
121
+ source=freeze(payload.get("source", {})),
122
+ defaults=freeze(payload.get("defaults", {})),
123
+ operations=operations,
124
+ by_id=by_id,
125
+ )
126
+
127
+
128
+ def _build_operation(entry: str, payload: Any) -> AikbOperation:
129
+ if not isinstance(payload, dict):
130
+ raise _catalog_error(f"{entry} 必须是对象")
131
+ version = payload.get("schema_version")
132
+ if not isinstance(version, str) or version.split(".", 1)[0] != SUPPORTED_SCHEMA_MAJOR:
133
+ raise _catalog_error(f"{entry} schema_version 不受支持")
134
+ group, filename = entry.split("/")
135
+ operation_id = payload.get("id")
136
+ if operation_id != filename or payload.get("group") != group:
137
+ raise _catalog_error(f"{entry} 的 id/group 与文件名不一致")
138
+ for field in ("title", "description"):
139
+ if not isinstance(payload.get(field), str) or not payload[field].strip():
140
+ raise _catalog_error(f"{entry} 缺少 {field}")
141
+ keywords = payload.get("keywords")
142
+ if not isinstance(keywords, list) or any(not isinstance(item, str) for item in keywords):
143
+ raise _catalog_error(f"{entry} keywords 不合法")
144
+ source = payload.get("source")
145
+ if not isinstance(source, dict) or not all(
146
+ isinstance(source.get(field), str) and source[field].strip()
147
+ for field in ("url", "anchor", "snapshot_date")
148
+ ):
149
+ raise _catalog_error(f"{entry} source 不完整")
150
+ http = payload.get("http")
151
+ if not isinstance(http, dict):
152
+ raise _catalog_error(f"{entry} http 不合法")
153
+ method = http.get("method")
154
+ path = http.get("path")
155
+ if method not in HTTP_METHODS or not _safe_aikb_path(path):
156
+ raise _catalog_error(f"{entry} HTTP method/path 不合法")
157
+ query_schema = http.get("query_schema")
158
+ body_schema = http.get("body_schema")
159
+ for name, schema in (("query", query_schema), ("body", body_schema)):
160
+ if not isinstance(schema, dict) or schema.get("type") != "object":
161
+ raise _catalog_error(f"{entry} {name}_schema 必须是 object")
162
+ if schema.get("additionalProperties") is not False:
163
+ raise _catalog_error(f"{entry} {name}_schema 必须禁止未知参数")
164
+ Draft202012Validator.check_schema(schema)
165
+ _validate_schema_descriptions(schema, request=True, root=True)
166
+ query_fields = set(query_schema.get("properties", {}))
167
+ body_fields = set(body_schema.get("properties", {}))
168
+ if query_fields & body_fields:
169
+ raise _catalog_error(f"{entry} query/body 参数重名")
170
+ if any(field.lower() in RESERVED_PARAMETERS for field in query_fields | body_fields):
171
+ raise _catalog_error(f"{entry} 声明了保留认证参数")
172
+ response = payload.get("response")
173
+ if not isinstance(response, dict) or not isinstance(response.get("schema"), dict):
174
+ raise _catalog_error(f"{entry} response 不合法")
175
+ Draft202012Validator.check_schema(response["schema"])
176
+ _validate_schema_descriptions(response["schema"], request=False, root=True)
177
+ pointers = [response.get("request_id_pointer"), *response.get("sensitive_pointers", [])]
178
+ if any(not _schema_has_pointer(response["schema"], pointer) for pointer in pointers):
179
+ raise _catalog_error(f"{entry} response JSON Pointer 不合法")
180
+ risk = payload.get("risk")
181
+ if not isinstance(risk, dict) or risk.get("level") not in {"read", "write", "destructive"}:
182
+ raise _catalog_error(f"{entry} risk 不合法")
183
+ execution = payload.get("execution")
184
+ if not isinstance(execution, dict) or execution.get("kind") not in EXECUTION_KINDS:
185
+ raise _catalog_error(f"{entry} execution kind 不合法")
186
+ if execution["kind"] == "http" and set(execution) != {"kind"}:
187
+ raise _catalog_error(f"{entry} http executor 含未知配置")
188
+ if execution["kind"] == "presigned-put-v1":
189
+ _validate_presigned_put(entry, payload, execution, body_schema, response["schema"])
190
+ notes = payload.get("compatibility_notes", [])
191
+ if not isinstance(notes, list) or any(not isinstance(note, dict) for note in notes):
192
+ raise _catalog_error(f"{entry} compatibility_notes 不合法")
193
+ if any(str(note.get("verification", "")).lower() == "pending" for note in notes):
194
+ raise _catalog_error(f"{entry} 存在未闭环兼容性问题")
195
+ examples = payload.get("examples")
196
+ if not isinstance(examples, list) or any(not isinstance(example, dict) for example in examples):
197
+ raise _catalog_error(f"{entry} examples 不合法")
198
+ _validate_examples(entry, examples, query_schema, body_schema, execution["kind"])
199
+ return AikbOperation(
200
+ id=operation_id,
201
+ title=payload["title"],
202
+ description=payload["description"],
203
+ group=group,
204
+ keywords=tuple(keywords),
205
+ source=freeze(source),
206
+ http=freeze(http),
207
+ response=freeze(response),
208
+ risk=freeze(risk),
209
+ execution=freeze(execution),
210
+ examples=tuple(freeze(examples)),
211
+ file_input=freeze(payload.get("file_input")),
212
+ compatibility_notes=tuple(freeze(notes)),
213
+ )
214
+
215
+
216
+ def _safe_aikb_path(value: Any) -> bool:
217
+ if not isinstance(value, str) or not value.startswith("/aikb/"):
218
+ return False
219
+ parsed = urlsplit(value)
220
+ return not parsed.scheme and not parsed.netloc and not parsed.query and not parsed.fragment and ".." not in PurePosixPath(parsed.path).parts
221
+
222
+
223
+ def _validate_schema_descriptions(schema: dict[str, Any], *, request: bool, root: bool = False) -> None:
224
+ if not root:
225
+ text_required = ("description",) if not request else ("title", "description")
226
+ schema_type = schema.get("type")
227
+ valid_type = isinstance(schema_type, str) and bool(schema_type.strip())
228
+ valid_type = valid_type or (
229
+ isinstance(schema_type, list)
230
+ and bool(schema_type)
231
+ and all(isinstance(item, str) and item.strip() for item in schema_type)
232
+ )
233
+ if not valid_type or any(
234
+ not isinstance(schema.get(field), str) or not schema[field].strip()
235
+ for field in text_required
236
+ ):
237
+ raise _catalog_error("schema 字段缺少类型或说明")
238
+ if not _ZH_PATTERN.search(schema["description"]):
239
+ raise _catalog_error("schema description 必须包含中文")
240
+ if "enum" in schema:
241
+ descriptions = schema.get("x-enum-descriptions")
242
+ if not isinstance(descriptions, dict) or set(map(str, schema["enum"])) != set(descriptions):
243
+ raise _catalog_error("enum 缺少完整说明")
244
+ for child in schema.get("properties", {}).values():
245
+ _validate_schema_descriptions(child, request=request)
246
+ if isinstance(schema.get("items"), dict):
247
+ _validate_schema_descriptions(schema["items"], request=request)
248
+ for keyword in ("oneOf", "anyOf", "allOf"):
249
+ for child in schema.get(keyword, []):
250
+ _validate_schema_descriptions(child, request=request, root=True)
251
+ for child in schema.get("$defs", {}).values():
252
+ _validate_schema_descriptions(child, request=request, root=True)
253
+
254
+
255
+ def _schema_has_pointer(schema: dict[str, Any], pointer: Any) -> bool:
256
+ if (
257
+ not isinstance(pointer, str)
258
+ or not pointer.startswith("/")
259
+ or re.search(r"~(?:[^01]|$)", pointer)
260
+ ):
261
+ return False
262
+ current = schema
263
+ for raw in pointer[1:].split("/"):
264
+ token = raw.replace("~1", "/").replace("~0", "~")
265
+ properties = current.get("properties")
266
+ if not isinstance(properties, dict) or token not in properties:
267
+ return False
268
+ current = properties[token]
269
+ return True
270
+
271
+
272
+ def _validate_presigned_put(
273
+ entry: str,
274
+ payload: dict[str, Any],
275
+ execution: dict[str, Any],
276
+ body_schema: dict[str, Any],
277
+ response_schema: dict[str, Any],
278
+ ) -> None:
279
+ if set(execution) != {"kind", "request_bindings", "upload", "output"}:
280
+ raise _catalog_error(f"{entry} presigned-put-v1 含未知配置")
281
+ file_input = payload.get("file_input")
282
+ if not isinstance(file_input, dict) or file_input.get("required") is not True:
283
+ raise _catalog_error(f"{entry} 缺少必需 file_input")
284
+ bindings = execution.get("request_bindings")
285
+ if not isinstance(bindings, list) or not bindings:
286
+ raise _catalog_error(f"{entry} request_bindings 不合法")
287
+ for binding in bindings:
288
+ if not isinstance(binding, dict) or set(binding) != {"source", "target"}:
289
+ raise _catalog_error(f"{entry} request binding 不合法")
290
+ if binding["source"] not in {"file.basename", "file.size"} or not _schema_has_pointer(body_schema, binding["target"]):
291
+ raise _catalog_error(f"{entry} request binding 不安全")
292
+ upload = execution.get("upload")
293
+ required_upload = {
294
+ "url_pointer",
295
+ "method",
296
+ "success_statuses",
297
+ "follow_redirects",
298
+ "forward_auth_headers",
299
+ "timeout_profile",
300
+ }
301
+ if not isinstance(upload, dict) or set(upload) != required_upload:
302
+ raise _catalog_error(f"{entry} upload 配置不完整")
303
+ if (
304
+ not _schema_has_pointer(response_schema, upload["url_pointer"])
305
+ or upload["method"] != "PUT"
306
+ or not isinstance(upload["success_statuses"], list)
307
+ or not upload["success_statuses"]
308
+ or any(not isinstance(status, int) for status in upload["success_statuses"])
309
+ or upload["follow_redirects"] is not False
310
+ or upload["forward_auth_headers"] is not False
311
+ or upload["timeout_profile"] != "upload"
312
+ ):
313
+ raise _catalog_error(f"{entry} upload 配置不安全")
314
+ output = execution.get("output")
315
+ if not isinstance(output, dict) or not output:
316
+ raise _catalog_error(f"{entry} output 配置不合法")
317
+ for field in output.values():
318
+ if not isinstance(field, dict):
319
+ raise _catalog_error(f"{entry} output 来源不安全")
320
+ if field.get("constant") is True and field.get("type") == "boolean":
321
+ continue
322
+ if field.get("source") not in {
323
+ "prepare_response",
324
+ "file.basename",
325
+ "file.size",
326
+ }:
327
+ raise _catalog_error(f"{entry} output 来源不安全")
328
+ if field["source"] == "prepare_response" and not _schema_has_pointer(
329
+ response_schema, field.get("pointer")
330
+ ):
331
+ raise _catalog_error(f"{entry} output pointer 不合法")
332
+
333
+
334
+ def _source_operations(schema: Any):
335
+ if isinstance(schema, dict):
336
+ source = schema.get("x-taco-source-operation")
337
+ if isinstance(source, str):
338
+ yield source
339
+ for value in schema.values():
340
+ yield from _source_operations(value)
341
+ elif isinstance(schema, list):
342
+ for value in schema:
343
+ yield from _source_operations(value)
344
+
345
+
346
+ def _validate_examples(
347
+ entry: str,
348
+ examples: list[dict[str, Any]],
349
+ query_schema: dict[str, Any],
350
+ body_schema: dict[str, Any],
351
+ execution_kind: str,
352
+ ) -> None:
353
+ query_fields = set(query_schema.get("properties", {}))
354
+ body_fields = set(body_schema.get("properties", {}))
355
+ for example in examples:
356
+ if not isinstance(example.get("title"), str):
357
+ raise _catalog_error(f"{entry} example 缺少 title")
358
+ params = example.get("params", {})
359
+ if execution_kind == "presigned-put-v1" and "file" in example:
360
+ continue
361
+ if not isinstance(params, dict):
362
+ raise _catalog_error(f"{entry} example params 不合法")
363
+ unknown = set(params) - query_fields - body_fields
364
+ if unknown:
365
+ raise _catalog_error(f"{entry} example 含未知参数")
366
+ errors = [
367
+ *Draft202012Validator(query_schema).iter_errors({key: value for key, value in params.items() if key in query_fields}),
368
+ *Draft202012Validator(body_schema).iter_errors({key: value for key, value in params.items() if key in body_fields}),
369
+ ]
370
+ if errors:
371
+ raise _catalog_error(f"{entry} example 未通过 schema")
372
+
373
+
374
+ def _catalog_error(detail: str) -> TacoError:
375
+ return TacoError(
376
+ code="AIKB_CATALOG_INVALID",
377
+ message="内置 AIKB operation catalog 不合法。",
378
+ exit_code=2,
379
+ hint=detail,
380
+ )
taco/aikb/executors.py ADDED
@@ -0,0 +1,157 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import os
5
+ from typing import Any
6
+ from urllib.parse import urlsplit
7
+
8
+ import httpx
9
+ from jsonschema import Draft202012Validator
10
+
11
+ from taco.aikb.file_input import OpenFileInput
12
+ from taco.aikb.models import AikbOperation
13
+ from taco.aikb.parameters import thaw
14
+ from taco.core.developer_center_client import DeveloperCenterClient
15
+ from taco.core.errors import TacoError
16
+ from taco.core.http_headers import with_cli_user_agent
17
+
18
+
19
+ DEFAULT_UPLOAD_TIMEOUT_SECONDS = 600.0
20
+
21
+
22
+ def execute_http(
23
+ operation: AikbOperation,
24
+ client: DeveloperCenterClient,
25
+ *,
26
+ query: dict[str, Any],
27
+ body: dict[str, Any],
28
+ ) -> dict[str, Any]:
29
+ payload = client.request_json(
30
+ operation.http["method"],
31
+ operation.http["path"],
32
+ query=query or None,
33
+ json_body=body or None,
34
+ )
35
+ return validate_response(operation, payload)
36
+
37
+
38
+ def execute_presigned_put(
39
+ operation: AikbOperation,
40
+ client: DeveloperCenterClient,
41
+ *,
42
+ query: dict[str, Any],
43
+ body: dict[str, Any],
44
+ file: OpenFileInput,
45
+ upload_http_client: httpx.Client | None = None,
46
+ upload_timeout: float | None = None,
47
+ ) -> dict[str, Any]:
48
+ timeout = _resolve_upload_timeout(upload_timeout)
49
+ bound_body = dict(body)
50
+ bound_body.update({"fileName": file.name, "fileSize": file.size})
51
+ prepared = client.request_json(
52
+ operation.http["method"], operation.http["path"], query=query or None, json_body=bound_body
53
+ )
54
+ validate_response(operation, prepared)
55
+ try:
56
+ upload_url = prepared["result"]["uploadUrl"]
57
+ file_key = prepared["result"]["fileKey"]
58
+ except (KeyError, TypeError):
59
+ raise _response_error(_request_id(prepared)) from None
60
+ if not isinstance(upload_url, str) or not isinstance(file_key, str) or not file_key:
61
+ raise _response_error(_request_id(prepared))
62
+ parsed = urlsplit(upload_url)
63
+ if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
64
+ raise TacoError(code="AIKB_UPLOAD_URL_INVALID", message="临时上传地址不安全。", exit_code=4)
65
+ file.verify_unchanged()
66
+ owns_client = upload_http_client is None
67
+ upload_client = upload_http_client or httpx.Client(follow_redirects=False, timeout=timeout)
68
+ try:
69
+ with file.stream() as stream:
70
+ response = upload_client.put(
71
+ upload_url,
72
+ content=stream,
73
+ headers=with_cli_user_agent({"Content-Length": str(file.size)}),
74
+ follow_redirects=False,
75
+ timeout=timeout,
76
+ )
77
+ except httpx.HTTPError:
78
+ raise TacoError(
79
+ code="AIKB_UPLOAD_FAILED",
80
+ message="文档 PUT 上传失败,结果不明确,未自动重试。",
81
+ exit_code=4,
82
+ ) from None
83
+ finally:
84
+ if owns_client:
85
+ upload_client.close()
86
+ if response.status_code != 200:
87
+ raise TacoError(
88
+ code="AIKB_UPLOAD_FAILED",
89
+ message=f"文档 PUT 上传失败,HTTP 状态码 {response.status_code}。",
90
+ exit_code=4,
91
+ status=response.status_code,
92
+ )
93
+ return {
94
+ "operation": operation.id,
95
+ "file_name": file.name,
96
+ "file_size": file.size,
97
+ "file_key": file_key,
98
+ "uploaded": True,
99
+ }
100
+
101
+
102
+ def validate_response(operation: AikbOperation, payload: Any) -> dict[str, Any]:
103
+ request_id = _request_id(payload)
104
+ error = next(
105
+ Draft202012Validator(thaw(operation.response["schema"])).iter_errors(payload),
106
+ None,
107
+ )
108
+ if error is not None:
109
+ pointer = "/" + "/".join(str(item) for item in error.absolute_path)
110
+ raise TacoError(
111
+ code="AIKB_RESPONSE_INVALID",
112
+ message=f"AIKB 响应 {pointer or '/'} 校验失败,期望 {error.validator}。",
113
+ exit_code=4,
114
+ request_id=request_id,
115
+ path=operation.http["path"],
116
+ )
117
+ business = dict(payload)
118
+ business.pop("requestId", None)
119
+ return {"request_id": request_id, "data": business}
120
+
121
+
122
+ def _request_id(payload: Any) -> str | None:
123
+ if isinstance(payload, dict) and isinstance(payload.get("requestId"), str):
124
+ return payload["requestId"]
125
+ return None
126
+
127
+
128
+ def _response_error(request_id: str | None) -> TacoError:
129
+ return TacoError(
130
+ code="AIKB_RESPONSE_INVALID",
131
+ message="获取上传地址响应缺少必要字段。",
132
+ exit_code=4,
133
+ request_id=request_id,
134
+ )
135
+
136
+
137
+ def _resolve_upload_timeout(value: float | None) -> float:
138
+ raw: float | str = (
139
+ value
140
+ if value is not None
141
+ else os.getenv(
142
+ "TACO_AIKB_UPLOAD_TIMEOUT_SECONDS",
143
+ str(DEFAULT_UPLOAD_TIMEOUT_SECONDS),
144
+ )
145
+ )
146
+ try:
147
+ timeout = float(raw)
148
+ except (TypeError, ValueError):
149
+ timeout = 0.0
150
+ if not math.isfinite(timeout) or timeout <= 0:
151
+ raise TacoError(
152
+ code="AIKB_UPLOAD_TIMEOUT_INVALID",
153
+ message="AIKB 上传超时必须是有限的正数秒。",
154
+ exit_code=2,
155
+ hint="请检查 --upload-timeout 或 TACO_AIKB_UPLOAD_TIMEOUT_SECONDS。",
156
+ )
157
+ return timeout
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import stat
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from taco.core.errors import TacoError
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class OpenFileInput:
13
+ path: Path
14
+ descriptor: int
15
+ name: str
16
+ size: int
17
+ _snapshot: os.stat_result
18
+
19
+ @classmethod
20
+ def open(cls, path: Path) -> OpenFileInput:
21
+ flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
22
+ try:
23
+ descriptor = os.open(path, flags)
24
+ snapshot = os.fstat(descriptor)
25
+ except OSError:
26
+ raise _file_error("无法安全打开上传文件。") from None
27
+ if not stat.S_ISREG(snapshot.st_mode):
28
+ os.close(descriptor)
29
+ raise _file_error("上传目标必须是普通文件。")
30
+ return cls(path, descriptor, path.name, snapshot.st_size, snapshot)
31
+
32
+ def verify_unchanged(self) -> None:
33
+ current = os.fstat(self.descriptor)
34
+ fields = ("st_dev", "st_ino", "st_size", "st_mtime_ns", "st_mode")
35
+ if any(getattr(current, field) != getattr(self._snapshot, field) for field in fields):
36
+ raise _file_error("上传文件在申请临时地址期间发生变化。")
37
+
38
+ def stream(self):
39
+ os.lseek(self.descriptor, 0, os.SEEK_SET)
40
+ return os.fdopen(os.dup(self.descriptor), "rb")
41
+
42
+ def close(self) -> None:
43
+ if self.descriptor >= 0:
44
+ descriptor, self.descriptor = self.descriptor, -1
45
+ os.close(descriptor)
46
+
47
+ def __enter__(self) -> OpenFileInput:
48
+ return self
49
+
50
+ def __exit__(self, *args: object) -> None:
51
+ self.close()
52
+
53
+
54
+ def _file_error(message: str) -> TacoError:
55
+ return TacoError(code="AIKB_FILE_INVALID", message=message, exit_code=2)
taco/aikb/models.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from types import MappingProxyType
5
+ from typing import Any, Mapping
6
+
7
+
8
+ JsonMapping = Mapping[str, Any]
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class AikbOperation:
13
+ id: str
14
+ title: str
15
+ description: str
16
+ group: str
17
+ keywords: tuple[str, ...]
18
+ source: JsonMapping
19
+ http: JsonMapping
20
+ response: JsonMapping
21
+ risk: JsonMapping
22
+ execution: JsonMapping
23
+ examples: tuple[JsonMapping, ...]
24
+ file_input: JsonMapping | None = None
25
+ compatibility_notes: tuple[JsonMapping, ...] = ()
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class AikbCatalog:
30
+ catalog_version: str
31
+ source: JsonMapping
32
+ defaults: JsonMapping
33
+ operations: tuple[AikbOperation, ...]
34
+ by_id: Mapping[str, AikbOperation]
35
+
36
+
37
+ def freeze(value: Any) -> Any:
38
+ if isinstance(value, dict):
39
+ return MappingProxyType({key: freeze(item) for key, item in value.items()})
40
+ if isinstance(value, list):
41
+ return tuple(freeze(item) for item in value)
42
+ return value