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.
- thwip/__init__.py +4 -0
- thwip/__main__.py +6 -0
- thwip/agents/__init__.py +98 -0
- thwip/agents/base.py +343 -0
- thwip/agents/claude_agent.py +346 -0
- thwip/agents/deepseek_agent.py +217 -0
- thwip/agents/google_agent.py +314 -0
- thwip/agents/groq_agent.py +181 -0
- thwip/agents/ollama_agent.py +180 -0
- thwip/agents/openai_agent.py +338 -0
- thwip/agents/openrouter_agent.py +192 -0
- thwip/cli.py +593 -0
- thwip/config.py +363 -0
- thwip/detector.py +259 -0
- thwip/limits.py +84 -0
- thwip/session.py +175 -0
- thwip/shortcuts.py +75 -0
- thwip/theme.py +380 -0
- thwip/tools/__init__.py +184 -0
- thwip/tools/code_runner.py +62 -0
- thwip/tools/file_editor.py +92 -0
- thwip/tools/git_ops.py +44 -0
- thwip/tools/terminal.py +65 -0
- thwip/utils.py +110 -0
- thwip_cli-1.0.0.dist-info/METADATA +157 -0
- thwip_cli-1.0.0.dist-info/RECORD +28 -0
- thwip_cli-1.0.0.dist-info/WHEEL +4 -0
- thwip_cli-1.0.0.dist-info/entry_points.txt +2 -0
|
@@ -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
|