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.
- iac_code/__init__.py +2 -0
- iac_code/acp/__init__.py +97 -0
- iac_code/acp/convert.py +423 -0
- iac_code/acp/http_sse.py +448 -0
- iac_code/acp/mcp.py +54 -0
- iac_code/acp/metrics.py +71 -0
- iac_code/acp/server.py +662 -0
- iac_code/acp/session.py +446 -0
- iac_code/acp/slash_registry.py +125 -0
- iac_code/acp/state.py +99 -0
- iac_code/acp/tools.py +112 -0
- iac_code/acp/types.py +13 -0
- iac_code/acp/version.py +26 -0
- iac_code/agent/__init__.py +19 -0
- iac_code/agent/agent_loop.py +640 -0
- iac_code/agent/agent_tool.py +269 -0
- iac_code/agent/agent_types.py +87 -0
- iac_code/agent/message.py +153 -0
- iac_code/agent/system_prompt.py +313 -0
- iac_code/cli/__init__.py +3 -0
- iac_code/cli/headless.py +114 -0
- iac_code/cli/main.py +246 -0
- iac_code/cli/output_formats.py +125 -0
- iac_code/commands/__init__.py +93 -0
- iac_code/commands/auth.py +1055 -0
- iac_code/commands/clear.py +34 -0
- iac_code/commands/compact.py +43 -0
- iac_code/commands/debug.py +45 -0
- iac_code/commands/effort.py +116 -0
- iac_code/commands/exit.py +10 -0
- iac_code/commands/help.py +49 -0
- iac_code/commands/model.py +130 -0
- iac_code/commands/registry.py +245 -0
- iac_code/commands/resume.py +49 -0
- iac_code/commands/tasks.py +41 -0
- iac_code/config.py +304 -0
- iac_code/i18n/__init__.py +141 -0
- iac_code/i18n/locales/zh/LC_MESSAGES/messages.po +1355 -0
- iac_code/memory/__init__.py +1 -0
- iac_code/memory/memory_manager.py +92 -0
- iac_code/memory/memory_tools.py +88 -0
- iac_code/providers/__init__.py +1 -0
- iac_code/providers/anthropic_provider.py +284 -0
- iac_code/providers/base.py +128 -0
- iac_code/providers/dashscope_provider.py +47 -0
- iac_code/providers/deepseek_provider.py +36 -0
- iac_code/providers/manager.py +399 -0
- iac_code/providers/openai_provider.py +344 -0
- iac_code/providers/retry.py +58 -0
- iac_code/providers/stream_watchdog.py +47 -0
- iac_code/providers/thinking.py +164 -0
- iac_code/services/__init__.py +1 -0
- iac_code/services/agent_factory.py +127 -0
- iac_code/services/cloud_credentials.py +22 -0
- iac_code/services/context_manager.py +221 -0
- iac_code/services/providers/__init__.py +1 -0
- iac_code/services/providers/aliyun.py +232 -0
- iac_code/services/session_index.py +281 -0
- iac_code/services/session_storage.py +245 -0
- iac_code/services/telemetry/__init__.py +66 -0
- iac_code/services/telemetry/attributes.py +84 -0
- iac_code/services/telemetry/client.py +330 -0
- iac_code/services/telemetry/config.py +76 -0
- iac_code/services/telemetry/constants.py +75 -0
- iac_code/services/telemetry/content_serializer.py +124 -0
- iac_code/services/telemetry/events.py +42 -0
- iac_code/services/telemetry/fallback.py +59 -0
- iac_code/services/telemetry/identity.py +73 -0
- iac_code/services/telemetry/metrics.py +62 -0
- iac_code/services/telemetry/names.py +199 -0
- iac_code/services/telemetry/sanitize.py +88 -0
- iac_code/services/telemetry/sink.py +67 -0
- iac_code/services/telemetry/tracing.py +38 -0
- iac_code/services/telemetry/types.py +13 -0
- iac_code/services/token_budget.py +54 -0
- iac_code/services/token_counter.py +76 -0
- iac_code/skills/__init__.py +1 -0
- iac_code/skills/bundled/__init__.py +94 -0
- iac_code/skills/bundled/iac_aliyun/SKILL.md +192 -0
- iac_code/skills/bundled/iac_aliyun/__init__.py +16 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/ecs.md +167 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/oss.md +69 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/rds.md +95 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/redis.md +100 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/slb.md +60 -0
- iac_code/skills/bundled/iac_aliyun/references/cloud-products/vpc.md +54 -0
- iac_code/skills/bundled/iac_aliyun/references/ros-template.md +155 -0
- iac_code/skills/bundled/iac_aliyun/references/template-parameters.md +206 -0
- iac_code/skills/bundled/iac_aliyun/references/terraform-template.md +101 -0
- iac_code/skills/bundled/iac_aliyun/scripts/tf2ros.py +77 -0
- iac_code/skills/bundled/simplify.py +28 -0
- iac_code/skills/discovery.py +136 -0
- iac_code/skills/frontmatter.py +119 -0
- iac_code/skills/listing.py +92 -0
- iac_code/skills/loader.py +42 -0
- iac_code/skills/processor.py +81 -0
- iac_code/skills/renderer.py +157 -0
- iac_code/skills/skill_definition.py +82 -0
- iac_code/skills/skill_tool.py +261 -0
- iac_code/state/__init__.py +5 -0
- iac_code/state/app_state.py +122 -0
- iac_code/tasks/__init__.py +1 -0
- iac_code/tasks/notification_queue.py +28 -0
- iac_code/tasks/task_state.py +66 -0
- iac_code/tasks/task_tools.py +114 -0
- iac_code/tools/__init__.py +8 -0
- iac_code/tools/base.py +226 -0
- iac_code/tools/bash.py +133 -0
- iac_code/tools/cloud/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/__init__.py +0 -0
- iac_code/tools/cloud/aliyun/aliyun_api.py +510 -0
- iac_code/tools/cloud/aliyun/aliyun_doc_search.py +145 -0
- iac_code/tools/cloud/aliyun/endpoints.yml +343 -0
- iac_code/tools/cloud/aliyun/ros_client.py +56 -0
- iac_code/tools/cloud/aliyun/ros_stack.py +633 -0
- iac_code/tools/cloud/aliyun/ros_stack_instances.py +247 -0
- iac_code/tools/cloud/base_api.py +162 -0
- iac_code/tools/cloud/base_stack.py +242 -0
- iac_code/tools/cloud/registry.py +20 -0
- iac_code/tools/cloud/types.py +105 -0
- iac_code/tools/edit_file.py +121 -0
- iac_code/tools/glob.py +103 -0
- iac_code/tools/grep.py +254 -0
- iac_code/tools/list_files.py +104 -0
- iac_code/tools/read_file.py +127 -0
- iac_code/tools/result_storage.py +39 -0
- iac_code/tools/tool_executor.py +165 -0
- iac_code/tools/web_fetch.py +177 -0
- iac_code/tools/write_file.py +88 -0
- iac_code/types/__init__.py +40 -0
- iac_code/types/permissions.py +26 -0
- iac_code/types/skill_source.py +11 -0
- iac_code/types/stream_events.py +227 -0
- iac_code/ui/__init__.py +5 -0
- iac_code/ui/banner.py +110 -0
- iac_code/ui/components/__init__.py +0 -0
- iac_code/ui/components/dialog.py +142 -0
- iac_code/ui/components/divider.py +20 -0
- iac_code/ui/components/fuzzy_picker.py +308 -0
- iac_code/ui/components/progress_bar.py +54 -0
- iac_code/ui/components/search_box.py +165 -0
- iac_code/ui/components/select.py +319 -0
- iac_code/ui/components/status_icon.py +42 -0
- iac_code/ui/components/tabs.py +128 -0
- iac_code/ui/core/__init__.py +0 -0
- iac_code/ui/core/in_place_render.py +129 -0
- iac_code/ui/core/input_history.py +118 -0
- iac_code/ui/core/key_event.py +41 -0
- iac_code/ui/core/prompt_input.py +507 -0
- iac_code/ui/core/raw_input.py +302 -0
- iac_code/ui/core/screen.py +80 -0
- iac_code/ui/dialogs/__init__.py +0 -0
- iac_code/ui/dialogs/global_search.py +178 -0
- iac_code/ui/dialogs/history_search.py +100 -0
- iac_code/ui/dialogs/model_picker.py +280 -0
- iac_code/ui/dialogs/quick_open.py +108 -0
- iac_code/ui/dialogs/resume_picker.py +749 -0
- iac_code/ui/keybindings/__init__.py +0 -0
- iac_code/ui/keybindings/manager.py +124 -0
- iac_code/ui/renderer.py +1535 -0
- iac_code/ui/repl.py +772 -0
- iac_code/ui/spinner.py +112 -0
- iac_code/ui/suggestions/__init__.py +0 -0
- iac_code/ui/suggestions/aggregator.py +171 -0
- iac_code/ui/suggestions/command_provider.py +43 -0
- iac_code/ui/suggestions/directory_provider.py +95 -0
- iac_code/ui/suggestions/file_provider.py +121 -0
- iac_code/ui/suggestions/shell_history_provider.py +108 -0
- iac_code/ui/suggestions/token_extractor.py +77 -0
- iac_code/ui/suggestions/types.py +45 -0
- iac_code/ui/transcript_view.py +199 -0
- iac_code/utils/__init__.py +0 -0
- iac_code/utils/background_housekeeping.py +53 -0
- iac_code/utils/cleanup.py +68 -0
- iac_code/utils/json_utils.py +60 -0
- iac_code/utils/log.py +150 -0
- iac_code/utils/project_paths.py +74 -0
- iac_code/utils/tool_input_parser.py +62 -0
- iac_code-0.1.0.dist-info/LICENSE +201 -0
- iac_code-0.1.0.dist-info/METADATA +64 -0
- iac_code-0.1.0.dist-info/RECORD +184 -0
- iac_code-0.1.0.dist-info/WHEEL +5 -0
- iac_code-0.1.0.dist-info/entry_points.txt +2 -0
- 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))
|