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 @@
1
+ # Memory system package.
@@ -0,0 +1,92 @@
1
+ """Persistent memory system — stores memories across sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ MEMORY_TYPES = {"user", "feedback", "project", "reference"}
9
+ INDEX_FILE = "MEMORY.md"
10
+ MAX_INDEX_LINES = 200
11
+
12
+
13
+ class MemoryManager:
14
+ def __init__(self, memory_dir: str):
15
+ self._memory_dir = memory_dir
16
+ os.makedirs(memory_dir, exist_ok=True)
17
+
18
+ def _memory_path(self, name: str) -> str:
19
+ return os.path.join(self._memory_dir, f"{name}.md")
20
+
21
+ def _index_path(self) -> str:
22
+ return os.path.join(self._memory_dir, INDEX_FILE)
23
+
24
+ def save(self, name: str, content: str, memory_type: str, description: str) -> None:
25
+ if memory_type not in MEMORY_TYPES:
26
+ raise ValueError(f"Invalid memory type: {memory_type}")
27
+ file_content = f"---\nname: {name}\ndescription: {description}\ntype: {memory_type}\n---\n\n{content}\n"
28
+ with open(self._memory_path(name), "w", encoding="utf-8") as f:
29
+ f.write(file_content)
30
+ self._update_index()
31
+
32
+ def load(self, name: str) -> dict[str, Any] | None:
33
+ path = self._memory_path(name)
34
+ if not os.path.exists(path):
35
+ return None
36
+ with open(path, encoding="utf-8") as f:
37
+ return self._parse_memory_file(f.read())
38
+
39
+ def delete(self, name: str) -> None:
40
+ path = self._memory_path(name)
41
+ if os.path.exists(path):
42
+ os.remove(path)
43
+ self._update_index()
44
+
45
+ def list_memories(self) -> list[dict[str, Any]]:
46
+ memories = []
47
+ for filename in os.listdir(self._memory_dir):
48
+ if filename.endswith(".md") and filename != INDEX_FILE:
49
+ mem = self.load(filename[:-3])
50
+ if mem:
51
+ memories.append(mem)
52
+ return memories
53
+
54
+ def get_index_content(self) -> str:
55
+ path = self._index_path()
56
+ if not os.path.exists(path):
57
+ return ""
58
+ with open(path, encoding="utf-8") as f:
59
+ return f.read()
60
+
61
+ def get_prompt_content(self) -> str:
62
+ memories = self.list_memories()
63
+ if not memories:
64
+ return ""
65
+ return "\n\n".join(f"[{m.get('type', '')}] {m['content']}" for m in memories)
66
+
67
+ def _update_index(self) -> None:
68
+ entries = []
69
+ for filename in sorted(os.listdir(self._memory_dir)):
70
+ if filename.endswith(".md") and filename != INDEX_FILE:
71
+ mem = self.load(filename[:-3])
72
+ if mem:
73
+ entries.append(f"- [{filename[:-3]}]({filename}) — {mem.get('description', '')}")
74
+ with open(self._index_path(), "w", encoding="utf-8") as f:
75
+ f.write("\n".join(entries[:MAX_INDEX_LINES]) + "\n")
76
+
77
+ @staticmethod
78
+ def _parse_memory_file(text: str) -> dict[str, Any]:
79
+ result: dict[str, Any] = {}
80
+ if text.startswith("---"):
81
+ parts = text.split("---", 2)
82
+ if len(parts) >= 3:
83
+ for line in parts[1].strip().split("\n"):
84
+ if ":" in line:
85
+ key, value = line.split(":", 1)
86
+ result[key.strip()] = value.strip()
87
+ result["content"] = parts[2].strip()
88
+ else:
89
+ result["content"] = text
90
+ else:
91
+ result["content"] = text
92
+ return result
@@ -0,0 +1,88 @@
1
+ """Tools for the model to read and write persistent memories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from iac_code.memory.memory_manager import MEMORY_TYPES, MemoryManager
8
+ from iac_code.tools.base import Tool, ToolContext, ToolResult
9
+
10
+
11
+ class ReadMemoryTool(Tool):
12
+ def __init__(self, memory_manager: MemoryManager):
13
+ self._manager = memory_manager
14
+
15
+ @property
16
+ def name(self) -> str:
17
+ return "read_memory"
18
+
19
+ @property
20
+ def description(self) -> str:
21
+ return "Read persistent memories. Omit name to list all, or provide name to read specific memory."
22
+
23
+ @property
24
+ def input_schema(self) -> dict[str, Any]:
25
+ return {
26
+ "type": "object",
27
+ "properties": {
28
+ "name": {
29
+ "type": "string",
30
+ "description": "Memory name to read. Omit to list all.",
31
+ }
32
+ },
33
+ }
34
+
35
+ async def execute(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult:
36
+ name = tool_input.get("name")
37
+ if name:
38
+ mem = self._manager.load(name)
39
+ if mem is None:
40
+ return ToolResult.error(f"Memory '{name}' not found.")
41
+ return ToolResult.success(f"[{mem.get('type', '')}] {mem.get('description', '')}\n\n{mem['content']}")
42
+ else:
43
+ index = self._manager.get_index_content()
44
+ return ToolResult.success(index or "No memories saved yet.")
45
+
46
+ def is_read_only(self, input: dict | None = None) -> bool:
47
+ return True
48
+
49
+
50
+ class WriteMemoryTool(Tool):
51
+ def __init__(self, memory_manager: MemoryManager):
52
+ self._manager = memory_manager
53
+
54
+ @property
55
+ def name(self) -> str:
56
+ return "write_memory"
57
+
58
+ @property
59
+ def description(self) -> str:
60
+ return f"Save a persistent memory. Types: {', '.join(sorted(MEMORY_TYPES))}."
61
+
62
+ @property
63
+ def input_schema(self) -> dict[str, Any]:
64
+ return {
65
+ "type": "object",
66
+ "properties": {
67
+ "name": {"type": "string"},
68
+ "content": {"type": "string"},
69
+ "memory_type": {"type": "string", "enum": sorted(MEMORY_TYPES)},
70
+ "description": {"type": "string"},
71
+ },
72
+ "required": ["name", "content", "memory_type", "description"],
73
+ }
74
+
75
+ async def execute(self, tool_input: dict[str, Any], context: ToolContext) -> ToolResult:
76
+ try:
77
+ self._manager.save(
78
+ name=tool_input["name"],
79
+ content=tool_input["content"],
80
+ memory_type=tool_input["memory_type"],
81
+ description=tool_input["description"],
82
+ )
83
+ return ToolResult.success(f"Memory '{tool_input['name']}' saved.")
84
+ except Exception as e:
85
+ return ToolResult.error(str(e))
86
+
87
+ def is_read_only(self, input: dict | None = None) -> bool:
88
+ return False
@@ -0,0 +1 @@
1
+ # Provider layer package.
@@ -0,0 +1,284 @@
1
+ """Anthropic provider — streams and completes via the Anthropic SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import AsyncGenerator
6
+ from typing import Any
7
+
8
+ import anthropic
9
+
10
+ from iac_code.providers.base import (
11
+ ContentBlock,
12
+ Message,
13
+ NonStreamingResponse,
14
+ Provider,
15
+ ToolDefinition,
16
+ )
17
+ from iac_code.providers.thinking import (
18
+ ANTHROPIC_BUDGET,
19
+ ThinkingFamily,
20
+ get_thinking_spec,
21
+ normalize_effort,
22
+ )
23
+ from iac_code.types.stream_events import (
24
+ MessageEndEvent,
25
+ MessageStartEvent,
26
+ StreamEvent,
27
+ TextDeltaEvent,
28
+ ThinkingDeltaEvent,
29
+ ToolInputDeltaEvent,
30
+ ToolUseStartEvent,
31
+ Usage,
32
+ )
33
+ from iac_code.utils.tool_input_parser import parse_tool_input_events
34
+
35
+ # Model aliases for variants that share a real model ID but require beta flags.
36
+ # Value format: (real_model_id, extra_beta_features)
37
+ _MODEL_ALIAS: dict[str, tuple[str, tuple[str, ...]]] = {
38
+ "claude-sonnet-4-6-1m": ("claude-sonnet-4-6", ("context-1m-2025-08-07",)),
39
+ }
40
+
41
+
42
+ class AnthropicProvider(Provider):
43
+ """Provider implementation backed by ``anthropic.AsyncAnthropic``."""
44
+
45
+ def __init__(
46
+ self,
47
+ model: str,
48
+ api_key: str | None = None,
49
+ base_url: str | None = None,
50
+ max_tokens: int = 8192,
51
+ client: Any = None,
52
+ effort: str | None = None,
53
+ **kwargs: Any,
54
+ ) -> None:
55
+ self._model = model
56
+ self._max_tokens = max_tokens
57
+ self._effort = effort
58
+ if client is not None:
59
+ self._client = client
60
+ else:
61
+ client_kwargs: dict[str, Any] = {}
62
+ if api_key is not None:
63
+ client_kwargs["api_key"] = api_key
64
+ if base_url is not None:
65
+ client_kwargs["base_url"] = base_url
66
+ client_kwargs.update(kwargs)
67
+ self._client = anthropic.AsyncAnthropic(**client_kwargs)
68
+
69
+ # -- public interface ------------------------------------------------------
70
+
71
+ _PROVIDER_KEY = "anthropic"
72
+
73
+ def get_model_name(self) -> str:
74
+ return self._model
75
+
76
+ def _build_thinking_kwargs(self) -> dict[str, Any]:
77
+ spec = get_thinking_spec(self._PROVIDER_KEY, self._model)
78
+ if spec.family is not ThinkingFamily.ANTHROPIC:
79
+ return {}
80
+ effort = normalize_effort(self._effort)
81
+ if effort is None or effort == "auto":
82
+ return {}
83
+ budget = ANTHROPIC_BUDGET.get(effort)
84
+ if budget is None:
85
+ return {}
86
+ return {"thinking": {"type": "enabled", "budget_tokens": budget}}
87
+
88
+ def _adjust_max_tokens(self, max_tokens: int) -> int:
89
+ spec = get_thinking_spec(self._PROVIDER_KEY, self._model)
90
+ if spec.family is not ThinkingFamily.ANTHROPIC:
91
+ return max_tokens
92
+ effort = normalize_effort(self._effort)
93
+ if effort is None or effort == "auto":
94
+ return max_tokens
95
+ budget = ANTHROPIC_BUDGET.get(effort)
96
+ if budget is None:
97
+ return max_tokens
98
+ min_max = budget + 4096
99
+ return max(max_tokens, min_max)
100
+
101
+ async def stream(
102
+ self,
103
+ messages: list[Message],
104
+ system: str,
105
+ tools: list[ToolDefinition] | None = None,
106
+ max_tokens: int = 8192,
107
+ ) -> AsyncGenerator[StreamEvent, None]:
108
+ kwargs = self._build_kwargs(messages, system, tools, max_tokens)
109
+
110
+ async with self._client.messages.stream(**kwargs) as stream:
111
+ # Track current content block state
112
+ current_tool_use_id: str | None = None
113
+ current_tool_name: str = ""
114
+ current_tool_input_json: str = ""
115
+
116
+ async for event in stream:
117
+ if event.type == "message_start":
118
+ event_data: Any = event
119
+ yield MessageStartEvent(message_id=event_data.message.id)
120
+
121
+ elif event.type == "content_block_start":
122
+ event_data: Any = event
123
+ block: Any = event_data.content_block
124
+ if block.type == "text":
125
+ pass # text deltas will follow
126
+ elif block.type == "tool_use":
127
+ current_tool_use_id = block.id
128
+ current_tool_name = block.name
129
+ current_tool_input_json = ""
130
+ yield ToolUseStartEvent(tool_use_id=block.id, name=block.name)
131
+ elif block.type == "thinking":
132
+ pass # thinking deltas will follow
133
+
134
+ elif event.type == "content_block_delta":
135
+ event_data: Any = event
136
+ delta: Any = event_data.delta
137
+ if delta.type == "text_delta":
138
+ yield TextDeltaEvent(text=delta.text)
139
+ elif delta.type == "input_json_delta":
140
+ current_tool_input_json += delta.partial_json
141
+ if current_tool_use_id is not None:
142
+ yield ToolInputDeltaEvent(
143
+ tool_use_id=current_tool_use_id,
144
+ partial_json=delta.partial_json,
145
+ )
146
+ elif delta.type == "thinking_delta":
147
+ yield ThinkingDeltaEvent(text=delta.thinking)
148
+
149
+ elif event.type == "content_block_stop":
150
+ if current_tool_use_id is not None:
151
+ events = list(
152
+ parse_tool_input_events(
153
+ current_tool_use_id,
154
+ current_tool_name,
155
+ current_tool_input_json,
156
+ )
157
+ )
158
+ for ev in events:
159
+ yield ev
160
+ current_tool_use_id = None
161
+ current_tool_name = ""
162
+ current_tool_input_json = ""
163
+
164
+ # After the stream ends, emit the final message event
165
+ final = await stream.get_final_message()
166
+ usage = Usage(
167
+ input_tokens=final.usage.input_tokens,
168
+ output_tokens=final.usage.output_tokens,
169
+ cache_creation_input_tokens=getattr(final.usage, "cache_creation_input_tokens", 0) or 0,
170
+ cache_read_input_tokens=getattr(final.usage, "cache_read_input_tokens", 0) or 0,
171
+ )
172
+ yield MessageEndEvent(stop_reason=final.stop_reason or "end_turn", usage=usage)
173
+
174
+ async def complete(
175
+ self,
176
+ messages: list[Message],
177
+ system: str,
178
+ tools: list[ToolDefinition] | None = None,
179
+ max_tokens: int = 8192,
180
+ ) -> NonStreamingResponse:
181
+ kwargs = self._build_kwargs(messages, system, tools, max_tokens)
182
+ response = await self._client.messages.create(**kwargs)
183
+
184
+ text_parts: list[str] = []
185
+ tool_uses: list[dict[str, Any]] = []
186
+ for block in response.content:
187
+ if block.type == "text":
188
+ text_parts.append(block.text)
189
+ elif block.type == "tool_use":
190
+ tool_uses.append({"id": block.id, "name": block.name, "input": block.input})
191
+
192
+ usage = Usage(
193
+ input_tokens=response.usage.input_tokens,
194
+ output_tokens=response.usage.output_tokens,
195
+ cache_creation_input_tokens=getattr(response.usage, "cache_creation_input_tokens", 0) or 0,
196
+ cache_read_input_tokens=getattr(response.usage, "cache_read_input_tokens", 0) or 0,
197
+ )
198
+
199
+ return NonStreamingResponse(
200
+ message_id=response.id,
201
+ text="".join(text_parts),
202
+ tool_uses=tool_uses,
203
+ stop_reason=response.stop_reason,
204
+ usage=usage,
205
+ )
206
+
207
+ # -- conversion helpers ----------------------------------------------------
208
+
209
+ def _build_kwargs(
210
+ self,
211
+ messages: list[Message],
212
+ system: str,
213
+ tools: list[ToolDefinition] | None,
214
+ max_tokens: int,
215
+ ) -> dict[str, Any]:
216
+ thinking_kwargs = self._build_thinking_kwargs()
217
+ effective_max_tokens = self._adjust_max_tokens(max_tokens)
218
+
219
+ model_id, extra_betas = _MODEL_ALIAS.get(self._model, (self._model, ()))
220
+ kwargs: dict[str, Any] = {
221
+ "model": model_id,
222
+ "max_tokens": effective_max_tokens,
223
+ "system": system,
224
+ "messages": self._convert_messages(messages),
225
+ }
226
+ if tools:
227
+ kwargs["tools"] = self._convert_tools(tools)
228
+ kwargs.update(thinking_kwargs)
229
+ if extra_betas:
230
+ kwargs["extra_headers"] = {"anthropic-beta": ",".join(extra_betas)}
231
+ return kwargs
232
+
233
+ def _convert_messages(self, messages: list[Message]) -> list[dict[str, Any]]:
234
+ """Convert internal ``Message`` list to Anthropic API format."""
235
+ result: list[dict[str, Any]] = []
236
+ for msg in messages:
237
+ if isinstance(msg.content, str):
238
+ result.append({"role": msg.role, "content": msg.content})
239
+ elif isinstance(msg.content, list):
240
+ blocks: list[dict[str, Any]] = []
241
+ for block in msg.content:
242
+ blocks.append(self._convert_content_block(block))
243
+ result.append({"role": msg.role, "content": blocks})
244
+ else:
245
+ result.append({"role": msg.role, "content": msg.content})
246
+ return result
247
+
248
+ @staticmethod
249
+ def _convert_content_block(block: ContentBlock) -> dict[str, Any]:
250
+ """Convert a single ``ContentBlock`` to Anthropic dict."""
251
+ if block.type == "text":
252
+ return {"type": "text", "text": block.text or ""}
253
+ elif block.type == "tool_use":
254
+ return {
255
+ "type": "tool_use",
256
+ "id": block.tool_use_id or "",
257
+ "name": block.name or "",
258
+ "input": block.input or {},
259
+ }
260
+ elif block.type == "tool_result":
261
+ d: dict[str, Any] = {
262
+ "type": "tool_result",
263
+ "tool_use_id": block.tool_use_id or "",
264
+ "content": block.content or "",
265
+ }
266
+ if block.is_error:
267
+ d["is_error"] = True
268
+ return d
269
+ elif block.type == "thinking":
270
+ return {"type": "thinking", "thinking": block.text or ""}
271
+ else:
272
+ return {"type": block.type}
273
+
274
+ @staticmethod
275
+ def _convert_tools(tools: list[ToolDefinition]) -> list[dict[str, Any]]:
276
+ """Convert ``ToolDefinition`` list to Anthropic API format."""
277
+ return [
278
+ {
279
+ "name": t.name,
280
+ "description": t.description,
281
+ "input_schema": t.input_schema,
282
+ }
283
+ for t in tools
284
+ ]
@@ -0,0 +1,128 @@
1
+ """Abstract Provider interface — unified across Anthropic, OpenAI, DashScope."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from collections.abc import AsyncGenerator
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from iac_code.types.stream_events import StreamEvent, Usage
11
+
12
+
13
+ @dataclass
14
+ class ToolDefinition:
15
+ """Tool schema passed to the model."""
16
+
17
+ name: str
18
+ description: str
19
+ input_schema: dict[str, Any]
20
+
21
+
22
+ @dataclass
23
+ class ContentBlock:
24
+ """A block of content within a message."""
25
+
26
+ type: str # "text", "tool_use", "tool_result", "thinking"
27
+ text: str | None = None
28
+ tool_use_id: str | None = None
29
+ name: str | None = None
30
+ input: dict[str, Any] | None = None
31
+ content: str | None = None
32
+ is_error: bool = False
33
+
34
+
35
+ @dataclass
36
+ class Message:
37
+ """Unified message format for all providers."""
38
+
39
+ role: str # "user", "assistant"
40
+ content: str | list[ContentBlock] = ""
41
+
42
+ @classmethod
43
+ def user(cls, text: str) -> Message:
44
+ return cls(role="user", content=text)
45
+
46
+ @classmethod
47
+ def assistant_text(cls, text: str) -> Message:
48
+ return cls(role="assistant", content=[ContentBlock(type="text", text=text)])
49
+
50
+ @classmethod
51
+ def assistant_tool_use(cls, *, tool_use_id: str, name: str, input: dict[str, Any]) -> Message:
52
+ return cls(
53
+ role="assistant",
54
+ content=[
55
+ ContentBlock(
56
+ type="tool_use",
57
+ tool_use_id=tool_use_id,
58
+ name=name,
59
+ input=input,
60
+ )
61
+ ],
62
+ )
63
+
64
+ @classmethod
65
+ def tool_result(cls, *, tool_use_id: str, content: str, is_error: bool = False) -> Message:
66
+ return cls(
67
+ role="user",
68
+ content=[
69
+ ContentBlock(
70
+ type="tool_result",
71
+ tool_use_id=tool_use_id,
72
+ content=content,
73
+ is_error=is_error,
74
+ )
75
+ ],
76
+ )
77
+
78
+
79
+ @dataclass
80
+ class NonStreamingResponse:
81
+ """Complete response from a non-streaming API call."""
82
+
83
+ message_id: str
84
+ text: str
85
+ tool_uses: list[dict[str, Any]]
86
+ stop_reason: str
87
+ usage: Usage
88
+ thinking: str = ""
89
+
90
+
91
+ class Provider(ABC):
92
+ @abstractmethod
93
+ def stream(
94
+ self,
95
+ messages: list[Message],
96
+ system: str,
97
+ tools: list[ToolDefinition] | None = None,
98
+ max_tokens: int = 8192,
99
+ ) -> AsyncGenerator[StreamEvent, None]: ...
100
+
101
+ @abstractmethod
102
+ async def complete(
103
+ self,
104
+ messages: list[Message],
105
+ system: str,
106
+ tools: list[ToolDefinition] | None = None,
107
+ max_tokens: int = 8192,
108
+ ) -> NonStreamingResponse: ...
109
+
110
+ @abstractmethod
111
+ def get_model_name(self) -> str: ...
112
+
113
+ def _build_thinking_kwargs(self) -> dict[str, Any]:
114
+ """Wire-level thinking kwargs to merge into the request payload.
115
+
116
+ Default: emit nothing. Subclasses override to translate
117
+ ``self._effort`` + the model's ``ThinkingSpec`` into provider-specific
118
+ request fields (e.g. ``reasoning_effort``, ``extra_body.thinking``).
119
+ """
120
+ return {}
121
+
122
+ def _adjust_max_tokens(self, max_tokens: int) -> int:
123
+ """Provider-specific ``max_tokens`` adjustment.
124
+
125
+ Anthropic raises this to leave room for the configured thinking
126
+ budget; other providers leave it unchanged.
127
+ """
128
+ return max_tokens
@@ -0,0 +1,47 @@
1
+ """DashScope provider — Aliyun DashScope's OpenAI-compatible endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from iac_code.providers.openai_provider import OpenAIProvider
8
+ from iac_code.providers.thinking import ThinkingFamily, get_thinking_spec
9
+
10
+ DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
11
+ DASHSCOPE_TOKEN_PLAN_BASE_URL = "https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"
12
+
13
+
14
+ class DashScopeProvider(OpenAIProvider):
15
+ """Provider backed by Aliyun DashScope's OpenAI-compatible endpoint.
16
+
17
+ Both standard DashScope and DashScope Token Plan share the same wire
18
+ protocol (extra_body.enable_thinking=True); only the base URL and
19
+ thinking-registry key differ. Both are injected via __init__.
20
+ """
21
+
22
+ _PROVIDER_KEY = "dashscope"
23
+ supports_stream_options = True
24
+
25
+ def __init__(
26
+ self,
27
+ model: str,
28
+ api_key: str | None = None,
29
+ effort: str | None = None,
30
+ base_url: str = DASHSCOPE_BASE_URL,
31
+ provider_key: str = "dashscope",
32
+ ) -> None:
33
+ super().__init__(
34
+ model=model,
35
+ api_key=api_key,
36
+ base_url=base_url,
37
+ effort=effort,
38
+ )
39
+ # Instance attribute shadows the class attribute so per-variant
40
+ # thinking-registry lookups resolve to the right MODEL_THINKING bucket.
41
+ self._PROVIDER_KEY = provider_key
42
+
43
+ def _build_thinking_kwargs(self) -> dict[str, Any]:
44
+ spec = get_thinking_spec(self._PROVIDER_KEY, self._model)
45
+ if spec.family is not ThinkingFamily.DASHSCOPE:
46
+ return {}
47
+ return {"extra_body": {"enable_thinking": True}}
@@ -0,0 +1,36 @@
1
+ """DeepSeek provider — uses OpenAI-compatible API with thinking mode support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from iac_code.providers.openai_provider import OpenAIProvider
6
+
7
+ DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1"
8
+
9
+
10
+ class DeepSeekProvider(OpenAIProvider):
11
+ """Provider backed by DeepSeek's OpenAI-compatible endpoint.
12
+
13
+ Wire format is identical to ``OpenAIProvider`` (``reasoning_effort`` +
14
+ ``extra_body.thinking.type=enabled``); the registry's ``allowed_efforts``
15
+ constrains the effort vocabulary to ``high`` / ``max`` for DeepSeek V4.
16
+
17
+ Reasoning content is captured via ``reasoning_content`` in the stream
18
+ and echoed back as ``reasoning_content`` on subsequent assistant
19
+ messages (required by DeepSeek when tool calls are involved).
20
+ """
21
+
22
+ _PROVIDER_KEY = "deepseek"
23
+ supports_stream_options = True
24
+
25
+ def __init__(
26
+ self,
27
+ model: str,
28
+ api_key: str | None = None,
29
+ effort: str | None = None,
30
+ ) -> None:
31
+ super().__init__(
32
+ model=model,
33
+ api_key=api_key,
34
+ base_url=DEEPSEEK_BASE_URL,
35
+ effort=effort,
36
+ )