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,59 @@
1
+ """FallbackStore — on-disk persistence of failed event batches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from collections.abc import Iterable, Iterator
8
+ from pathlib import Path
9
+
10
+ _FAILED_PREFIX = "failed_events."
11
+ _FAILED_SUFFIX = ".jsonl"
12
+
13
+
14
+ class FallbackStore:
15
+ """JSONL-backed store for event batches that failed default-backend export."""
16
+
17
+ def __init__(self, base_dir: Path) -> None:
18
+ self._base_dir = base_dir
19
+
20
+ def _ensure_dir(self) -> Path:
21
+ self._base_dir.mkdir(parents=True, exist_ok=True)
22
+ return self._base_dir
23
+
24
+ def write(self, session_id: str, events: Iterable[dict]) -> Path:
25
+ """Write one JSONL file per batch. One line per event."""
26
+ batch_uuid = uuid.uuid4().hex[:12]
27
+ path = self._ensure_dir() / f"{_FAILED_PREFIX}{session_id}.{batch_uuid}{_FAILED_SUFFIX}"
28
+ with path.open("w", encoding="utf-8") as fh:
29
+ for event in events:
30
+ fh.write(json.dumps(event, ensure_ascii=False) + "\n")
31
+ return path
32
+
33
+ def list_pending(self) -> Iterator[Path]:
34
+ """Yield every failed-batch file currently on disk."""
35
+ if not self._base_dir.exists():
36
+ return
37
+ for p in self._base_dir.iterdir():
38
+ if p.is_file() and p.name.startswith(_FAILED_PREFIX) and p.suffix == _FAILED_SUFFIX:
39
+ yield p
40
+
41
+ def remove(self, path: Path) -> None:
42
+ """Delete a batch file after successful re-export. Silent on missing."""
43
+ try:
44
+ path.unlink()
45
+ except FileNotFoundError:
46
+ pass
47
+
48
+ def read(self, path: Path) -> list[dict]:
49
+ """Parse a JSONL batch file. Unparseable lines skipped."""
50
+ out: list[dict] = []
51
+ for line in path.read_text(encoding="utf-8").splitlines():
52
+ line = line.strip()
53
+ if not line:
54
+ continue
55
+ try:
56
+ out.append(json.loads(line))
57
+ except json.JSONDecodeError:
58
+ continue
59
+ return out
@@ -0,0 +1,73 @@
1
+ """Identity generation for telemetry.
2
+
3
+ user.id = iac_user_<uuid4>, persisted to a settings.yml path
4
+ session.id = iac_sess_<uuid4>, per Identity instance (per process)
5
+ tenant.id = iac_tenant_<user-defined>, from IAC_CODE_TENANT_ID
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import uuid
12
+ from pathlib import Path
13
+
14
+ from iac_code.config import _load_yaml, _save_yaml
15
+
16
+ USER_ID_PREFIX = "iac_user_"
17
+ SESSION_ID_PREFIX = "iac_sess_"
18
+ TENANT_ID_PREFIX = "iac_tenant_"
19
+
20
+ _USER_ID_KEY = "userID"
21
+ _TENANT_ENV_VAR = "IAC_CODE_TENANT_ID"
22
+
23
+
24
+ class Identity:
25
+ """Owns user.id / session.id / tenant.id.
26
+
27
+ `settings_path` is injected so tests can pass a tmp path instead of the
28
+ real ~/.iac-code/settings.yml.
29
+ """
30
+
31
+ def __init__(self, settings_path: Path, session_id: str | None = None) -> None:
32
+ self._settings_path = settings_path
33
+ self._user_id: str | None = None
34
+ self._session_id: str | None = f"{SESSION_ID_PREFIX}{session_id}" if session_id else None
35
+ self._was_first_run = False
36
+
37
+ def get_user_id(self) -> str:
38
+ """Return the persistent user.id; generate + persist on first miss."""
39
+ if self._user_id is not None:
40
+ return self._user_id
41
+ settings = _load_yaml(self._settings_path)
42
+ existing = settings.get(_USER_ID_KEY)
43
+ if isinstance(existing, str) and existing.startswith(USER_ID_PREFIX):
44
+ self._user_id = existing
45
+ return existing
46
+ new_id = f"{USER_ID_PREFIX}{uuid.uuid4()}"
47
+ settings[_USER_ID_KEY] = new_id
48
+ _save_yaml(self._settings_path, settings)
49
+ self._user_id = new_id
50
+ self._was_first_run = True
51
+ return new_id
52
+
53
+ def get_session_id(self) -> str:
54
+ """Return per-instance session.id; generate on first call."""
55
+ if self._session_id is None:
56
+ self._session_id = f"{SESSION_ID_PREFIX}{uuid.uuid4()}"
57
+ return self._session_id
58
+
59
+ def get_tenant_id(self) -> str | None:
60
+ """Return tenant.id if IAC_CODE_TENANT_ID is set, else None.
61
+
62
+ Read fresh each call so monkeypatching in tests works reliably.
63
+ """
64
+ raw = os.environ.get(_TENANT_ENV_VAR, "").strip()
65
+ if not raw:
66
+ return None
67
+ if raw.startswith(TENANT_ID_PREFIX):
68
+ return raw
69
+ return f"{TENANT_ID_PREFIX}{raw}"
70
+
71
+ def was_first_run(self) -> bool:
72
+ """True iff get_user_id() minted a new id on this instance."""
73
+ return self._was_first_run
@@ -0,0 +1,62 @@
1
+ """MetricsRegistry — wraps OTel Counter/Histogram instruments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from loguru import logger
8
+ from opentelemetry.metrics import Meter
9
+
10
+ from iac_code.services.telemetry.names import Metrics as M # noqa: N817
11
+
12
+ METRIC_NAMES: tuple[str, ...] = (
13
+ M.SESSION_COUNT,
14
+ M.ACTIVE_TIME_TOTAL,
15
+ M.TOKEN_USAGE,
16
+ M.API_REQUEST_COUNT,
17
+ M.API_REQUEST_DURATION,
18
+ M.TOOL_USE_COUNT,
19
+ M.TEMPLATE_GENERATED_COUNT,
20
+ M.TEMPLATE_VALIDATED_COUNT,
21
+ M.DEPLOYMENT_COUNT,
22
+ M.DEPLOYMENT_DURATION,
23
+ M.RESOURCE_TYPE_OBSERVED_COUNT,
24
+ M.ALIYUN_API_CALLED_COUNT,
25
+ M.ALIYUN_API_CALLED_DURATION,
26
+ M.TERRAFORM_PROVIDER_OBSERVED_COUNT,
27
+ )
28
+
29
+ _HISTOGRAM_NAMES: frozenset[str] = frozenset(
30
+ {
31
+ M.API_REQUEST_DURATION,
32
+ M.DEPLOYMENT_DURATION,
33
+ M.ALIYUN_API_CALLED_DURATION,
34
+ }
35
+ )
36
+
37
+
38
+ class MetricsRegistry:
39
+ """Holds instrument objects and dispatches add/record calls."""
40
+
41
+ def __init__(self, instruments: dict[str, Any] | None = None) -> None:
42
+ self._instruments: dict[str, Any] = dict(instruments) if instruments else {}
43
+
44
+ def register_all(self, meter: Meter) -> None:
45
+ """Create a Counter or Histogram per known metric name."""
46
+ self._instruments.clear()
47
+ for name in METRIC_NAMES:
48
+ if name in _HISTOGRAM_NAMES:
49
+ self._instruments[name] = meter.create_histogram(name=name, description=name)
50
+ else:
51
+ self._instruments[name] = meter.create_counter(name=name, description=name)
52
+
53
+ def add(self, name: str, value: int | float, attributes: dict[str, Any]) -> None:
54
+ """Route to the correct instrument method; silently drop unknown names."""
55
+ logger.info("[metric] {} value={} {}", name, value, attributes)
56
+ inst = self._instruments.get(name)
57
+ if inst is None:
58
+ return
59
+ if name in _HISTOGRAM_NAMES:
60
+ inst.record(value, attributes)
61
+ else:
62
+ inst.add(value, attributes)
@@ -0,0 +1,199 @@
1
+ """Centralized constants for all telemetry signal names."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # =====================================================================
6
+ # ARMS LLM semantic conventions (gen_ai.*)
7
+ # https://help.aliyun.com/zh/arms/application-monitoring/developer-reference/llm-trace-field-definition-description
8
+ # =====================================================================
9
+
10
+
11
+ class GenAiSpanKind:
12
+ """gen_ai.span.kind enumeration values."""
13
+
14
+ ENTRY = "ENTRY"
15
+ LLM = "LLM"
16
+ TOOL = "TOOL"
17
+ STEP = "STEP"
18
+ AGENT = "AGENT"
19
+ CHAIN = "CHAIN"
20
+ TASK = "TASK"
21
+ RETRIEVER = "RETRIEVER"
22
+ EMBEDDING = "EMBEDDING"
23
+ RERANKER = "RERANKER"
24
+
25
+
26
+ class GenAiOperationName:
27
+ """gen_ai.operation.name enumeration values."""
28
+
29
+ ENTER = "enter"
30
+ CHAT = "chat"
31
+ TEXT_COMPLETION = "text_completion"
32
+ GENERATE_CONTENT = "generate_content"
33
+ EXECUTE_TOOL = "execute_tool"
34
+ INVOKE_AGENT = "invoke_agent"
35
+ CREATE_AGENT = "create_agent"
36
+ REACT = "react"
37
+ RETRIEVAL = "retrieval"
38
+ EMBEDDINGS = "embeddings"
39
+
40
+
41
+ class GenAiAttr:
42
+ """gen_ai.* span attribute key constants."""
43
+
44
+ # --- Common (all spans) ---
45
+ SPAN_KIND = "gen_ai.span.kind"
46
+ OPERATION_NAME = "gen_ai.operation.name"
47
+ SESSION_ID = "gen_ai.session.id"
48
+ USER_ID = "gen_ai.user.id"
49
+ FRAMEWORK = "gen_ai.framework"
50
+
51
+ # --- Provider & Model ---
52
+ PROVIDER_NAME = "gen_ai.provider.name"
53
+ REQUEST_MODEL = "gen_ai.request.model"
54
+ RESPONSE_MODEL = "gen_ai.response.model"
55
+ RESPONSE_ID = "gen_ai.response.id"
56
+ CONVERSATION_ID = "gen_ai.conversation.id"
57
+
58
+ # --- Request parameters ---
59
+ REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens"
60
+ REQUEST_TEMPERATURE = "gen_ai.request.temperature"
61
+ REQUEST_TOP_P = "gen_ai.request.top_p"
62
+ REQUEST_TOP_K = "gen_ai.request.top_k"
63
+ REQUEST_FREQUENCY_PENALTY = "gen_ai.request.frequency_penalty"
64
+ REQUEST_PRESENCE_PENALTY = "gen_ai.request.presence_penalty"
65
+ REQUEST_STOP_SEQUENCES = "gen_ai.request.stop_sequences"
66
+ REQUEST_SEED = "gen_ai.request.seed"
67
+ REQUEST_CHOICE_COUNT = "gen_ai.request.choice.count"
68
+
69
+ # --- Response ---
70
+ RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons"
71
+ RESPONSE_TIME_TO_FIRST_TOKEN = "gen_ai.response.time_to_first_token"
72
+ USER_TIME_TO_FIRST_TOKEN = "gen_ai.user.time_to_first_token"
73
+ RESPONSE_REASONING_TIME = "gen_ai.response.reasoning_time"
74
+ OUTPUT_TYPE = "gen_ai.output.type"
75
+
76
+ # --- Usage ---
77
+ USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
78
+ USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
79
+ USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens"
80
+ USAGE_CACHE_CREATION_INPUT_TOKENS = "gen_ai.usage.cache_creation.input_tokens"
81
+ USAGE_CACHE_READ_INPUT_TOKENS = "gen_ai.usage.cache_read.input_tokens"
82
+
83
+ # --- Content (debug mode only) ---
84
+ INPUT_MESSAGES = "gen_ai.input.messages"
85
+ OUTPUT_MESSAGES = "gen_ai.output.messages"
86
+ SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
87
+ TOOL_DEFINITIONS = "gen_ai.tool.definitions"
88
+
89
+ # --- Tool ---
90
+ TOOL_NAME = "gen_ai.tool.name"
91
+ TOOL_TYPE = "gen_ai.tool.type"
92
+ TOOL_CALL_ID = "gen_ai.tool.call.id"
93
+ TOOL_DESCRIPTION = "gen_ai.tool.description"
94
+ TOOL_CALL_ARGUMENTS = "gen_ai.tool.call.arguments"
95
+ TOOL_CALL_RESULT = "gen_ai.tool.call.result"
96
+
97
+ # --- Agent ---
98
+ AGENT_NAME = "gen_ai.agent.name"
99
+ AGENT_ID = "gen_ai.agent.id"
100
+ AGENT_DESCRIPTION = "gen_ai.agent.description"
101
+ DATA_SOURCE_ID = "gen_ai.data_source.id"
102
+
103
+ # --- ReAct Step ---
104
+ REACT_FINISH_REASON = "gen_ai.react.finish_reason"
105
+ REACT_ROUND = "gen_ai.react.round"
106
+
107
+
108
+ class ArmsResourceAttr:
109
+ """ARMS-specific resource attribute keys."""
110
+
111
+ SERVICE_FEATURE = "acs.arms.service.feature"
112
+ CMS_WORKSPACE = "acs.cms.workspace"
113
+ SERVICE_ID = "acs.arms.service.id"
114
+
115
+
116
+ ARMS_FEATURE_GENAI_APP = "genai_app"
117
+ FRAMEWORK_IAC_CODE = "iac-code"
118
+
119
+
120
+ # =====================================================================
121
+ # iac-code application signals (iac.*)
122
+ # =====================================================================
123
+
124
+
125
+ class Events:
126
+ """All iac.* event names (OTel Logs signal)."""
127
+
128
+ # --- Lifecycle (5) ---
129
+ INIT = "iac.init"
130
+ SESSION_STARTED = "iac.session.started"
131
+ SESSION_EXITED = "iac.session.exited"
132
+ SESSION_CANCELLED = "iac.session.cancelled"
133
+ AUTH_CONFIGURED = "iac.auth.configured"
134
+
135
+ # --- API / LLM (5) ---
136
+ API_REQUEST_STARTED = "iac.api.request.started"
137
+ API_REQUEST_SUCCEEDED = "iac.api.request.succeeded"
138
+ API_REQUEST_FAILED = "iac.api.request.failed"
139
+ API_REQUEST_RETRIED = "iac.api.request.retried"
140
+ MODEL_FALLBACK_TRIGGERED = "iac.model.fallback.triggered"
141
+
142
+ # --- Tool (4) ---
143
+ TOOL_USE_SUCCEEDED = "iac.tool.use.succeeded"
144
+ TOOL_USE_FAILED = "iac.tool.use.failed"
145
+ TOOL_USE_GRANTED_IN_PROMPT = "iac.tool.use.granted_in_prompt"
146
+ TOOL_USE_REJECTED_IN_PROMPT = "iac.tool.use.rejected_in_prompt"
147
+
148
+ # --- IaC core (9) ---
149
+ TEMPLATE_GENERATED = "iac.template.generated"
150
+ TEMPLATE_VALIDATED = "iac.template.validated"
151
+ DEPLOYMENT_STARTED = "iac.deployment.started"
152
+ DEPLOYMENT_SUCCEEDED = "iac.deployment.succeeded"
153
+ DEPLOYMENT_FAILED = "iac.deployment.failed"
154
+ DEPLOYMENT_CANCELLED = "iac.deployment.cancelled"
155
+ DOC_SEARCHED = "iac.doc.searched"
156
+ SKILL_INVOKED = "iac.skill.invoked"
157
+ SKILL_COMPLETED = "iac.skill.completed"
158
+
159
+ # --- Aliyun API (1) ---
160
+ ALIYUN_API_CALLED = "iac.aliyun.api.called"
161
+
162
+ # --- Memory (2) ---
163
+ MEMORY_COMPACT_SUCCEEDED = "iac.memory.compact.succeeded"
164
+ MEMORY_COMPACT_FAILED = "iac.memory.compact.failed"
165
+
166
+ # --- Crash / error (3) ---
167
+ EXCEPTION_UNCAUGHT = "iac.exception.uncaught"
168
+ EXCEPTION_UNHANDLED = "iac.exception.unhandled"
169
+ QUERY_FAILED = "iac.query.failed"
170
+
171
+
172
+ class Metrics:
173
+ """All iac.* metric names."""
174
+
175
+ SESSION_COUNT = "iac.session.count"
176
+ ACTIVE_TIME_TOTAL = "iac.active_time.total"
177
+ TOKEN_USAGE = "iac.token.usage"
178
+ API_REQUEST_COUNT = "iac.api.request.count"
179
+ API_REQUEST_DURATION = "iac.api.request.duration"
180
+ TOOL_USE_COUNT = "iac.tool.use.count"
181
+ TEMPLATE_GENERATED_COUNT = "iac.template.generated.count"
182
+ TEMPLATE_VALIDATED_COUNT = "iac.template.validated.count"
183
+ DEPLOYMENT_COUNT = "iac.deployment.count"
184
+ DEPLOYMENT_DURATION = "iac.deployment.duration"
185
+ RESOURCE_TYPE_OBSERVED_COUNT = "iac.resource_type.observed.count"
186
+ ALIYUN_API_CALLED_COUNT = "iac.aliyun.api.called.count"
187
+ ALIYUN_API_CALLED_DURATION = "iac.aliyun.api.called.duration"
188
+ TERRAFORM_PROVIDER_OBSERVED_COUNT = "iac.terraform.provider.observed.count"
189
+
190
+
191
+ class Spans:
192
+ """Span name constants (ARMS LLM convention: '{operation} {identifier}')."""
193
+
194
+ ENTRY = "enter_ai_application_system"
195
+ LLM_CHAT = "chat"
196
+ TOOL_EXECUTE = "execute_tool"
197
+ REACT_STEP = "react step"
198
+ SKILL_EXECUTE = "iac.skill.execute"
199
+ TEMPLATE_VALIDATE = "iac.template.validate"
@@ -0,0 +1,88 @@
1
+ """Sanitization helpers for telemetry text fields."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from typing import Literal
7
+
8
+ from iac_code.services.telemetry.config import is_essential_traffic_only
9
+ from iac_code.services.telemetry.constants import (
10
+ BUNDLED_SKILLS,
11
+ CUSTOM_ROS_RESOURCE_PLACEHOLDER,
12
+ CUSTOM_SKILL_PLACEHOLDER,
13
+ CUSTOM_TF_PROVIDER_PLACEHOLDER,
14
+ CUSTOM_TF_RESOURCE_PLACEHOLDER,
15
+ KNOWN_MODELS,
16
+ MCP_TOOL_PLACEHOLDER,
17
+ OTHER_MODEL_PLACEHOLDER,
18
+ ROS_ALLOWED_PREFIXES,
19
+ TERRAFORM_OFFICIAL_PROVIDERS,
20
+ )
21
+
22
+ _CONTROL_CHARS_RE = re.compile(r"[\n\r\t\x00-\x1f]+")
23
+ _MAX_ERROR_MSG_BYTES = 512
24
+ _TRUNCATION_MARKER = "... (truncated)"
25
+ _DEV_VERSION_SUFFIX_RE = re.compile(r"-\d{8}$")
26
+
27
+
28
+ def sanitize_error_message(raw: str | None) -> str | None:
29
+ """Clean and truncate an error message. None under essential-traffic mode."""
30
+ if raw is None:
31
+ return None
32
+ if is_essential_traffic_only():
33
+ return None
34
+ cleaned = _CONTROL_CHARS_RE.sub(" ", raw).strip()
35
+ encoded = cleaned.encode("utf-8")
36
+ if len(encoded) > _MAX_ERROR_MSG_BYTES:
37
+ keep = _MAX_ERROR_MSG_BYTES - len(_TRUNCATION_MARKER.encode("utf-8"))
38
+ cleaned = encoded[:keep].decode("utf-8", errors="ignore") + _TRUNCATION_MARKER
39
+ return cleaned
40
+
41
+
42
+ def sanitize_skill_name(raw: str | None) -> str | None:
43
+ if raw is None:
44
+ return None
45
+ return raw if raw in BUNDLED_SKILLS else CUSTOM_SKILL_PLACEHOLDER
46
+
47
+
48
+ def sanitize_resource_type(raw: str, kind: Literal["ros", "terraform"]) -> str:
49
+ """Keep official resource types; replace custom/unknown with placeholder."""
50
+ if kind == "ros":
51
+ if raw.startswith(ROS_ALLOWED_PREFIXES):
52
+ return raw
53
+ return CUSTOM_ROS_RESOURCE_PLACEHOLDER
54
+ # Terraform: resource types are of the form `<provider>_<resource>`.
55
+ if "_" not in raw:
56
+ return CUSTOM_TF_RESOURCE_PLACEHOLDER
57
+ provider = raw.split("_", 1)[0]
58
+ if provider in TERRAFORM_OFFICIAL_PROVIDERS:
59
+ return raw
60
+ return CUSTOM_TF_RESOURCE_PLACEHOLDER
61
+
62
+
63
+ def sanitize_terraform_provider(raw: str) -> str:
64
+ return raw if raw in TERRAFORM_OFFICIAL_PROVIDERS else CUSTOM_TF_PROVIDER_PLACEHOLDER
65
+
66
+
67
+ def sanitize_model_name(raw: str) -> str:
68
+ """Trim dev suffix (-YYYYMMDD) and map to known set or 'other'."""
69
+ base = _DEV_VERSION_SUFFIX_RE.sub("", raw)
70
+ return base if base in KNOWN_MODELS else OTHER_MODEL_PLACEHOLDER
71
+
72
+
73
+ def sanitize_tool_name(raw: str) -> str:
74
+ """MCP tools are collapsed to a single placeholder."""
75
+ if raw.startswith("mcp__"):
76
+ return MCP_TOOL_PLACEHOLDER
77
+ return raw
78
+
79
+
80
+ def bucket_resource_count(n: int) -> str:
81
+ """bucket for iac.deployment.duration histogram."""
82
+ if n <= 5:
83
+ return "1-5"
84
+ if n <= 20:
85
+ return "6-20"
86
+ if n <= 50:
87
+ return "21-50"
88
+ return "50+"
@@ -0,0 +1,67 @@
1
+ """AnalyticsSink — routes events through the privacy gate to the Events pipeline.
2
+
3
+ Design:
4
+ - Before `activate()`, events are queued in-memory (bounded by maxlen=10k).
5
+ - After `activate()`, events go directly to the EventEmitter.
6
+ - `drain_sync()` / `drain_soon()` flushes the pre-queue once activated.
7
+ - The privacy gate blocks emission when no-telemetry / essential-traffic is on.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from collections import deque
14
+ from threading import Lock
15
+ from typing import Any
16
+
17
+ from loguru import logger
18
+
19
+ from iac_code.services.telemetry.config import is_telemetry_disabled
20
+ from iac_code.services.telemetry.events import EventEmitter
21
+
22
+
23
+ class AnalyticsSink:
24
+ """Privacy-gated event router with pre-activation queue."""
25
+
26
+ def __init__(self, emitter: EventEmitter, queue_max: int = 10_000) -> None:
27
+ self._emitter = emitter
28
+ self._queue: deque[tuple[str, dict[str, Any]]] = deque(maxlen=queue_max)
29
+ self._lock = Lock()
30
+ self._active = False
31
+
32
+ def log_event(self, event_name: str, metadata: dict[str, Any]) -> None:
33
+ """Queue the event if not yet active, else gate+emit directly."""
34
+ with self._lock:
35
+ if not self._active:
36
+ self._queue.append((event_name, metadata))
37
+ return
38
+ self._dispatch(event_name, metadata)
39
+
40
+ def activate(self) -> None:
41
+ """Mark the sink active. Idempotent. Does NOT drain — call drain_*()."""
42
+ with self._lock:
43
+ self._active = True
44
+
45
+ def drain_sync(self) -> None:
46
+ """Synchronously flush the pre-activation queue."""
47
+ with self._lock:
48
+ queued = list(self._queue)
49
+ self._queue.clear()
50
+ for name, meta in queued:
51
+ self._dispatch(name, meta)
52
+
53
+ def drain_soon(self) -> None:
54
+ """Schedule an async drain on the running loop, if any. Else sync."""
55
+ try:
56
+ loop = asyncio.get_running_loop()
57
+ loop.call_soon(self.drain_sync)
58
+ return
59
+ except RuntimeError:
60
+ pass
61
+ self.drain_sync()
62
+
63
+ def _dispatch(self, event_name: str, metadata: dict[str, Any]) -> None:
64
+ logger.info("[event] {} {}", event_name, metadata)
65
+ if is_telemetry_disabled():
66
+ return
67
+ self._emitter.emit(event_name, metadata)
@@ -0,0 +1,38 @@
1
+ """SpanFactory — wraps the OTel Tracer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ from typing import Any, Iterator
7
+
8
+ from loguru import logger
9
+ from opentelemetry import trace
10
+ from opentelemetry.trace import Tracer
11
+
12
+ _TRACER_NAME = "com.iac-code.tracing"
13
+ _TRACER_VERSION = "1.0.0"
14
+
15
+
16
+ class SpanFactory:
17
+ """Context-manager span producer."""
18
+
19
+ def __init__(self) -> None:
20
+ self._tracer: Tracer | None = None
21
+
22
+ def attach(self, tracer: Tracer) -> None:
23
+ self._tracer = tracer
24
+
25
+ def _get_tracer(self) -> Tracer:
26
+ if self._tracer is None:
27
+ return trace.get_tracer(_TRACER_NAME, _TRACER_VERSION)
28
+ return self._tracer
29
+
30
+ @contextmanager
31
+ def start(self, name: str, attributes: dict[str, Any] | None = None) -> Iterator[Any]:
32
+ """Start a span as a context manager."""
33
+ tracer = self._get_tracer()
34
+ with tracer.start_as_current_span(name, attributes=attributes or {}) as span:
35
+ yield span
36
+ attrs = getattr(span, "attributes", None)
37
+ if attrs:
38
+ logger.debug("[span] {} {}", name, {k: v for k, v in attrs.items()})
@@ -0,0 +1,13 @@
1
+ """Type definitions for telemetry payloads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Union
6
+
7
+ # Primitive values allowed directly in event/metric metadata.
8
+ # Strings are NOT in this list by design — callers must sanitize text explicitly.
9
+ AllowedMetadataValue = Union[bool, int, float, None, list, str]
10
+ # Note: `str` is included because sanitize_*() returns str. Callers must only pass
11
+ # strings that came from a sanitize_*() function or from a well-known enum constant.
12
+
13
+ EventMetadata = dict[str, AllowedMetadataValue]
@@ -0,0 +1,54 @@
1
+ """Token budget management for controlling LLM usage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass
10
+ class TokenBudget:
11
+ """Tracks token consumption against a total budget."""
12
+
13
+ total: int | None = None # None means unlimited
14
+ used: int = field(default=0, init=False)
15
+
16
+ @property
17
+ def remaining(self) -> int | float:
18
+ if self.total is None:
19
+ return float("inf")
20
+ return max(0, self.total - self.used)
21
+
22
+ @property
23
+ def is_exhausted(self) -> bool:
24
+ if self.total is None:
25
+ return False
26
+ return self.used >= self.total
27
+
28
+ @property
29
+ def usage_percent(self) -> float:
30
+ if self.total is None or self.total == 0:
31
+ return 0.0
32
+ return (self.used / self.total) * 100.0
33
+
34
+ def consume(self, tokens: int) -> None:
35
+ self.used += tokens
36
+
37
+ @staticmethod
38
+ def parse_shorthand(text: str) -> int:
39
+ cleaned = text.strip().lstrip("+")
40
+ match = re.match(r"^(\d+(?:\.\d+)?)\s*([kmKM])?$", cleaned)
41
+ if not match:
42
+ raise ValueError(f"Invalid token shorthand: '{text}'")
43
+ value = float(match.group(1))
44
+ suffix = (match.group(2) or "").lower()
45
+ multipliers = {"k": 1_000, "m": 1_000_000}
46
+ return int(value * multipliers.get(suffix, 1))
47
+
48
+ @classmethod
49
+ def unlimited(cls) -> TokenBudget:
50
+ return cls(total=None)
51
+
52
+ @classmethod
53
+ def from_shorthand(cls, text: str) -> TokenBudget:
54
+ return cls(total=cls.parse_shorthand(text))