thwip-cli 1.0.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.
@@ -0,0 +1,346 @@
1
+ """
2
+ Claude Code agent adapter (Anthropic).
3
+
4
+ Full capabilities: chat, file editing, code execution, terminal, git.
5
+ Detects Claude Code CLI installation and Anthropic API keys.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import json
12
+ import shutil
13
+ import subprocess
14
+ from pathlib import Path
15
+ from typing import Any, AsyncIterator
16
+
17
+ from thwip.agents.base import (
18
+ AgentDone,
19
+ AgentEvent,
20
+ BaseAgent,
21
+ Capability,
22
+ LimitHit,
23
+ LimitStatus,
24
+ ModelInfo,
25
+ SubscriptionInfo,
26
+ SubscriptionTier,
27
+ TextDelta,
28
+ ThinkingDelta,
29
+ TokenUsage,
30
+ ToolUseStart,
31
+ )
32
+
33
+
34
+ class ClaudeAgent(BaseAgent):
35
+ """
36
+ Anthropic Claude Code: full coding agent.
37
+ Capabilities: Chat, file editing, code execution, terminal, git, browser, search.
38
+ Uses the Anthropic Python SDK for API communication.
39
+ """
40
+
41
+ name = "claude"
42
+ display_name = "Claude Code"
43
+ company = "Anthropic"
44
+ description = "Anthropic's agentic coding assistant with full terminal and file access"
45
+ website = "https://claude.ai/code"
46
+
47
+ capabilities = {
48
+ Capability.CHAT,
49
+ Capability.FILE_EDIT,
50
+ Capability.FILE_READ,
51
+ Capability.CODE_RUN,
52
+ Capability.TERMINAL,
53
+ Capability.GIT,
54
+ Capability.BROWSER,
55
+ Capability.SEARCH,
56
+ }
57
+
58
+ available_models = [
59
+ ModelInfo(
60
+ id="claude-sonnet-4",
61
+ name="Claude Sonnet 4",
62
+ context_window=200_000,
63
+ max_output=16_384,
64
+ supports_tools=True,
65
+ supports_streaming=True,
66
+ supports_vision=True,
67
+ supports_thinking=True,
68
+ is_default=True,
69
+ pricing_input=3.0,
70
+ pricing_output=15.0,
71
+ ),
72
+ ModelInfo(
73
+ id="claude-opus-4",
74
+ name="Claude Opus 4",
75
+ context_window=200_000,
76
+ max_output=32_000,
77
+ supports_tools=True,
78
+ supports_streaming=True,
79
+ supports_vision=True,
80
+ supports_thinking=True,
81
+ pricing_input=15.0,
82
+ pricing_output=75.0,
83
+ ),
84
+ ModelInfo(
85
+ id="claude-haiku-3.5",
86
+ name="Claude Haiku 3.5",
87
+ context_window=200_000,
88
+ max_output=8_192,
89
+ supports_tools=True,
90
+ supports_streaming=True,
91
+ supports_vision=True,
92
+ is_default=False,
93
+ pricing_input=0.80,
94
+ pricing_output=4.0,
95
+ ),
96
+ ]
97
+
98
+ def __init__(self, api_key: str | None = None) -> None:
99
+ self._api_key = api_key
100
+ self._client = None
101
+ self._last_limit_status = LimitStatus.UNKNOWN
102
+ self._install_cache: dict[str, str] | None = None
103
+
104
+ def _get_api_key(self) -> str | None:
105
+ """Resolve API key from multiple sources."""
106
+ if self._api_key:
107
+ return self._api_key
108
+
109
+ # 1. Environment variable
110
+ key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
111
+ if key:
112
+ return key
113
+
114
+ # 2. Claude Code config files
115
+ config_paths = [
116
+ Path.home() / ".claude.json",
117
+ Path.home() / ".claude" / "config.json",
118
+ Path.home() / ".config" / "claude" / "config.json",
119
+ ]
120
+ for p in config_paths:
121
+ if p.is_file():
122
+ try:
123
+ data = json.loads(p.read_text())
124
+ if isinstance(data, dict):
125
+ k = data.get("apiKey") or data.get("api_key") or ""
126
+ if k:
127
+ return k
128
+ except (json.JSONDecodeError, OSError):
129
+ continue
130
+
131
+ return None
132
+
133
+ def _ensure_client(self) -> Any:
134
+ """Lazily initialize the Anthropic client."""
135
+ if self._client is None:
136
+ try:
137
+ import anthropic
138
+ key = self._get_api_key()
139
+ if key:
140
+ self._client = anthropic.AsyncAnthropic(api_key=key)
141
+ else:
142
+ self._client = anthropic.AsyncAnthropic() # Will use env var
143
+ except ImportError:
144
+ raise RuntimeError(
145
+ "anthropic package not installed. Run: pip install anthropic"
146
+ )
147
+ return self._client
148
+
149
+ # --- Detection ---
150
+
151
+ def is_installed(self) -> bool:
152
+ """Check if Claude Code CLI is installed or configured."""
153
+ return shutil.which("claude") is not None or self.is_configured()
154
+
155
+ def is_configured(self) -> bool:
156
+ """Check if we have a valid Anthropic API key."""
157
+ return bool(self._get_api_key())
158
+
159
+ def get_install_info(self) -> dict[str, str]:
160
+ """Get Claude Code installation details."""
161
+ if self._install_cache is not None:
162
+ return self._install_cache
163
+
164
+ info: dict[str, str] = {"method": "not installed", "path": "", "version": ""}
165
+
166
+ path = shutil.which("claude")
167
+ if path:
168
+ info["path"] = path
169
+ info["method"] = "npm global" # Claude Code is typically installed via npm
170
+
171
+ # Try to get version
172
+ try:
173
+ result = subprocess.run(
174
+ ["claude", "--version"],
175
+ capture_output=True, text=True, timeout=5,
176
+ )
177
+ if result.returncode == 0:
178
+ info["version"] = result.stdout.strip().split("\n")[0]
179
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
180
+ pass
181
+
182
+ self._install_cache = info
183
+ return info
184
+
185
+ def get_subscription_info(self) -> SubscriptionInfo:
186
+ """
187
+ Determine subscription status.
188
+ We can't fully detect tier without an API call, but we can infer from
189
+ the key prefix and check basic validity.
190
+ """
191
+ key = self._get_api_key()
192
+ if not key:
193
+ return SubscriptionInfo(
194
+ tier=SubscriptionTier.UNKNOWN,
195
+ is_active=False,
196
+ message="No API key found",
197
+ )
198
+
199
+ # Anthropic keys start with "sk-ant-"
200
+ if key.startswith("sk-ant-"):
201
+ return SubscriptionInfo(
202
+ tier=SubscriptionTier.PRO, # Assume pro if they have a key
203
+ is_active=True,
204
+ message="API key detected",
205
+ )
206
+
207
+ return SubscriptionInfo(
208
+ tier=SubscriptionTier.UNKNOWN,
209
+ is_active=True,
210
+ message="API key format not recognized, but may work",
211
+ )
212
+
213
+ # --- Chat ---
214
+
215
+ async def chat(
216
+ self,
217
+ messages: list[dict[str, Any]],
218
+ model: str | None = None,
219
+ system_prompt: str | None = None,
220
+ tools: list[dict[str, Any]] | None = None,
221
+ stream: bool = True,
222
+ ) -> AsyncIterator[AgentEvent]:
223
+ """Stream a chat response from Claude."""
224
+ import anthropic
225
+
226
+ client = self._ensure_client()
227
+ model = model or self.get_default_model()
228
+
229
+ # Build request kwargs
230
+ kwargs: dict[str, Any] = {
231
+ "model": model,
232
+ "messages": messages,
233
+ "max_tokens": 16_384,
234
+ }
235
+
236
+ if system_prompt:
237
+ kwargs["system"] = system_prompt
238
+
239
+ if tools:
240
+ kwargs["tools"] = tools
241
+
242
+ # Check if model supports extended thinking
243
+ model_info = self.get_model_info(model)
244
+ if model_info and model_info.supports_thinking:
245
+ # Enable extended thinking for supported models
246
+ kwargs["thinking"] = {"type": "enabled", "budget_tokens": 10_000}
247
+ kwargs["max_tokens"] = 16_384 + 10_000
248
+
249
+ try:
250
+ if stream:
251
+ async with client.messages.stream(**kwargs) as response:
252
+ async for event in response:
253
+ if hasattr(event, "type"):
254
+ if event.type == "content_block_delta":
255
+ if hasattr(event.delta, "text"):
256
+ yield TextDelta(content=event.delta.text)
257
+ elif hasattr(event.delta, "thinking"):
258
+ yield ThinkingDelta(content=event.delta.thinking)
259
+ elif event.type == "content_block_start":
260
+ if hasattr(event.content_block, "type") and event.content_block.type == "tool_use":
261
+ yield ToolUseStart(
262
+ tool_id=event.content_block.id,
263
+ tool_name=event.content_block.name,
264
+ args={},
265
+ )
266
+ elif event.type == "message_stop":
267
+ usage = getattr(response, "usage", None) or getattr(
268
+ response, "current_message_snapshot", None
269
+ )
270
+ tu = TokenUsage()
271
+ if usage:
272
+ msg = response.current_message_snapshot
273
+ if msg and hasattr(msg, "usage"):
274
+ tu = TokenUsage(
275
+ input_tokens=msg.usage.input_tokens,
276
+ output_tokens=msg.usage.output_tokens,
277
+ )
278
+ yield AgentDone(usage=tu)
279
+
280
+ self._last_limit_status = LimitStatus.OK
281
+ else:
282
+ response = await client.messages.create(**kwargs)
283
+ for block in response.content:
284
+ if hasattr(block, "text"):
285
+ yield TextDelta(content=block.text)
286
+ elif hasattr(block, "type") and block.type == "tool_use":
287
+ yield ToolUseStart(
288
+ tool_id=block.id,
289
+ tool_name=block.name,
290
+ args=block.input,
291
+ )
292
+ yield AgentDone(
293
+ usage=TokenUsage(
294
+ input_tokens=response.usage.input_tokens,
295
+ output_tokens=response.usage.output_tokens,
296
+ ),
297
+ stop_reason=response.stop_reason or "end_turn",
298
+ )
299
+ self._last_limit_status = LimitStatus.OK
300
+
301
+ except anthropic.RateLimitError as e:
302
+ self._last_limit_status = LimitStatus.RATE_LIMITED
303
+ retry_after = None
304
+ if hasattr(e, "response") and e.response:
305
+ retry_after_str = e.response.headers.get("retry-after")
306
+ if retry_after_str:
307
+ try:
308
+ retry_after = float(retry_after_str)
309
+ except ValueError:
310
+ pass
311
+ yield LimitHit(
312
+ error_type=LimitStatus.RATE_LIMITED,
313
+ retry_after=retry_after,
314
+ message=str(e),
315
+ )
316
+
317
+ except anthropic.APIStatusError as e:
318
+ error_body = getattr(e, "body", {}) or {}
319
+ error_msg = ""
320
+ if isinstance(error_body, dict):
321
+ err = error_body.get("error", {})
322
+ error_msg = err.get("message", str(e)) if isinstance(err, dict) else str(e)
323
+ else:
324
+ error_msg = str(e)
325
+
326
+ # Check for quota/spend limit exhaustion
327
+ if "spend_limit" in error_msg.lower() or "quota" in error_msg.lower():
328
+ self._last_limit_status = LimitStatus.QUOTA_EXHAUSTED
329
+ yield LimitHit(
330
+ error_type=LimitStatus.QUOTA_EXHAUSTED,
331
+ message=error_msg,
332
+ )
333
+ else:
334
+ self._last_limit_status = LimitStatus.UNKNOWN
335
+ yield LimitHit(
336
+ error_type=LimitStatus.UNKNOWN,
337
+ message=error_msg,
338
+ )
339
+
340
+ # --- Limits ---
341
+
342
+ def check_limits(self) -> LimitStatus:
343
+ """Return last known limit status."""
344
+ if not self.is_configured():
345
+ return LimitStatus.NO_KEY
346
+ return self._last_limit_status if self._last_limit_status != LimitStatus.UNKNOWN else LimitStatus.OK
@@ -0,0 +1,217 @@
1
+ """
2
+ DeepSeek agent adapter.
3
+
4
+ Capabilities: Chat, code generation, reasoning, file editing.
5
+ Uses OpenAI-compatible API client pointing to DeepSeek endpoints.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import json
12
+ import shutil
13
+ from pathlib import Path
14
+ from typing import Any, AsyncIterator
15
+
16
+ from thwip.agents.base import (
17
+ AgentDone,
18
+ AgentEvent,
19
+ BaseAgent,
20
+ Capability,
21
+ LimitHit,
22
+ LimitStatus,
23
+ ModelInfo,
24
+ SubscriptionInfo,
25
+ SubscriptionTier,
26
+ TextDelta,
27
+ ThinkingDelta,
28
+ TokenUsage,
29
+ ToolUseStart,
30
+ )
31
+
32
+
33
+ class DeepSeekAgent(BaseAgent):
34
+ """
35
+ DeepSeek Coder / DeepSeek V3 / R1 Agent.
36
+
37
+ Capabilities: Chat, code generation, reasoning, file editing.
38
+ Uses OpenAI-compatible client connecting to https://api.deepseek.com.
39
+ """
40
+
41
+ name = "deepseek"
42
+ display_name = "DeepSeek"
43
+ company = "DeepSeek"
44
+ description = "High performance reasoning & coding models with DeepSeek R1 and V3"
45
+ website = "https://deepseek.com"
46
+
47
+ capabilities = {
48
+ Capability.CHAT,
49
+ Capability.FILE_EDIT,
50
+ Capability.FILE_READ,
51
+ Capability.CODE_RUN,
52
+ Capability.SEARCH,
53
+ }
54
+
55
+ available_models = [
56
+ ModelInfo(
57
+ id="deepseek-chat",
58
+ name="DeepSeek V3 (Chat)",
59
+ context_window=64_000,
60
+ max_output=8_192,
61
+ supports_tools=True,
62
+ supports_streaming=True,
63
+ is_default=True,
64
+ pricing_input=0.14,
65
+ pricing_output=0.28,
66
+ ),
67
+ ModelInfo(
68
+ id="deepseek-reasoner",
69
+ name="DeepSeek R1 (Reasoner)",
70
+ context_window=64_000,
71
+ max_output=8_192,
72
+ supports_tools=False,
73
+ supports_streaming=True,
74
+ supports_thinking=True,
75
+ pricing_input=0.55,
76
+ pricing_output=2.19,
77
+ ),
78
+ ]
79
+
80
+ def __init__(self, api_key: str | None = None) -> None:
81
+ self._api_key = api_key
82
+ self._client = None
83
+ self._last_limit_status = LimitStatus.UNKNOWN
84
+
85
+ def _get_api_key(self) -> str | None:
86
+ if self._api_key:
87
+ return self._api_key
88
+ key = os.environ.get("DEEPSEEK_API_KEY", "").strip()
89
+ if key:
90
+ return key
91
+ config_path = Path.home() / ".config" / "deepseek" / "config.json"
92
+ if config_path.is_file():
93
+ try:
94
+ data = json.loads(config_path.read_text())
95
+ return data.get("api_key") or data.get("apiKey")
96
+ except (json.JSONDecodeError, OSError):
97
+ pass
98
+ return None
99
+
100
+ def _ensure_client(self) -> Any:
101
+ if self._client is None:
102
+ try:
103
+ from openai import AsyncOpenAI
104
+ key = self._get_api_key()
105
+ self._client = AsyncOpenAI(
106
+ api_key=key or "dummy",
107
+ base_url="https://api.deepseek.com",
108
+ )
109
+ except ImportError:
110
+ raise RuntimeError("openai package required for DeepSeek adapter.")
111
+ return self._client
112
+
113
+ def is_installed(self) -> bool:
114
+ return self.is_configured() or shutil.which("deepseek") is not None
115
+
116
+ def is_configured(self) -> bool:
117
+ return bool(self._get_api_key())
118
+
119
+ def get_install_info(self) -> dict[str, str]:
120
+ path = shutil.which("deepseek") or ""
121
+ return {
122
+ "method": "API / CLI" if path else "API Key Configured",
123
+ "path": path,
124
+ "version": "API v1",
125
+ }
126
+
127
+ def get_subscription_info(self) -> SubscriptionInfo:
128
+ key = self._get_api_key()
129
+ if not key:
130
+ return SubscriptionInfo(tier=SubscriptionTier.UNKNOWN, is_active=False, message="No API key found")
131
+ return SubscriptionInfo(tier=SubscriptionTier.PRO, is_active=True, message="DeepSeek API key ready")
132
+
133
+ async def chat(
134
+ self,
135
+ messages: list[dict[str, Any]],
136
+ model: str | None = None,
137
+ system_prompt: str | None = None,
138
+ tools: list[dict[str, Any]] | None = None,
139
+ stream: bool = True,
140
+ ) -> AsyncIterator[AgentEvent]:
141
+ import openai
142
+
143
+ client = self._ensure_client()
144
+ model = model or self.get_default_model()
145
+
146
+ api_messages: list[dict[str, Any]] = []
147
+ if system_prompt:
148
+ api_messages.append({"role": "system", "content": system_prompt})
149
+ api_messages.extend(messages)
150
+
151
+ kwargs: dict[str, Any] = {
152
+ "model": model,
153
+ "messages": api_messages,
154
+ "stream": stream,
155
+ }
156
+ if tools and model != "deepseek-reasoner":
157
+ kwargs["tools"] = tools
158
+
159
+ try:
160
+ if stream:
161
+ response = await client.chat.completions.create(**kwargs)
162
+ total_usage = TokenUsage()
163
+ async for chunk in response:
164
+ if chunk.choices:
165
+ delta = chunk.choices[0].delta
166
+ reasoning = getattr(delta, "reasoning_content", None)
167
+ if reasoning:
168
+ yield ThinkingDelta(content=reasoning)
169
+ if delta.content:
170
+ yield TextDelta(content=delta.content)
171
+ if delta.tool_calls:
172
+ for tc in delta.tool_calls:
173
+ if tc.function:
174
+ yield ToolUseStart(
175
+ tool_id=tc.id or "",
176
+ tool_name=tc.function.name or "",
177
+ args=json.loads(tc.function.arguments) if tc.function.arguments else {},
178
+ )
179
+ if chunk.usage:
180
+ total_usage = TokenUsage(
181
+ input_tokens=chunk.usage.prompt_tokens or 0,
182
+ output_tokens=chunk.usage.completion_tokens or 0,
183
+ )
184
+ yield AgentDone(usage=total_usage)
185
+ self._last_limit_status = LimitStatus.OK
186
+ else:
187
+ response = await client.chat.completions.create(**kwargs)
188
+ msg = response.choices[0].message
189
+ reasoning = getattr(msg, "reasoning_content", None)
190
+ if reasoning:
191
+ yield ThinkingDelta(content=reasoning)
192
+ if msg.content:
193
+ yield TextDelta(content=msg.content)
194
+ usage = response.usage
195
+ yield AgentDone(
196
+ usage=TokenUsage(
197
+ input_tokens=usage.prompt_tokens if usage else 0,
198
+ output_tokens=usage.completion_tokens if usage else 0,
199
+ )
200
+ )
201
+ self._last_limit_status = LimitStatus.OK
202
+
203
+ except openai.RateLimitError as e:
204
+ self._last_limit_status = LimitStatus.RATE_LIMITED
205
+ yield LimitHit(error_type=LimitStatus.RATE_LIMITED, message=str(e))
206
+ except openai.APIStatusError as e:
207
+ err_text = str(e).lower()
208
+ if "insufficient" in err_text or "quota" in err_text or "balance" in err_text:
209
+ self._last_limit_status = LimitStatus.QUOTA_EXHAUSTED
210
+ yield LimitHit(error_type=LimitStatus.QUOTA_EXHAUSTED, message=str(e))
211
+ else:
212
+ yield LimitHit(error_type=LimitStatus.UNKNOWN, message=str(e))
213
+
214
+ def check_limits(self) -> LimitStatus:
215
+ if not self.is_configured():
216
+ return LimitStatus.NO_KEY
217
+ return self._last_limit_status if self._last_limit_status != LimitStatus.UNKNOWN else LimitStatus.OK