iac-code 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.
Files changed (184) hide show
  1. iac_code/__init__.py +2 -0
  2. iac_code/acp/__init__.py +97 -0
  3. iac_code/acp/convert.py +423 -0
  4. iac_code/acp/http_sse.py +448 -0
  5. iac_code/acp/mcp.py +54 -0
  6. iac_code/acp/metrics.py +71 -0
  7. iac_code/acp/server.py +662 -0
  8. iac_code/acp/session.py +446 -0
  9. iac_code/acp/slash_registry.py +125 -0
  10. iac_code/acp/state.py +99 -0
  11. iac_code/acp/tools.py +112 -0
  12. iac_code/acp/types.py +13 -0
  13. iac_code/acp/version.py +26 -0
  14. iac_code/agent/__init__.py +19 -0
  15. iac_code/agent/agent_loop.py +640 -0
  16. iac_code/agent/agent_tool.py +269 -0
  17. iac_code/agent/agent_types.py +87 -0
  18. iac_code/agent/message.py +153 -0
  19. iac_code/agent/system_prompt.py +313 -0
  20. iac_code/cli/__init__.py +3 -0
  21. iac_code/cli/headless.py +114 -0
  22. iac_code/cli/main.py +246 -0
  23. iac_code/cli/output_formats.py +125 -0
  24. iac_code/commands/__init__.py +93 -0
  25. iac_code/commands/auth.py +1055 -0
  26. iac_code/commands/clear.py +34 -0
  27. iac_code/commands/compact.py +43 -0
  28. iac_code/commands/debug.py +45 -0
  29. iac_code/commands/effort.py +116 -0
  30. iac_code/commands/exit.py +10 -0
  31. iac_code/commands/help.py +49 -0
  32. iac_code/commands/model.py +130 -0
  33. iac_code/commands/registry.py +245 -0
  34. iac_code/commands/resume.py +49 -0
  35. iac_code/commands/tasks.py +41 -0
  36. iac_code/config.py +304 -0
  37. iac_code/i18n/__init__.py +141 -0
  38. iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
  39. iac_code/memory/__init__.py +1 -0
  40. iac_code/memory/memory_manager.py +92 -0
  41. iac_code/memory/memory_tools.py +88 -0
  42. iac_code/providers/__init__.py +1 -0
  43. iac_code/providers/anthropic_provider.py +284 -0
  44. iac_code/providers/base.py +128 -0
  45. iac_code/providers/dashscope_provider.py +47 -0
  46. iac_code/providers/deepseek_provider.py +36 -0
  47. iac_code/providers/manager.py +399 -0
  48. iac_code/providers/openai_provider.py +344 -0
  49. iac_code/providers/retry.py +58 -0
  50. iac_code/providers/stream_watchdog.py +47 -0
  51. iac_code/providers/thinking.py +164 -0
  52. iac_code/services/__init__.py +1 -0
  53. iac_code/services/agent_factory.py +127 -0
  54. iac_code/services/cloud_credentials.py +22 -0
  55. iac_code/services/context_manager.py +221 -0
  56. iac_code/services/providers/__init__.py +1 -0
  57. iac_code/services/providers/aliyun.py +232 -0
  58. iac_code/services/session_index.py +281 -0
  59. iac_code/services/session_storage.py +245 -0
  60. iac_code/services/telemetry/__init__.py +66 -0
  61. iac_code/services/telemetry/attributes.py +84 -0
  62. iac_code/services/telemetry/client.py +330 -0
  63. iac_code/services/telemetry/config.py +76 -0
  64. iac_code/services/telemetry/constants.py +75 -0
  65. iac_code/services/telemetry/content_serializer.py +124 -0
  66. iac_code/services/telemetry/events.py +42 -0
  67. iac_code/services/telemetry/fallback.py +59 -0
  68. iac_code/services/telemetry/identity.py +73 -0
  69. iac_code/services/telemetry/metrics.py +62 -0
  70. iac_code/services/telemetry/names.py +199 -0
  71. iac_code/services/telemetry/sanitize.py +88 -0
  72. iac_code/services/telemetry/sink.py +67 -0
  73. iac_code/services/telemetry/tracing.py +38 -0
  74. iac_code/services/telemetry/types.py +13 -0
  75. iac_code/services/token_budget.py +54 -0
  76. iac_code/services/token_counter.py +76 -0
  77. iac_code/skills/__init__.py +1 -0
  78. iac_code/skills/bundled/__init__.py +94 -0
  79. iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
  80. iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
  81. iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
  82. iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
  83. iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
  84. iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
  85. iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
  86. iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
  87. iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
  88. iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
  89. iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
  90. iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
  91. iac_code/skills/bundled/simplify.py +28 -0
  92. iac_code/skills/discovery.py +136 -0
  93. iac_code/skills/frontmatter.py +119 -0
  94. iac_code/skills/listing.py +92 -0
  95. iac_code/skills/loader.py +42 -0
  96. iac_code/skills/processor.py +81 -0
  97. iac_code/skills/renderer.py +157 -0
  98. iac_code/skills/skill_definition.py +82 -0
  99. iac_code/skills/skill_tool.py +261 -0
  100. iac_code/state/__init__.py +5 -0
  101. iac_code/state/app_state.py +122 -0
  102. iac_code/tasks/__init__.py +1 -0
  103. iac_code/tasks/notification_queue.py +28 -0
  104. iac_code/tasks/task_state.py +66 -0
  105. iac_code/tasks/task_tools.py +114 -0
  106. iac_code/tools/__init__.py +8 -0
  107. iac_code/tools/base.py +226 -0
  108. iac_code/tools/bash.py +133 -0
  109. iac_code/tools/cloud/__init__.py +0 -0
  110. iac_code/tools/cloud/aliyun/__init__.py +0 -0
  111. iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
  112. iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
  113. iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
  114. iac_code/tools/cloud/aliyun/ros_client.py +56 -0
  115. iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
  116. iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
  117. iac_code/tools/cloud/base_api.py +162 -0
  118. iac_code/tools/cloud/base_stack.py +242 -0
  119. iac_code/tools/cloud/registry.py +20 -0
  120. iac_code/tools/cloud/types.py +105 -0
  121. iac_code/tools/edit_file.py +121 -0
  122. iac_code/tools/glob.py +103 -0
  123. iac_code/tools/grep.py +254 -0
  124. iac_code/tools/list_files.py +104 -0
  125. iac_code/tools/read_file.py +127 -0
  126. iac_code/tools/result_storage.py +39 -0
  127. iac_code/tools/tool_executor.py +165 -0
  128. iac_code/tools/web_fetch.py +177 -0
  129. iac_code/tools/write_file.py +88 -0
  130. iac_code/types/__init__.py +40 -0
  131. iac_code/types/permissions.py +26 -0
  132. iac_code/types/skill_source.py +11 -0
  133. iac_code/types/stream_events.py +227 -0
  134. iac_code/ui/__init__.py +5 -0
  135. iac_code/ui/banner.py +110 -0
  136. iac_code/ui/components/__init__.py +0 -0
  137. iac_code/ui/components/dialog.py +142 -0
  138. iac_code/ui/components/divider.py +20 -0
  139. iac_code/ui/components/fuzzy_picker.py +308 -0
  140. iac_code/ui/components/progress_bar.py +54 -0
  141. iac_code/ui/components/search_box.py +165 -0
  142. iac_code/ui/components/select.py +319 -0
  143. iac_code/ui/components/status_icon.py +42 -0
  144. iac_code/ui/components/tabs.py +128 -0
  145. iac_code/ui/core/__init__.py +0 -0
  146. iac_code/ui/core/in_place_render.py +129 -0
  147. iac_code/ui/core/input_history.py +118 -0
  148. iac_code/ui/core/key_event.py +41 -0
  149. iac_code/ui/core/prompt_input.py +507 -0
  150. iac_code/ui/core/raw_input.py +302 -0
  151. iac_code/ui/core/screen.py +80 -0
  152. iac_code/ui/dialogs/__init__.py +0 -0
  153. iac_code/ui/dialogs/global_search.py +178 -0
  154. iac_code/ui/dialogs/history_search.py +100 -0
  155. iac_code/ui/dialogs/model_picker.py +280 -0
  156. iac_code/ui/dialogs/quick_open.py +108 -0
  157. iac_code/ui/dialogs/resume_picker.py +749 -0
  158. iac_code/ui/keybindings/__init__.py +0 -0
  159. iac_code/ui/keybindings/manager.py +124 -0
  160. iac_code/ui/renderer.py +1535 -0
  161. iac_code/ui/repl.py +772 -0
  162. iac_code/ui/spinner.py +112 -0
  163. iac_code/ui/suggestions/__init__.py +0 -0
  164. iac_code/ui/suggestions/aggregator.py +171 -0
  165. iac_code/ui/suggestions/command_provider.py +43 -0
  166. iac_code/ui/suggestions/directory_provider.py +95 -0
  167. iac_code/ui/suggestions/file_provider.py +121 -0
  168. iac_code/ui/suggestions/shell_history_provider.py +108 -0
  169. iac_code/ui/suggestions/token_extractor.py +77 -0
  170. iac_code/ui/suggestions/types.py +45 -0
  171. iac_code/ui/transcript_view.py +199 -0
  172. iac_code/utils/__init__.py +0 -0
  173. iac_code/utils/background_housekeeping.py +53 -0
  174. iac_code/utils/cleanup.py +68 -0
  175. iac_code/utils/json_utils.py +60 -0
  176. iac_code/utils/log.py +150 -0
  177. iac_code/utils/project_paths.py +74 -0
  178. iac_code/utils/tool_input_parser.py +62 -0
  179. iac_code-0.1.0.dist-info/LICENSE +201 -0
  180. iac_code-0.1.0.dist-info/METADATA +64 -0
  181. iac_code-0.1.0.dist-info/RECORD +184 -0
  182. iac_code-0.1.0.dist-info/WHEEL +5 -0
  183. iac_code-0.1.0.dist-info/entry_points.txt +2 -0
  184. iac_code-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,510 @@
