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 @@
|
|
|
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
|
+
)
|