claxcode 0.1.1__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.
- claxcode/__init__.py +3 -0
- claxcode/api/__init__.py +1 -0
- claxcode/api/chat.py +51 -0
- claxcode/api/session.py +66 -0
- claxcode/api/subagents.py +82 -0
- claxcode/api/tools.py +38 -0
- claxcode/cli.py +1214 -0
- claxcode/cli_completion.py +174 -0
- claxcode/config.py +54 -0
- claxcode/core/__init__.py +1 -0
- claxcode/core/agent.py +327 -0
- claxcode/core/checkpoint.py +100 -0
- claxcode/core/context.py +114 -0
- claxcode/core/models.py +446 -0
- claxcode/extensions/__init__.py +1 -0
- claxcode/extensions/hooks.py +131 -0
- claxcode/extensions/mcp.py +13 -0
- claxcode/extensions/plugins.py +10 -0
- claxcode/extensions/skills.py +205 -0
- claxcode/main.py +55 -0
- claxcode/memory/__init__.py +23 -0
- claxcode/memory/auto_memory.py +68 -0
- claxcode/memory/long_term.py +196 -0
- claxcode/memory/project_context.py +20 -0
- claxcode/memory/project_memory.py +194 -0
- claxcode/memory/session_store.py +206 -0
- claxcode/models/__init__.py +1 -0
- claxcode/models/session.py +25 -0
- claxcode/models/subagent.py +25 -0
- claxcode/models/tool.py +24 -0
- claxcode/security/__init__.py +1 -0
- claxcode/security/audit.py +54 -0
- claxcode/security/permissions.py +29 -0
- claxcode/skills/bug-fix/SKILL.md +33 -0
- claxcode/skills/code-review/SKILL.md +35 -0
- claxcode/skills/python-refactor/SKILL.md +22 -0
- claxcode/skills/task-workflow/SKILL.md +38 -0
- claxcode/subagents/__init__.py +1 -0
- claxcode/subagents/base.py +108 -0
- claxcode/subagents/executor.py +16 -0
- claxcode/subagents/explore.py +25 -0
- claxcode/subagents/planner.py +25 -0
- claxcode/tools/__init__.py +14 -0
- claxcode/tools/admin.py +170 -0
- claxcode/tools/base.py +19 -0
- claxcode/tools/browser.py +499 -0
- claxcode/tools/code_intelligence.py +68 -0
- claxcode/tools/custom_example.py +76 -0
- claxcode/tools/execution.py +114 -0
- claxcode/tools/file_operations.py +287 -0
- claxcode/tools/memory_tool.py +29 -0
- claxcode/tools/registry.py +35 -0
- claxcode/tools/search.py +157 -0
- claxcode/tools/ssh.py +534 -0
- claxcode/tools/web.py +83 -0
- claxcode-0.1.1.dist-info/METADATA +359 -0
- claxcode-0.1.1.dist-info/RECORD +60 -0
- claxcode-0.1.1.dist-info/WHEEL +4 -0
- claxcode-0.1.1.dist-info/entry_points.txt +3 -0
- claxcode-0.1.1.dist-info/licenses/LICENSE +201 -0
claxcode/core/context.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Context window management."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from claxcode.config import settings
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def estimate_tokens(text: str) -> int:
|
|
9
|
+
"""Very rough token estimation (1 token ~= 4 chars for English/CJK mix)."""
|
|
10
|
+
return len(text) // 3 + 1
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ContextManager:
|
|
14
|
+
"""Manages conversation history with compression support."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, system_prompt: str = ""):
|
|
17
|
+
self.system_prompt = system_prompt
|
|
18
|
+
self.messages: list[dict[str, Any]] = []
|
|
19
|
+
if system_prompt:
|
|
20
|
+
self.messages.append({"role": "system", "content": system_prompt})
|
|
21
|
+
|
|
22
|
+
def add_message(self, role: str, content: str) -> None:
|
|
23
|
+
self.messages.append({"role": role, "content": content})
|
|
24
|
+
self._maybe_compress()
|
|
25
|
+
|
|
26
|
+
def add_tool_result(self, tool_call_id: str, name: str, output: str) -> None:
|
|
27
|
+
"""Add a tool result message (OpenAI format)."""
|
|
28
|
+
self.messages.append(
|
|
29
|
+
{
|
|
30
|
+
"role": "tool",
|
|
31
|
+
"tool_call_id": tool_call_id,
|
|
32
|
+
"content": output,
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
self._maybe_compress()
|
|
36
|
+
|
|
37
|
+
def add_assistant_tool_calls(self, tool_calls: list[dict[str, Any]]) -> None:
|
|
38
|
+
self.messages.append(
|
|
39
|
+
{
|
|
40
|
+
"role": "assistant",
|
|
41
|
+
"content": "",
|
|
42
|
+
"tool_calls": tool_calls,
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
self._maybe_compress()
|
|
46
|
+
|
|
47
|
+
def get_messages(self) -> list[dict[str, Any]]:
|
|
48
|
+
return self.messages.copy()
|
|
49
|
+
|
|
50
|
+
def _total_tokens(self) -> int:
|
|
51
|
+
text = "\n".join(str(m.get("content", "")) for m in self.messages)
|
|
52
|
+
return estimate_tokens(text) + estimate_tokens(self.system_prompt)
|
|
53
|
+
|
|
54
|
+
def over_threshold(self) -> bool:
|
|
55
|
+
"""Whether the estimated token count exceeds the compression threshold."""
|
|
56
|
+
threshold = int(settings.max_context_tokens * settings.context_compression_threshold)
|
|
57
|
+
return self._total_tokens() > threshold
|
|
58
|
+
|
|
59
|
+
def pop_oldest(self, count: int) -> list[dict[str, Any]]:
|
|
60
|
+
"""Remove and return up to `count` oldest non-system messages.
|
|
61
|
+
|
|
62
|
+
Orphaned tool results at the cut boundary are dropped as well —
|
|
63
|
+
providers reject a tool result without its assistant tool call.
|
|
64
|
+
"""
|
|
65
|
+
end = min(1 + count, len(self.messages))
|
|
66
|
+
removed = self.messages[1:end]
|
|
67
|
+
del self.messages[1:end]
|
|
68
|
+
while len(self.messages) > 1 and self.messages[1].get("role") == "tool":
|
|
69
|
+
removed.append(self.messages.pop(1))
|
|
70
|
+
return removed
|
|
71
|
+
|
|
72
|
+
def insert_summary(self, summary: str) -> None:
|
|
73
|
+
"""Insert a compression summary right after the system prompt."""
|
|
74
|
+
self.messages.insert(1, {"role": "system", "content": summary})
|
|
75
|
+
|
|
76
|
+
def restore(self, messages: list[dict[str, Any]]) -> None:
|
|
77
|
+
"""Restore persisted messages after the system prompt.
|
|
78
|
+
|
|
79
|
+
Leading tool results are dropped — without their assistant tool call
|
|
80
|
+
(e.g. lost to compression in a previous session) providers reject them.
|
|
81
|
+
Trailing assistant tool_calls without matching tool results are also
|
|
82
|
+
dropped, e.g. when a previous run crashed between tool_calls and results.
|
|
83
|
+
"""
|
|
84
|
+
self.messages.extend(messages)
|
|
85
|
+
while len(self.messages) > 1 and self.messages[1].get("role") == "tool":
|
|
86
|
+
self.messages.pop(1)
|
|
87
|
+
|
|
88
|
+
i = 1
|
|
89
|
+
while i < len(self.messages):
|
|
90
|
+
msg = self.messages[i]
|
|
91
|
+
if msg.get("role") == "assistant" and msg.get("tool_calls"):
|
|
92
|
+
needed = {tc["id"] for tc in msg["tool_calls"]}
|
|
93
|
+
j = i + 1
|
|
94
|
+
found: set[str] = set()
|
|
95
|
+
while j < len(self.messages) and self.messages[j].get("role") == "tool":
|
|
96
|
+
found.add(self.messages[j].get("tool_call_id"))
|
|
97
|
+
j += 1
|
|
98
|
+
if needed != found:
|
|
99
|
+
# Drop the orphan assistant and any trailing tool messages.
|
|
100
|
+
del self.messages[i:j]
|
|
101
|
+
continue
|
|
102
|
+
i += 1
|
|
103
|
+
|
|
104
|
+
def _maybe_compress(self) -> None:
|
|
105
|
+
"""Emergency fallback: hard-drop oldest messages while over the threshold."""
|
|
106
|
+
while self.over_threshold() and len(self.messages) > 3:
|
|
107
|
+
self.messages.pop(1) # keep system at index 0
|
|
108
|
+
while len(self.messages) > 1 and self.messages[1].get("role") == "tool":
|
|
109
|
+
self.messages.pop(1)
|
|
110
|
+
|
|
111
|
+
def clear(self) -> None:
|
|
112
|
+
self.messages = []
|
|
113
|
+
if self.system_prompt:
|
|
114
|
+
self.messages.append({"role": "system", "content": self.system_prompt})
|
claxcode/core/models.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""LLM abstraction layer supporting multiple providers and custom models."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from claxcode.config import settings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ToolCallChunk:
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.id: str = ""
|
|
16
|
+
self.name: str = ""
|
|
17
|
+
self.arguments: str = ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LLMProvider(ABC):
|
|
21
|
+
"""Abstract LLM provider."""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
async def stream_chat(
|
|
25
|
+
self,
|
|
26
|
+
messages: list[dict[str, str]],
|
|
27
|
+
tools: list[dict[str, Any]] | None = None,
|
|
28
|
+
) -> AsyncIterator[str | dict[str, Any]]:
|
|
29
|
+
"""Yield text chunks or tool-call dicts."""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DummyProvider(LLMProvider):
|
|
34
|
+
"""Fallback provider when no LLM package is installed or config is invalid."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, reason: str = ""):
|
|
37
|
+
self.reason = reason
|
|
38
|
+
|
|
39
|
+
async def stream_chat(
|
|
40
|
+
self,
|
|
41
|
+
messages: list[dict[str, str]],
|
|
42
|
+
tools: list[dict[str, Any]] | None = None,
|
|
43
|
+
) -> AsyncIterator[str | dict[str, Any]]:
|
|
44
|
+
if self.reason:
|
|
45
|
+
yield f"[DummyProvider] {self.reason}"
|
|
46
|
+
else:
|
|
47
|
+
yield "[DummyProvider] 未配置 LLM。请设置 LLM_PROVIDER 和 LLM_API_KEY。"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class OpenAIProvider(LLMProvider):
|
|
51
|
+
"""OpenAI-compatible API provider using httpx (no openai package required)."""
|
|
52
|
+
|
|
53
|
+
def __init__(self):
|
|
54
|
+
self.api_key = settings.llm_api_key or "sk-test"
|
|
55
|
+
self.base_url = (settings.llm_base_url or "https://api.openai.com").rstrip("/")
|
|
56
|
+
self.model = settings.llm_model
|
|
57
|
+
self.temperature = settings.llm_temperature
|
|
58
|
+
self.max_tokens = settings.llm_max_tokens
|
|
59
|
+
|
|
60
|
+
async def stream_chat(
|
|
61
|
+
self,
|
|
62
|
+
messages: list[dict[str, str]],
|
|
63
|
+
tools: list[dict[str, Any]] | None = None,
|
|
64
|
+
) -> AsyncIterator[str | dict[str, Any]]:
|
|
65
|
+
url = f"{self.base_url}/v1/chat/completions"
|
|
66
|
+
headers = {
|
|
67
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
}
|
|
70
|
+
payload: dict[str, Any] = {
|
|
71
|
+
"model": self.model,
|
|
72
|
+
"messages": messages,
|
|
73
|
+
"temperature": self.temperature,
|
|
74
|
+
"max_tokens": self.max_tokens,
|
|
75
|
+
"stream": True,
|
|
76
|
+
}
|
|
77
|
+
if tools:
|
|
78
|
+
payload["tools"] = tools
|
|
79
|
+
|
|
80
|
+
if settings.debug:
|
|
81
|
+
print("[LLM PAYLOAD]", json.dumps(payload, ensure_ascii=False, indent=2))
|
|
82
|
+
|
|
83
|
+
async with httpx.AsyncClient(timeout=120) as client:
|
|
84
|
+
async with client.stream("POST", url, headers=headers, json=payload) as response:
|
|
85
|
+
if response.status_code >= 400:
|
|
86
|
+
body = await response.aread()
|
|
87
|
+
print(f"[LLM ERROR {response.status_code}] {body.decode('utf-8', errors='replace')}")
|
|
88
|
+
response.raise_for_status()
|
|
89
|
+
current_tool: ToolCallChunk | None = None
|
|
90
|
+
|
|
91
|
+
async for line in response.aiter_lines():
|
|
92
|
+
if not line:
|
|
93
|
+
continue
|
|
94
|
+
if line.startswith("data: "):
|
|
95
|
+
line = line[6:]
|
|
96
|
+
if line == "[DONE]":
|
|
97
|
+
break
|
|
98
|
+
try:
|
|
99
|
+
data = json.loads(line)
|
|
100
|
+
except json.JSONDecodeError:
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
delta = data.get("choices", [{}])[0].get("delta", {})
|
|
104
|
+
content = delta.get("content", "")
|
|
105
|
+
if content:
|
|
106
|
+
yield content
|
|
107
|
+
|
|
108
|
+
# Tool calls
|
|
109
|
+
tcs = delta.get("tool_calls", [])
|
|
110
|
+
for tc in tcs:
|
|
111
|
+
if tc.get("id"):
|
|
112
|
+
if current_tool is None:
|
|
113
|
+
current_tool = ToolCallChunk()
|
|
114
|
+
current_tool.id = tc["id"]
|
|
115
|
+
func = tc.get("function", {})
|
|
116
|
+
if func.get("name"):
|
|
117
|
+
if current_tool is None:
|
|
118
|
+
current_tool = ToolCallChunk()
|
|
119
|
+
current_tool.name = func["name"]
|
|
120
|
+
if func.get("arguments"):
|
|
121
|
+
if current_tool is None:
|
|
122
|
+
current_tool = ToolCallChunk()
|
|
123
|
+
current_tool.arguments += func["arguments"]
|
|
124
|
+
|
|
125
|
+
finish_reason = data.get("choices", [{}])[0].get("finish_reason")
|
|
126
|
+
if finish_reason and current_tool and current_tool.name:
|
|
127
|
+
try:
|
|
128
|
+
args = json.loads(current_tool.arguments or "{}")
|
|
129
|
+
except json.JSONDecodeError:
|
|
130
|
+
# Some providers return malformed JSON; fall back to raw string
|
|
131
|
+
args = {"_raw": current_tool.arguments}
|
|
132
|
+
yield {
|
|
133
|
+
"type": "tool_call",
|
|
134
|
+
"id": current_tool.id,
|
|
135
|
+
"name": current_tool.name,
|
|
136
|
+
"arguments": args,
|
|
137
|
+
}
|
|
138
|
+
current_tool = None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class AnthropicProvider(LLMProvider):
|
|
142
|
+
def __init__(self):
|
|
143
|
+
try:
|
|
144
|
+
from anthropic import AsyncAnthropic
|
|
145
|
+
except ImportError as exc:
|
|
146
|
+
raise ImportError(
|
|
147
|
+
"AnthropicProvider 需要 anthropic 包。请安装: pip install anthropic"
|
|
148
|
+
) from exc
|
|
149
|
+
|
|
150
|
+
self.client = AsyncAnthropic(api_key=settings.llm_api_key or "sk-test")
|
|
151
|
+
self.model = settings.llm_model
|
|
152
|
+
self.temperature = settings.llm_temperature
|
|
153
|
+
self.max_tokens = settings.llm_max_tokens
|
|
154
|
+
|
|
155
|
+
async def stream_chat(
|
|
156
|
+
self,
|
|
157
|
+
messages: list[dict[str, str]],
|
|
158
|
+
tools: list[dict[str, Any]] | None = None,
|
|
159
|
+
) -> AsyncIterator[str | dict[str, Any]]:
|
|
160
|
+
system = ""
|
|
161
|
+
anthropic_messages = []
|
|
162
|
+
for m in messages:
|
|
163
|
+
if m["role"] == "system":
|
|
164
|
+
system = m["content"]
|
|
165
|
+
else:
|
|
166
|
+
anthropic_messages.append({"role": m["role"], "content": m["content"]})
|
|
167
|
+
|
|
168
|
+
kwargs: dict[str, Any] = {
|
|
169
|
+
"model": self.model,
|
|
170
|
+
"messages": anthropic_messages,
|
|
171
|
+
"max_tokens": self.max_tokens,
|
|
172
|
+
"temperature": self.temperature,
|
|
173
|
+
"stream": True,
|
|
174
|
+
}
|
|
175
|
+
if system:
|
|
176
|
+
kwargs["system"] = system
|
|
177
|
+
if tools:
|
|
178
|
+
kwargs["tools"] = tools
|
|
179
|
+
|
|
180
|
+
async with self.client.messages.stream(**kwargs) as stream:
|
|
181
|
+
current_tool: ToolCallChunk | None = None
|
|
182
|
+
async for event in stream:
|
|
183
|
+
if event.type == "content_block_delta":
|
|
184
|
+
if hasattr(event.delta, "text") and event.delta.text:
|
|
185
|
+
yield event.delta.text
|
|
186
|
+
if hasattr(event.delta, "partial_json"):
|
|
187
|
+
if current_tool is None:
|
|
188
|
+
current_tool = ToolCallChunk()
|
|
189
|
+
current_tool.arguments += event.delta.partial_json
|
|
190
|
+
elif event.type == "content_block_start":
|
|
191
|
+
if hasattr(event.content_block, "name"):
|
|
192
|
+
if current_tool is None:
|
|
193
|
+
current_tool = ToolCallChunk()
|
|
194
|
+
current_tool.name = event.content_block.name
|
|
195
|
+
current_tool.id = getattr(event.content_block, "id", "")
|
|
196
|
+
elif event.type == "message_stop":
|
|
197
|
+
if current_tool and current_tool.name:
|
|
198
|
+
yield {
|
|
199
|
+
"type": "tool_call",
|
|
200
|
+
"id": current_tool.id,
|
|
201
|
+
"name": current_tool.name,
|
|
202
|
+
"arguments": json.loads(current_tool.arguments or "{}"),
|
|
203
|
+
}
|
|
204
|
+
current_tool = None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class GenericOpenAIProvider(OpenAIProvider):
|
|
208
|
+
"""For local models with OpenAI-compatible API (e.g., vLLM, LM Studio, DashScope)."""
|
|
209
|
+
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class OllamaProvider(LLMProvider):
|
|
214
|
+
"""Native Ollama API provider."""
|
|
215
|
+
|
|
216
|
+
def __init__(self):
|
|
217
|
+
self.base_url = settings.llm_base_url or "http://localhost:11434"
|
|
218
|
+
self.model = settings.llm_model
|
|
219
|
+
self.temperature = settings.llm_temperature
|
|
220
|
+
self.max_tokens = settings.llm_max_tokens
|
|
221
|
+
self.options = self._parse_options()
|
|
222
|
+
|
|
223
|
+
def _parse_options(self) -> dict[str, Any]:
|
|
224
|
+
"""Parse extra Ollama options from custom config."""
|
|
225
|
+
opts: dict[str, Any] = {}
|
|
226
|
+
try:
|
|
227
|
+
custom = json.loads(settings.llm_custom_config or "{}")
|
|
228
|
+
opts.update(custom.get("ollama_options", {}))
|
|
229
|
+
except json.JSONDecodeError:
|
|
230
|
+
pass
|
|
231
|
+
return opts
|
|
232
|
+
|
|
233
|
+
async def stream_chat(
|
|
234
|
+
self,
|
|
235
|
+
messages: list[dict[str, str]],
|
|
236
|
+
tools: list[dict[str, Any]] | None = None,
|
|
237
|
+
) -> AsyncIterator[str | dict[str, Any]]:
|
|
238
|
+
url = f"{self.base_url.rstrip('/')}/api/chat"
|
|
239
|
+
payload: dict[str, Any] = {
|
|
240
|
+
"model": self.model,
|
|
241
|
+
"messages": messages,
|
|
242
|
+
"stream": True,
|
|
243
|
+
"options": {
|
|
244
|
+
"temperature": self.temperature,
|
|
245
|
+
"num_predict": self.max_tokens,
|
|
246
|
+
**self.options,
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
if tools:
|
|
250
|
+
payload["tools"] = [
|
|
251
|
+
{
|
|
252
|
+
"type": "function",
|
|
253
|
+
"function": {
|
|
254
|
+
"name": t["function"]["name"],
|
|
255
|
+
"description": t["function"]["description"],
|
|
256
|
+
"parameters": t["function"]["parameters"],
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
for t in tools
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
async with httpx.AsyncClient(timeout=120) as client:
|
|
263
|
+
async with client.stream("POST", url, json=payload) as response:
|
|
264
|
+
response.raise_for_status()
|
|
265
|
+
async for line in response.aiter_lines():
|
|
266
|
+
if not line:
|
|
267
|
+
continue
|
|
268
|
+
try:
|
|
269
|
+
data = json.loads(line)
|
|
270
|
+
except json.JSONDecodeError:
|
|
271
|
+
continue
|
|
272
|
+
if data.get("done"):
|
|
273
|
+
break
|
|
274
|
+
msg = data.get("message", {})
|
|
275
|
+
content = msg.get("content", "")
|
|
276
|
+
if content:
|
|
277
|
+
yield content
|
|
278
|
+
tool_calls = msg.get("tool_calls", [])
|
|
279
|
+
for tc in tool_calls:
|
|
280
|
+
func = tc.get("function", {})
|
|
281
|
+
yield {
|
|
282
|
+
"type": "tool_call",
|
|
283
|
+
"id": tc.get("id", ""),
|
|
284
|
+
"name": func.get("name", ""),
|
|
285
|
+
"arguments": func.get("arguments", {}),
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class CustomHTTPProvider(LLMProvider):
|
|
290
|
+
"""Generic HTTP provider for custom LLM APIs.
|
|
291
|
+
|
|
292
|
+
Uses llm_custom_config to define request/response format:
|
|
293
|
+
{
|
|
294
|
+
"request_template": {
|
|
295
|
+
"url": "http://localhost:8000/v1/chat",
|
|
296
|
+
"method": "POST",
|
|
297
|
+
"headers": {"Authorization": "Bearer {api_key}"},
|
|
298
|
+
"body": {
|
|
299
|
+
"model": "{model}",
|
|
300
|
+
"messages": "{messages}",
|
|
301
|
+
"temperature": "{temperature}",
|
|
302
|
+
"max_tokens": "{max_tokens}",
|
|
303
|
+
"stream": true
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
"response_parser": {
|
|
307
|
+
"content_path": "choices.0.delta.content",
|
|
308
|
+
"tool_calls_path": "choices.0.delta.tool_calls"
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
def __init__(self):
|
|
314
|
+
self.model = settings.llm_model
|
|
315
|
+
self.temperature = settings.llm_temperature
|
|
316
|
+
self.max_tokens = settings.llm_max_tokens
|
|
317
|
+
self.api_key = settings.llm_api_key
|
|
318
|
+
self.custom_config = self._load_config()
|
|
319
|
+
|
|
320
|
+
def _load_config(self) -> dict[str, Any]:
|
|
321
|
+
try:
|
|
322
|
+
return json.loads(settings.llm_custom_config or "{}")
|
|
323
|
+
except json.JSONDecodeError:
|
|
324
|
+
return {}
|
|
325
|
+
|
|
326
|
+
def _render_template(self, template: Any, **values: Any) -> Any:
|
|
327
|
+
if isinstance(template, str):
|
|
328
|
+
result = template
|
|
329
|
+
for key, val in values.items():
|
|
330
|
+
result = result.replace(f"{{{key}}}", json.dumps(val) if isinstance(val, (list, dict)) else str(val))
|
|
331
|
+
return result
|
|
332
|
+
if isinstance(template, dict):
|
|
333
|
+
return {k: self._render_template(v, **values) for k, v in template.items()}
|
|
334
|
+
if isinstance(template, list):
|
|
335
|
+
return [self._render_template(item, **values) for item in template]
|
|
336
|
+
return template
|
|
337
|
+
|
|
338
|
+
async def stream_chat(
|
|
339
|
+
self,
|
|
340
|
+
messages: list[dict[str, str]],
|
|
341
|
+
tools: list[dict[str, Any]] | None = None,
|
|
342
|
+
) -> AsyncIterator[str | dict[str, Any]]:
|
|
343
|
+
cfg = self.custom_config.get("request_template", {})
|
|
344
|
+
url = cfg.get("url", "http://localhost:8000/v1/chat")
|
|
345
|
+
method = cfg.get("method", "POST")
|
|
346
|
+
headers = self._render_template(cfg.get("headers", {}), api_key=self.api_key)
|
|
347
|
+
body = self._render_template(
|
|
348
|
+
cfg.get("body", {}),
|
|
349
|
+
model=self.model,
|
|
350
|
+
messages=messages,
|
|
351
|
+
temperature=self.temperature,
|
|
352
|
+
max_tokens=self.max_tokens,
|
|
353
|
+
tools=tools or [],
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
async with httpx.AsyncClient(timeout=120) as client:
|
|
357
|
+
async with client.stream(method, url, headers=headers, json=body) as response:
|
|
358
|
+
response.raise_for_status()
|
|
359
|
+
parser = self.custom_config.get("response_parser", {})
|
|
360
|
+
content_path = parser.get("content_path", "choices.0.delta.content")
|
|
361
|
+
|
|
362
|
+
async for line in response.aiter_lines():
|
|
363
|
+
if not line:
|
|
364
|
+
continue
|
|
365
|
+
if line.startswith("data: "):
|
|
366
|
+
line = line[6:]
|
|
367
|
+
if line == "[DONE]":
|
|
368
|
+
break
|
|
369
|
+
try:
|
|
370
|
+
data = json.loads(line)
|
|
371
|
+
except json.JSONDecodeError:
|
|
372
|
+
continue
|
|
373
|
+
|
|
374
|
+
content = self._get_path(data, content_path)
|
|
375
|
+
if content:
|
|
376
|
+
yield content
|
|
377
|
+
|
|
378
|
+
tool_path = parser.get("tool_calls_path")
|
|
379
|
+
if tool_path:
|
|
380
|
+
tcs = self._get_path(data, tool_path)
|
|
381
|
+
if tcs:
|
|
382
|
+
for tc in tcs:
|
|
383
|
+
yield {
|
|
384
|
+
"type": "tool_call",
|
|
385
|
+
"id": tc.get("id", ""),
|
|
386
|
+
"name": tc.get("function", {}).get("name", ""),
|
|
387
|
+
"arguments": tc.get("function", {}).get("arguments", {}),
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
def _get_path(self, data: dict, path: str) -> Any:
|
|
391
|
+
parts = path.split(".")
|
|
392
|
+
current: Any = data
|
|
393
|
+
for part in parts:
|
|
394
|
+
if isinstance(current, dict):
|
|
395
|
+
current = current.get(part)
|
|
396
|
+
elif isinstance(current, list) and part.isdigit():
|
|
397
|
+
current = current[int(part)] if int(part) < len(current) else None
|
|
398
|
+
else:
|
|
399
|
+
return None
|
|
400
|
+
if current is None:
|
|
401
|
+
return None
|
|
402
|
+
return current
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
_CUSTOM_PROVIDERS: dict[str, type[LLMProvider]] = {}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def register_custom_provider(name: str, provider_cls: type[LLMProvider]) -> None:
|
|
409
|
+
"""Register a custom LLM provider class at runtime.
|
|
410
|
+
|
|
411
|
+
Example:
|
|
412
|
+
from claxcode.core.models import register_custom_provider, LLMProvider
|
|
413
|
+
|
|
414
|
+
class MyProvider(LLMProvider):
|
|
415
|
+
async def stream_chat(self, messages, tools=None):
|
|
416
|
+
yield "Hello from my model!"
|
|
417
|
+
|
|
418
|
+
register_custom_provider("my_model", MyProvider)
|
|
419
|
+
"""
|
|
420
|
+
_CUSTOM_PROVIDERS[name.lower()] = provider_cls
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def get_provider() -> LLMProvider:
|
|
424
|
+
provider = settings.llm_provider.lower()
|
|
425
|
+
if provider in _CUSTOM_PROVIDERS:
|
|
426
|
+
return _CUSTOM_PROVIDERS[provider]()
|
|
427
|
+
if provider == "openai":
|
|
428
|
+
return OpenAIProvider()
|
|
429
|
+
if provider == "anthropic":
|
|
430
|
+
try:
|
|
431
|
+
return AnthropicProvider()
|
|
432
|
+
except ImportError as exc:
|
|
433
|
+
return DummyProvider(
|
|
434
|
+
reason=f"加载提供商 '{provider}' 失败。{exc}. "
|
|
435
|
+
"请安装所需的包或选择其他 LLM_PROVIDER。"
|
|
436
|
+
)
|
|
437
|
+
if provider == "generic_openai":
|
|
438
|
+
return GenericOpenAIProvider()
|
|
439
|
+
if provider == "ollama":
|
|
440
|
+
return OllamaProvider()
|
|
441
|
+
if provider == "custom_http":
|
|
442
|
+
return CustomHTTPProvider()
|
|
443
|
+
return DummyProvider(
|
|
444
|
+
reason=f"未知的提供商 '{provider}'. "
|
|
445
|
+
"支持的提供商: openai, anthropic, generic_openai, ollama, custom_http。"
|
|
446
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Extension system for ClaxCode."""
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Lifecycle hooks — run shell commands before/after tool execution.
|
|
2
|
+
|
|
3
|
+
Config is read from `~/.claxcode/hooks.json` (global) and
|
|
4
|
+
`<project>/.claxcode/hooks.json` (project). Format:
|
|
5
|
+
|
|
6
|
+
{
|
|
7
|
+
"PreToolUse": [{"match": "run_shell|write_file", "command": "python guard.py"}],
|
|
8
|
+
"PostToolUse": [{"match": "", "command": "echo done"}]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
`match` is a regex tested against the tool name (empty or "*" matches all).
|
|
12
|
+
The hook command receives a JSON payload on stdin
|
|
13
|
+
({"tool": ..., "arguments": {...}, "result": ...}) plus the env vars
|
|
14
|
+
CLAXCODE_TOOL_NAME / CLAXCODE_TOOL_ARGS, and runs with the config's
|
|
15
|
+
project directory as cwd. A PreToolUse hook blocks the tool call when it
|
|
16
|
+
exits non-zero; its stdout becomes the block reason. Hooks that fail to
|
|
17
|
+
spawn or time out never block the main flow.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
HOOK_TIMEOUT = 30
|
|
29
|
+
HOOK_EVENTS = ("PreToolUse", "PostToolUse")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Hook:
|
|
34
|
+
"""A single shell hook from a hooks.json config."""
|
|
35
|
+
|
|
36
|
+
match: str
|
|
37
|
+
command: str
|
|
38
|
+
cwd: Path
|
|
39
|
+
|
|
40
|
+
def matches(self, tool_name: str) -> bool:
|
|
41
|
+
if not self.match or self.match == "*":
|
|
42
|
+
return True
|
|
43
|
+
try:
|
|
44
|
+
return re.search(self.match, tool_name) is not None
|
|
45
|
+
except re.error:
|
|
46
|
+
return self.match == tool_name
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HookManager:
|
|
50
|
+
"""Load and execute PreToolUse/PostToolUse shell hooks."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, project_path: Path | None = None):
|
|
53
|
+
self._hooks: dict[str, list[Hook]] = {event: [] for event in HOOK_EVENTS}
|
|
54
|
+
for config_path in self._config_paths(project_path):
|
|
55
|
+
self._load_config(config_path)
|
|
56
|
+
|
|
57
|
+
def _config_paths(self, project_path: Path | None) -> list[Path]:
|
|
58
|
+
paths = [Path.home() / ".claxcode" / "hooks.json"]
|
|
59
|
+
if project_path is not None:
|
|
60
|
+
paths.append(project_path / ".claxcode" / "hooks.json")
|
|
61
|
+
return paths
|
|
62
|
+
|
|
63
|
+
def _load_config(self, path: Path) -> None:
|
|
64
|
+
if not path.exists():
|
|
65
|
+
return
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
68
|
+
except (json.JSONDecodeError, OSError):
|
|
69
|
+
return
|
|
70
|
+
cwd = path.parent.parent if path.parent.name == ".claxcode" else path.parent
|
|
71
|
+
for event in HOOK_EVENTS:
|
|
72
|
+
for entry in data.get(event, []):
|
|
73
|
+
command = entry.get("command", "")
|
|
74
|
+
if command:
|
|
75
|
+
self._hooks[event].append(Hook(entry.get("match", ""), command, cwd))
|
|
76
|
+
|
|
77
|
+
async def run_pre(self, tool_name: str, arguments: dict[str, Any]) -> tuple[bool, str]:
|
|
78
|
+
"""Run PreToolUse hooks. Returns (allowed, reason)."""
|
|
79
|
+
for hook in self._hooks["PreToolUse"]:
|
|
80
|
+
if not hook.matches(tool_name):
|
|
81
|
+
continue
|
|
82
|
+
code, output = await self._run_command(hook, tool_name, arguments)
|
|
83
|
+
if code != 0:
|
|
84
|
+
return False, output or f"钩子命令退出码 {code}"
|
|
85
|
+
return True, ""
|
|
86
|
+
|
|
87
|
+
async def run_post(self, tool_name: str, arguments: dict[str, Any], result: str) -> str:
|
|
88
|
+
"""Run PostToolUse hooks. Returns combined hook output (may be empty)."""
|
|
89
|
+
outputs = []
|
|
90
|
+
for hook in self._hooks["PostToolUse"]:
|
|
91
|
+
if not hook.matches(tool_name):
|
|
92
|
+
continue
|
|
93
|
+
_, output = await self._run_command(hook, tool_name, arguments, result)
|
|
94
|
+
if output:
|
|
95
|
+
outputs.append(output)
|
|
96
|
+
return "\n".join(outputs)
|
|
97
|
+
|
|
98
|
+
async def _run_command(
|
|
99
|
+
self,
|
|
100
|
+
hook: Hook,
|
|
101
|
+
tool_name: str,
|
|
102
|
+
arguments: dict[str, Any],
|
|
103
|
+
result: str = "",
|
|
104
|
+
) -> tuple[int, str]:
|
|
105
|
+
payload = {"tool": tool_name, "arguments": arguments, "result": result}
|
|
106
|
+
env = {
|
|
107
|
+
**os.environ,
|
|
108
|
+
"CLAXCODE_TOOL_NAME": tool_name,
|
|
109
|
+
"CLAXCODE_TOOL_ARGS": json.dumps(arguments, ensure_ascii=False),
|
|
110
|
+
}
|
|
111
|
+
try:
|
|
112
|
+
proc = await asyncio.create_subprocess_shell(
|
|
113
|
+
hook.command,
|
|
114
|
+
stdin=asyncio.subprocess.PIPE,
|
|
115
|
+
stdout=asyncio.subprocess.PIPE,
|
|
116
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
117
|
+
cwd=str(hook.cwd),
|
|
118
|
+
env=env,
|
|
119
|
+
)
|
|
120
|
+
except OSError:
|
|
121
|
+
return 0, "" # 钩子无法启动时不阻塞主流程
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
stdout, _ = await asyncio.wait_for(
|
|
125
|
+
proc.communicate(json.dumps(payload, ensure_ascii=False).encode()),
|
|
126
|
+
timeout=HOOK_TIMEOUT,
|
|
127
|
+
)
|
|
128
|
+
except TimeoutError:
|
|
129
|
+
proc.kill()
|
|
130
|
+
return 0, ""
|
|
131
|
+
return proc.returncode or 0, stdout.decode("utf-8", errors="replace").strip()
|