1
+ """Generic Alibaba Cloud API tool using OpenAPI SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+ from alibabacloud_tea_openapi import models as open_api_models
13
+ from alibabacloud_tea_openapi.client import Client as OpenApiClient
14
+ from darabonba.runtime import RuntimeOptions
15
+
16
+ from iac_code.i18n import _
17
+ from iac_code.services.cloud_credentials import CloudCredentials
18
+ from iac_code.services.providers.aliyun import AliyunCredential
19
+ from iac_code.services.telemetry import add_metric, log_event
20
+ from iac_code.services.telemetry.names import Events, Metrics
21
+ from iac_code.services.telemetry.sanitize import sanitize_error_message
22
+ from iac_code.tools.base import ToolContext, ToolResult
23
+ from iac_code.tools.cloud.base_api import BaseCloudApi
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ VERSION_MAP = {
28
+ "ros": "2019-09-10",
29
+ "ecs": "2014-05-26",
30
+ "rds": "2014-08-15",
31
+ "r-kvstore": "2015-01-01",
32
+ "slb": "2014-05-15",
33
+ "alb": "2024-03-27",
34
+ "nlb": "2022-04-30",
35
+ "vpc": "2016-04-28",
36
+ "oss": "2019-05-17",
37
+ "IaCService": "2021-08-06",
38
+ }
39
+
40
+ # Endpoint config loaded from endpoints.yml
41
+ _ENDPOINTS_FILE = Path(__file__).parent / "endpoints.yml"
42
+
43
+
44
+ def _load_endpoints() -> dict[str, Any]:
45
+ data = yaml.safe_load(_ENDPOINTS_FILE.read_text()) or {}
46
+ # Convert region lists to sets for O(1) lookup
47
+ for config in data.values():
48
+ for key in ("regional", "central"):
49
+ section = config.get(key)
50
+ if section and "regions" in section:
51
+ section["regions"] = set(section["regions"])
52
+ return data
53
+
54
+
55
+ _ENDPOINTS: dict[str, Any] = _load_endpoints()
56
+
57
+ # Cache for Location service discovered endpoints
58
+ _endpoint_cache: dict[tuple[str, str], str | None] = {}
59
+
60
+ # Error categories for template validation
61
+ _VALIDATE_ERROR_CATEGORIES: dict[str, str] = {
62
+ "InvalidTemplateURL": "invalid_url",
63
+ "InvalidTemplate": "invalid_template",
64
+ "TemplateNotFound": "not_found",
65
+ "AccessDenied": "access_denied",
66
+ "InvalidJSON": "invalid_json",
67
+ "InvalidYAML": "invalid_yaml",
68
+ }
69
+
70
+
71
+ def _extract_error_info(error_str: str) -> tuple[str | None, str | None]:
72
+ """Extract error code and message from exception string.
73
+
74
+ Aliyun errors typically come in formats like:
75
+ - "InvalidTemplate Response: {...}"
76
+ - "InvalidAction.NotFound: The specified action is not found."
77
+ """
78
+ error_code = None
79
+ error_message = None
80
+
81
+ if not error_str:
82
+ return error_code, error_message
83
+
84
+ # Try to extract error code (first word before space or colon)
85
+ parts = error_str.split()
86
+ if parts:
87
+ first_part = parts[0].rstrip(":")
88
+ if not first_part.startswith("{"): # Skip JSON fragments
89
+ error_code = first_part
90
+
91
+ # Remove "Response: {...}" suffix to get clean message
92
+ if "Response:" in error_str:
93
+ error_message = error_str.split("Response:")[0].strip()
94
+ else:
95
+ error_message = error_str
96
+
97
+ return error_code, error_message
98
+
99
+
100
+ def _emit_validate_template_event(response_body: dict | Any, duration_ms: int) -> None:
101
+ """Emit TEMPLATE_VALIDATED event for ROS ValidateTemplate action.
102
+
103
+ Maps response outcome to pass/fail and classifies error if present.
104
+ """
105
+ outcome = "pass"
106
+ error_category = None
107
+
108
+ # Check if response contains validation errors
109
+ if isinstance(response_body, dict):
110
+ errors = response_body.get("Errors")
111
+ if errors and len(errors) > 0:
112
+ outcome = "fail"
113
+ # Try to classify the first error
114
+ first_error = errors[0] if isinstance(errors, list) else errors
115
+ if isinstance(first_error, dict):
116
+ error_key = first_error.get("ErrorCode") or first_error.get("Type", "")
117
+ # Look up error category from mapping
118
+ for pattern, category in _VALIDATE_ERROR_CATEGORIES.items():
119
+ if pattern in error_key:
120
+ error_category = category
121
+ break
122
+ if not error_category:
123
+ error_category = "other"
124
+
125
+ log_event(
126
+ Events.TEMPLATE_VALIDATED,
127
+ {
128
+ "outcome": outcome,
129
+ "duration_ms": duration_ms,
130
+ "error_category": error_category,
131
+ },
132
+ )
133
+ add_metric(
134
+ Metrics.TEMPLATE_VALIDATED_COUNT,
135
+ 1,
136
+ {"outcome": outcome},
137
+ )
138
+
139
+
140
+ class AliyunApi(BaseCloudApi):
141
+ """Generic Alibaba Cloud API tool.
142
+
143
+ Can call any Aliyun product API through the common OpenAPI SDK.
144
+ """
145
+
146
+ @property
147
+ def provider_name(self) -> str:
148
+ return "aliyun"
149
+
150
+ @property
151
+ def supported_actions(self) -> list[str]:
152
+ return []
153
+
154
+ async def call_action(self, action: str, params: dict, region: str) -> dict:
155
+ raise NotImplementedError("AliyunApi uses execute() directly, not call_action()")
156
+
157
+ @property
158
+ def description(self) -> str:
159
+ return (
160
+ "Call any Alibaba Cloud product API through the common OpenAPI SDK. "
161
+ "Supports ECS, RDS, Redis, SLB, ALB, VPC, OSS, ROS, and more."
162
+ )
163
+
164
+ def user_facing_name(self, input: dict | None = None) -> str:
165
+ return _("Aliyun API")
166
+
167
+ def _get_default_region(self) -> str:
168
+ credentials = CloudCredentials()
169
+ cred = credentials.get_provider("aliyun")
170
+ return cred.region_id if cred else ""
171
+
172
+ @property
173
+ def input_schema(self) -> dict[str, Any]:
174
+ region_desc = "The region to call the action in."
175
+ default_region = self._get_default_region()
176
+ if default_region:
177
+ region_desc += f" Defaults to '{default_region}'."
178
+ return {
179
+ "type": "object",
180
+ "properties": {
181
+ "product": {
182
+ "type": "string",
183
+ "description": "The Aliyun product code (e.g. 'ros', 'ecs', 'rds', 'vpc').",
184
+ },
185
+ "action": {
186
+ "type": "string",
187
+ "description": "The API action to call.",
188
+ },
189
+ "version": {
190
+ "type": "string",
191
+ "description": (
192
+ "API version. Optional for common products: "
193
+ + ", ".join(f"{k}({v})" for k, v in VERSION_MAP.items())
194
+ + "."
195
+ ),
196
+ },
197
+ "params": {
198
+ "type": "object",
199
+ "description": "Parameters to pass to the action.",
200
+ },
201
+ "region_id": {
202
+ "type": "string",
203
+ "description": region_desc,
204
+ },
205
+ "style": {
206
+ "type": "string",
207
+ "enum": ["RPC", "ROA"],
208
+ "description": "API style. Defaults to 'RPC'. Use 'ROA' for RESTful APIs (e.g. CS, CR, FC).",
209
+ },
210
+ "method": {
211
+ "type": "string",
212
+ "enum": ["GET", "POST", "PUT", "DELETE"],
213
+ "description": "HTTP method. Defaults to 'POST'. Only needed for ROA APIs.",
214
+ },
215
+ "pathname": {
216
+ "type": "string",
217
+ "description": "Request path. Defaults to '/'. Only needed for ROA APIs (e.g. '/clusters').",
218
+ },
219
+ "body": {
220
+ "type": "object",
221
+ "description": "Request body. Only needed for ROA POST/PUT APIs.",
222
+ },
223
+ },
224
+ "required": ["product", "action"],
225
+ }
226
+
227
+ def _resolve_version(self, input: dict) -> str:
228
+ """Resolve the API version from input or built-in map."""
229
+ explicit = input.get("version")
230
+ if explicit:
231
+ return explicit
232
+ product = input.get("product", "")
233
+ if product in VERSION_MAP:
234
+ return VERSION_MAP[product]
235
+ raise ValueError(
236
+ f"No built-in version for product '{product}'. Please provide an explicit 'version' parameter."
237
+ )
238
+
239
+ @staticmethod
240
+ def _get_endpoint(product: str, region_id: str = "") -> str | None:
241
+ """Resolve endpoint from endpoints.yml. Returns None if not found."""
242
+ config = _ENDPOINTS.get(product)
243
+ if config is None:
244
+ return None
245
+ # Global central endpoint (all regions)
246
+ if "endpoint" in config:
247
+ return config["endpoint"]
248
+ if not region_id:
249
+ return None
250
+ # Central override for specific regions
251
+ central = config.get("central")
252
+ if central and region_id in central.get("regions", set()):
253
+ return central["endpoint"]
254
+ # Regionalized: mapping (priority) → pattern + regions
255
+ regional = config.get("regional")
256
+ if regional:
257
+ mapping = regional.get("mapping", {})
258
+ if region_id in mapping:
259
+ return mapping[region_id]
260
+ if region_id in regional.get("regions", set()):
261
+ return regional["pattern"].format(region_id=region_id)
262
+ return None
263
+
264
+ def _discover_endpoint(self, product: str, region_id: str, credential: AliyunCredential) -> str | None:
265
+ """Discover endpoint via Location service. Results are cached in memory."""
266
+ if not region_id:
267
+ return None
268
+ cache_key = (product, region_id)
269
+ if cache_key in _endpoint_cache:
270
+ return _endpoint_cache[cache_key]
271
+ try:
272
+ config = self._build_config(credential, "location.aliyuncs.com", region_id)
273
+ client = OpenApiClient(config)
274
+ api_params = open_api_models.Params(
275
+ action="DescribeEndpoints",
276
+ version="2015-06-12",
277
+ protocol="HTTPS",
278
+ pathname="/",
279
+ method="POST",
280
+ auth_type="AK",
281
+ style="RPC",
282
+ body_type="json",
283
+ req_body_type="json",
284
+ )
285
+ request = open_api_models.OpenApiRequest(
286
+ query={"Id": region_id, "ServiceCode": product},
287
+ )
288
+ result = client.call_api(api_params, request, RuntimeOptions())
289
+ body = result.get("body", result)
290
+ for ep in body.get("Endpoints", {}).get("Endpoint", []):
291
+ if ep.get("Type") == "openAPI":
292
+ endpoint = ep.get("Endpoint", "")
293
+ if endpoint:
294
+ _endpoint_cache[cache_key] = endpoint
295
+ return endpoint
296
+ _endpoint_cache[cache_key] = None
297
+ return None
298
+ except Exception:
299
+ _endpoint_cache[cache_key] = None
300
+ return None
301
+
302
+ @staticmethod
303
+ def _get_endpoint_fallback(product: str, region_id: str = "") -> str:
304
+ """Last resort fallback endpoint."""
305
+ if region_id:
306
+ return f"{product}.{region_id}.aliyuncs.com"
307
+ return f"{product}.aliyuncs.com"
308
+
309
+ @staticmethod
310
+ def _build_config(credential: AliyunCredential, endpoint: str, region_id: str) -> open_api_models.Config:
311
+ """Build OpenAPI config from credential, endpoint, and region."""
312
+ mode = credential.mode
313
+
314
+ if mode == "StsToken":
315
+ return open_api_models.Config(
316
+ access_key_id=credential.access_key_id,
317
+ access_key_secret=credential.access_key_secret,
318
+ security_token=credential.sts_token,
319
+ endpoint=endpoint,
320
+ region_id=region_id,
321
+ )
322
+
323
+ if mode == "RamRoleArn":
324
+ from alibabacloud_credentials import models as credential_models
325
+ from alibabacloud_credentials.client import Client as CredentialClient
326
+
327
+ cred_config = credential_models.Config(
328
+ type="ram_role_arn",
329
+ access_key_id=credential.access_key_id,
330
+ access_key_secret=credential.access_key_secret,
331
+ role_arn=credential.ram_role_arn,
332
+ role_session_name=credential.ram_session_name or "iac-code-session",
333
+ )
334
+ cred_client = CredentialClient(cred_config)
335
+ return open_api_models.Config(
336
+ credential=cred_client,
337
+ endpoint=endpoint,
338
+ region_id=region_id,
339
+ )
340
+
341
+ # Default: AK mode
342
+ return open_api_models.Config(
343
+ access_key_id=credential.access_key_id,
344
+ access_key_secret=credential.access_key_secret,
345
+ endpoint=endpoint,
346
+ region_id=region_id,
347
+ )
348
+
349
+ @staticmethod
350
+ def _serialize_params(params: dict) -> dict[str, str]:
351
+ """Convert param values for query string."""
352
+ result: dict[str, str] = {}
353
+ for k, v in params.items():
354
+ if isinstance(v, str):
355
+ result[k] = v
356
+ elif isinstance(v, bool):
357
+ result[k] = "true" if v else "false"
358
+ elif isinstance(v, (dict, list)):
359
+ result[k] = json.dumps(v, ensure_ascii=False)
360
+ else:
361
+ result[k] = str(v)
362
+ return result
363
+
364
+ def _get_action_display_detail(self, input: dict) -> str:
365
+ product = input.get("product", "")
366
+ region = self._resolve_region(input)
367
+ return f"{product} {region}".strip()
368
+
369
+ def _summarize_success_result(self, action: str, result: dict) -> str:
370
+ request_id = result.get("RequestId") if isinstance(result, dict) else None
371
+ if request_id:
372
+ return _("Call succeeded (RequestId: {request_id})").format(request_id=request_id)
373
+ return _("Call succeeded")
374
+
375
+ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult:
376
+ product = tool_input.get("product", "")
377
+ action = tool_input.get("action", "")
378
+ params = tool_input.get("params") or {}
379
+ region = self._resolve_region(tool_input)
380
+
381
+ # ROS: TemplateURL as local file path → read into TemplateBody
382
+ if product == "ros":
383
+ template_url = params.get("TemplateURL", "")
384
+ if template_url and not template_url.startswith(("http://", "https://", "oss://")):
385
+ params["TemplateBody"] = Path(template_url).read_text()
386
+ del params["TemplateURL"]
387
+
388
+ try:
389
+ version = self._resolve_version(tool_input)
390
+ except ValueError as e:
391
+ return ToolResult.error(str(e))
392
+
393
+ credentials = CloudCredentials()
394
+ credential = credentials.get_provider("aliyun")
395
+ if credential is None:
396
+ return ToolResult.error(
397
+ "Alibaba Cloud credentials not configured. "
398
+ "Run 'iac-code auth' and select 'Cloud Provider' to configure."
399
+ )
400
+
401
+ endpoint = (
402
+ self._get_endpoint(product, region)
403
+ or self._discover_endpoint(product, region, credential)
404
+ or self._get_endpoint_fallback(product, region)
405
+ )
406
+ config = self._build_config(credential, endpoint, region)
407
+ client = OpenApiClient(config)
408
+
409
+ style = tool_input.get("style", "RPC")
410
+ method = tool_input.get("method", "POST")
411
+ pathname = tool_input.get("pathname", "/")
412
+ body = tool_input.get("body")
413
+
414
+ api_params = open_api_models.Params(
415
+ action=action,
416
+ version=version,
417
+ protocol="HTTPS",
418
+ pathname=pathname,
419
+ method=method,
420
+ auth_type="AK",
421
+ style=style,
422
+ body_type="json",
423
+ req_body_type="json",
424
+ )
425
+
426
+ if style == "ROA":
427
+ # ROA: params go to query, body goes to body
428
+ serialized = self._serialize_params(params)
429
+ request = open_api_models.OpenApiRequest(
430
+ query=serialized,
431
+ body=body,
432
+ )
433
+ else:
434
+ # RPC: ensure RegionId is in params
435
+ if region:
436
+ params.setdefault("RegionId", region)
437
+ serialized = self._serialize_params(params)
438
+ request = open_api_models.OpenApiRequest(query=serialized)
439
+ runtime = RuntimeOptions()
440
+
441
+ # Prepare telemetry metadata
442
+ api_service = product.upper()
443
+ started = time.monotonic()
444
+ http_status: int | None = None
445
+ error_code: str | None = None
446
+ error_message: str | None = None
447
+ outcome = "success"
448
+
449
+ try:
450
+ result = client.call_api(api_params, request, runtime)
451
+ body = result.get("body", result)
452
+
453
+ # Try to extract HTTP status from response
454
+ if isinstance(result, dict) and "http_status" in result:
455
+ http_status = result.get("http_status")
456
+
457
+ self._last_action = action
458
+ self._last_result = body
459
+
460
+ # Emit ALIYUN_API_CALLED event
461
+ duration_ms = int((time.monotonic() - started) * 1000)
462
+ log_event(
463
+ Events.ALIYUN_API_CALLED,
464
+ {
465
+ "api_service": api_service,
466
+ "api_name": action,
467
+ "api_version": version,
468
+ "region": region,
469
+ "outcome": outcome,
470
+ "duration_ms": duration_ms,
471
+ "http_status": http_status,
472
+ },
473
+ )
474
+ add_metric(Metrics.ALIYUN_API_CALLED_COUNT, 1, {"api_service": api_service, "outcome": outcome})
475
+ add_metric(Metrics.ALIYUN_API_CALLED_DURATION, duration_ms)
476
+
477
+ # Special case: ROS ValidateTemplate
478
+ if api_service == "ROS" and action == "ValidateTemplate":
479
+ _emit_validate_template_event(body, duration_ms)
480
+
481
+ return ToolResult.success(json.dumps(body, ensure_ascii=False, indent=2))
482
+ except Exception as e:
483
+ self._last_action = ""
484
+ self._last_result = None
485
+ outcome = "failure"
486
+ duration_ms = int((time.monotonic() - started) * 1000)
487
+ error_str = str(e)
488
+
489
+ # Try to extract error code and message
490
+ error_code, error_message = _extract_error_info(error_str)
491
+
492
+ # Emit ALIYUN_API_CALLED event (with error)
493
+ log_event(
494
+ Events.ALIYUN_API_CALLED,
495
+ {
496
+ "api_service": api_service,
497
+ "api_name": action,
498
+ "api_version": version,
499
+ "region": region,
500
+ "outcome": outcome,
501
+ "duration_ms": duration_ms,
502
+ "http_status": http_status,
503
+ "error_code": error_code,
504
+ "error_message": sanitize_error_message(error_message),
505
+ },
506
+ )
507
+ add_metric(Metrics.ALIYUN_API_CALLED_COUNT, 1, {"api_service": api_service, "outcome": outcome})
508
+ add_metric(Metrics.ALIYUN_API_CALLED_DURATION, duration_ms)
509
+
510
+ return ToolResult.error(self._clean_error_message(error_str))
@@ -0,0 +1,145 @@
1
+ """AliyunDocSearch - searches Alibaba Cloud documentation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from iac_code.i18n import _
10
+ from iac_code.services.telemetry import log_event
11
+ from iac_code.services.telemetry.names import Events
12
+ from iac_code.tools.base import Tool, ToolContext, ToolResult
13
+
14
+ _SEARCH_URL = "https://help.aliyun.com/help/json/search.json"
15
+ _TIMEOUT = 10
16
+ _PAGE_SIZE = 10
17
+
18
+
19
+ class AliyunDocSearch(Tool):
20
+ """Tool for searching Alibaba Cloud documentation."""
21
+
22
+ @property
23
+ def name(self) -> str:
24
+ return "aliyun_doc_search"
25
+
26
+ @property
27
+ def description(self) -> str:
28
+ return (
29
+ "Search Alibaba Cloud documentation. Returns document titles, summaries and links. "
30
+ "Use category_id=28850 to limit results to ROS product docs."
31
+ )
32
+
33
+ @property
34
+ def input_schema(self) -> dict[str, Any]:
35
+ return {
36
+ "type": "object",
37
+ "properties": {
38
+ "keywords": {
39
+ "type": "string",
40
+ "description": "Search keywords",
41
+ },
42
+ "category_id": {
43
+ "type": "integer",
44
+ "description": "Product category ID, e.g. 28850 for ROS. Omit to search all products.",
45
+ },
46
+ },
47
+ "required": ["keywords"],
48
+ }
49
+
50
+ def is_read_only(self, input: dict | None = None) -> bool:
51
+ return True
52
+
53
+ def is_destructive(self, input: dict | None = None) -> bool:
54
+ return False
55
+
56
+ def user_facing_name(self, input: dict | None = None) -> str:
57
+ return _("DocSearch")
58
+
59
+ def render_tool_use_message(self, input: dict, *, verbose: bool = False) -> str | None:
60
+ return input.get("keywords")
61
+
62
+ def render_tool_result_message(self, output: str, *, is_error: bool = False, verbose: bool = False) -> str | None:
63
+ if is_error:
64
+ return output
65
+ return self._last_summary if hasattr(self, "_last_summary") else None
66
+
67
+ def get_activity_description(self, input: dict | None = None) -> str | None:
68
+ if input:
69
+ keywords = input.get("keywords", "")
70
+ return _("Searching docs for {keywords}...").format(keywords=keywords)
71
+ return _("Searching docs...")
72
+
73
+ async def execute(self, *, tool_input: dict[str, Any], context: ToolContext) -> ToolResult:
74
+ keywords = tool_input.get("keywords", "").strip()
75
+ if not keywords:
76
+ return ToolResult.error(_("keywords cannot be empty."))
77
+
78
+ category_id = tool_input.get("category_id")
79
+
80
+ params: dict[str, Any] = {
81
+ "keywords": keywords,
82
+ "topics": "DOCUMENT,PRODUCT",
83
+ "language": "zh",
84
+ "website": "cn",
85
+ "pageSize": _PAGE_SIZE,
86
+ "pageNum": 1,
87
+ }
88
+ if category_id is not None:
89
+ params["categoryId"] = category_id
90
+
91
+ try:
92
+ async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
93
+ response = await client.get(_SEARCH_URL, params=params)
94
+ response.raise_for_status()
95
+ except httpx.HTTPStatusError as e:
96
+ return ToolResult.error(_("HTTP error {status} when searching docs.").format(status=e.response.status_code))
97
+ except httpx.HTTPError as e:
98
+ return ToolResult.error(_("Failed to search docs: {error}").format(error=str(e)))
99
+
100
+ try:
101
+ data = response.json()
102
+ except Exception:
103
+ return ToolResult.error(_("Failed to parse search response as JSON."))
104
+
105
+ if not data.get("success"):
106
+ return ToolResult.error(_("Search API returned failure."))
107
+
108
+ documents = data.get("data", {}).get("documents", {})
109
+ items = documents.get("data", [])
110
+ total = documents.get("totalCount", 0)
111
+
112
+ # Emit doc search event
113
+ category_str = str(category_id) if category_id is not None else None
114
+ log_event(
115
+ Events.DOC_SEARCHED,
116
+ {
117
+ "doc_source": "aliyun_ros_api",
118
+ "search_category": category_str,
119
+ "result_count": len(items),
120
+ "outcome": "success" if items else "empty",
121
+ },
122
+ )
123
+
124
+ if not items:
125
+ self._last_summary = _("No documents found")
126
+ return ToolResult.success(_("No documents found for keywords: {keywords}").format(keywords=keywords))
127
+
128
+ lines: list[str] = []
129
+ for i, item in enumerate(items, 1):
130
+ title = item.get("title", "")
131
+ content = item.get("content", "")
132
+ url = item.get("url", "")
133
+ lines.append(f"{i}. {title}")
134
+ if content:
135
+ lines.append(f" {content}")
136
+ if url:
137
+ lines.append(f" Link: {url}")
138
+ lines.append("")
139
+
140
+ count = len(items)
141
+ self._last_summary = _("Found {count} documents (total {total})").format(count=count, total=total)
142
+ lines.append(self._last_summary)
143
+ lines.append(_("Use web_fetch tool to read full document content if needed."))
144
+
145
+ return ToolResult.success("\n".join(lines))