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,314 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Google / Antigravity agent adapter.
|
|
3
|
+
|
|
4
|
+
Full capabilities: chat, file editing, code execution, terminal, browser, search.
|
|
5
|
+
Detects Antigravity IDE, Gemini CLI, and Google 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 GoogleAgent(BaseAgent):
|
|
35
|
+
"""
|
|
36
|
+
Google Antigravity / Gemini: full coding agent.
|
|
37
|
+
Capabilities: Chat, file editing, code execution, terminal, browser, web search.
|
|
38
|
+
Uses the google-genai SDK for API communication.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name = "google"
|
|
42
|
+
display_name = "Antigravity / Gemini"
|
|
43
|
+
company = "Google"
|
|
44
|
+
description = "Google's agentic coding assistant with full IDE, terminal, and browser access"
|
|
45
|
+
website = "https://gemini.google.com"
|
|
46
|
+
|
|
47
|
+
capabilities = {
|
|
48
|
+
Capability.CHAT,
|
|
49
|
+
Capability.FILE_EDIT,
|
|
50
|
+
Capability.FILE_READ,
|
|
51
|
+
Capability.CODE_RUN,
|
|
52
|
+
Capability.TERMINAL,
|
|
53
|
+
Capability.BROWSER,
|
|
54
|
+
Capability.SEARCH,
|
|
55
|
+
Capability.IMAGE_GEN,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
available_models = [
|
|
59
|
+
ModelInfo(
|
|
60
|
+
id="gemini-2.5-pro",
|
|
61
|
+
name="Gemini 2.5 Pro",
|
|
62
|
+
context_window=1_048_576,
|
|
63
|
+
max_output=65_536,
|
|
64
|
+
supports_tools=True,
|
|
65
|
+
supports_streaming=True,
|
|
66
|
+
supports_vision=True,
|
|
67
|
+
supports_thinking=True,
|
|
68
|
+
is_default=True,
|
|
69
|
+
pricing_input=1.25,
|
|
70
|
+
pricing_output=10.0,
|
|
71
|
+
),
|
|
72
|
+
ModelInfo(
|
|
73
|
+
id="gemini-2.5-flash",
|
|
74
|
+
name="Gemini 2.5 Flash",
|
|
75
|
+
context_window=1_048_576,
|
|
76
|
+
max_output=65_536,
|
|
77
|
+
supports_tools=True,
|
|
78
|
+
supports_streaming=True,
|
|
79
|
+
supports_vision=True,
|
|
80
|
+
supports_thinking=True,
|
|
81
|
+
pricing_input=0.15,
|
|
82
|
+
pricing_output=0.60,
|
|
83
|
+
),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
87
|
+
self._api_key = api_key
|
|
88
|
+
self._client = None
|
|
89
|
+
self._last_limit_status = LimitStatus.UNKNOWN
|
|
90
|
+
|
|
91
|
+
def _get_api_key(self) -> str | None:
|
|
92
|
+
if self._api_key:
|
|
93
|
+
return self._api_key
|
|
94
|
+
|
|
95
|
+
# 1. Environment variables
|
|
96
|
+
for env_var in ("GEMINI_API_KEY", "GOOGLE_API_KEY"):
|
|
97
|
+
key = os.environ.get(env_var, "").strip()
|
|
98
|
+
if key:
|
|
99
|
+
return key
|
|
100
|
+
|
|
101
|
+
# 2. Gemini CLI / Antigravity config files
|
|
102
|
+
config_paths = [
|
|
103
|
+
Path.home() / ".config" / "gemini" / "config.json",
|
|
104
|
+
Path.home() / ".gemini" / "config.json",
|
|
105
|
+
Path.home() / ".config" / "antigravity" / "config.json",
|
|
106
|
+
]
|
|
107
|
+
for p in config_paths:
|
|
108
|
+
if p.is_file():
|
|
109
|
+
try:
|
|
110
|
+
data = json.loads(p.read_text())
|
|
111
|
+
k = data.get("api_key") or data.get("apiKey") or ""
|
|
112
|
+
if k:
|
|
113
|
+
return k
|
|
114
|
+
except (json.JSONDecodeError, OSError):
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
def _ensure_client(self) -> Any:
|
|
120
|
+
if self._client is None:
|
|
121
|
+
try:
|
|
122
|
+
from google import genai
|
|
123
|
+
key = self._get_api_key()
|
|
124
|
+
if key:
|
|
125
|
+
self._client = genai.Client(api_key=key)
|
|
126
|
+
else:
|
|
127
|
+
self._client = genai.Client()
|
|
128
|
+
except ImportError:
|
|
129
|
+
raise RuntimeError(
|
|
130
|
+
"google-genai package not installed. Run: pip install google-genai"
|
|
131
|
+
)
|
|
132
|
+
return self._client
|
|
133
|
+
|
|
134
|
+
# --- Detection ---
|
|
135
|
+
|
|
136
|
+
def is_installed(self) -> bool:
|
|
137
|
+
"""Check if Gemini CLI, Antigravity, or API is configured."""
|
|
138
|
+
return self.is_configured() or any(
|
|
139
|
+
shutil.which(cmd) is not None
|
|
140
|
+
for cmd in ("gemini", "agy", "antigravity")
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def is_configured(self) -> bool:
|
|
144
|
+
return bool(self._get_api_key())
|
|
145
|
+
|
|
146
|
+
def get_install_info(self) -> dict[str, str]:
|
|
147
|
+
info: dict[str, str] = {"method": "not installed", "path": "", "version": ""}
|
|
148
|
+
|
|
149
|
+
for cmd in ("gemini", "agy", "antigravity"):
|
|
150
|
+
path = shutil.which(cmd)
|
|
151
|
+
if path:
|
|
152
|
+
info["path"] = path
|
|
153
|
+
info["method"] = "npm global" if cmd == "gemini" else "binary"
|
|
154
|
+
try:
|
|
155
|
+
result = subprocess.run(
|
|
156
|
+
[cmd, "--version"],
|
|
157
|
+
capture_output=True, text=True, timeout=5,
|
|
158
|
+
)
|
|
159
|
+
if result.returncode == 0:
|
|
160
|
+
info["version"] = result.stdout.strip().split("\n")[0]
|
|
161
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
162
|
+
pass
|
|
163
|
+
break
|
|
164
|
+
|
|
165
|
+
# Also check for Antigravity IDE (macOS app)
|
|
166
|
+
if not info["path"]:
|
|
167
|
+
app_paths = [
|
|
168
|
+
Path("/Applications/Antigravity.app"),
|
|
169
|
+
Path.home() / "Applications" / "Antigravity.app",
|
|
170
|
+
]
|
|
171
|
+
for app in app_paths:
|
|
172
|
+
if app.exists():
|
|
173
|
+
info["method"] = "macOS app"
|
|
174
|
+
info["path"] = str(app)
|
|
175
|
+
break
|
|
176
|
+
|
|
177
|
+
return info
|
|
178
|
+
|
|
179
|
+
def get_subscription_info(self) -> SubscriptionInfo:
|
|
180
|
+
key = self._get_api_key()
|
|
181
|
+
if not key:
|
|
182
|
+
return SubscriptionInfo(
|
|
183
|
+
tier=SubscriptionTier.UNKNOWN,
|
|
184
|
+
is_active=False,
|
|
185
|
+
message="No API key found",
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Google Gemini API keys typically start with "AIza"
|
|
189
|
+
if key.startswith("AIza"):
|
|
190
|
+
return SubscriptionInfo(
|
|
191
|
+
tier=SubscriptionTier.FREE, # Free tier by default
|
|
192
|
+
is_active=True,
|
|
193
|
+
message="Google API key detected",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return SubscriptionInfo(
|
|
197
|
+
tier=SubscriptionTier.UNKNOWN,
|
|
198
|
+
is_active=True,
|
|
199
|
+
message="API key detected",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# --- Chat ---
|
|
203
|
+
|
|
204
|
+
async def chat(
|
|
205
|
+
self,
|
|
206
|
+
messages: list[dict[str, Any]],
|
|
207
|
+
model: str | None = None,
|
|
208
|
+
system_prompt: str | None = None,
|
|
209
|
+
tools: list[dict[str, Any]] | None = None,
|
|
210
|
+
stream: bool = True,
|
|
211
|
+
) -> AsyncIterator[AgentEvent]:
|
|
212
|
+
"""Stream a chat response from Gemini."""
|
|
213
|
+
client = self._ensure_client()
|
|
214
|
+
model = model or self.get_default_model()
|
|
215
|
+
|
|
216
|
+
# Convert messages to Gemini format
|
|
217
|
+
gemini_contents = []
|
|
218
|
+
for msg in messages:
|
|
219
|
+
role = "user" if msg["role"] == "user" else "model"
|
|
220
|
+
gemini_contents.append({
|
|
221
|
+
"role": role,
|
|
222
|
+
"parts": [{"text": msg["content"]}],
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
config: dict[str, Any] = {}
|
|
226
|
+
if system_prompt:
|
|
227
|
+
config["system_instruction"] = system_prompt
|
|
228
|
+
|
|
229
|
+
model_info = self.get_model_info(model)
|
|
230
|
+
if model_info and model_info.supports_thinking:
|
|
231
|
+
config["thinking_config"] = {"thinking_budget": 8192}
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
from google.genai import types
|
|
235
|
+
|
|
236
|
+
if stream:
|
|
237
|
+
response = client.models.generate_content_stream(
|
|
238
|
+
model=model,
|
|
239
|
+
contents=gemini_contents,
|
|
240
|
+
config=types.GenerateContentConfig(**config) if config else None,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
total_input = 0
|
|
244
|
+
total_output = 0
|
|
245
|
+
|
|
246
|
+
for chunk in response:
|
|
247
|
+
if chunk.candidates:
|
|
248
|
+
for candidate in chunk.candidates:
|
|
249
|
+
if candidate.content and candidate.content.parts:
|
|
250
|
+
for part in candidate.content.parts:
|
|
251
|
+
if hasattr(part, "thought") and part.thought:
|
|
252
|
+
yield ThinkingDelta(content=part.text or "")
|
|
253
|
+
elif hasattr(part, "text") and part.text:
|
|
254
|
+
yield TextDelta(content=part.text)
|
|
255
|
+
|
|
256
|
+
if hasattr(chunk, "usage_metadata") and chunk.usage_metadata:
|
|
257
|
+
um = chunk.usage_metadata
|
|
258
|
+
total_input = getattr(um, "prompt_token_count", 0) or 0
|
|
259
|
+
total_output = getattr(um, "candidates_token_count", 0) or 0
|
|
260
|
+
|
|
261
|
+
yield AgentDone(
|
|
262
|
+
usage=TokenUsage(
|
|
263
|
+
input_tokens=total_input,
|
|
264
|
+
output_tokens=total_output,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
self._last_limit_status = LimitStatus.OK
|
|
268
|
+
else:
|
|
269
|
+
response = client.models.generate_content(
|
|
270
|
+
model=model,
|
|
271
|
+
contents=gemini_contents,
|
|
272
|
+
config=types.GenerateContentConfig(**config) if config else None,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
if response.candidates:
|
|
276
|
+
for candidate in response.candidates:
|
|
277
|
+
if candidate.content and candidate.content.parts:
|
|
278
|
+
for part in candidate.content.parts:
|
|
279
|
+
if hasattr(part, "text") and part.text:
|
|
280
|
+
yield TextDelta(content=part.text)
|
|
281
|
+
|
|
282
|
+
um = getattr(response, "usage_metadata", None)
|
|
283
|
+
yield AgentDone(
|
|
284
|
+
usage=TokenUsage(
|
|
285
|
+
input_tokens=getattr(um, "prompt_token_count", 0) or 0,
|
|
286
|
+
output_tokens=getattr(um, "candidates_token_count", 0) or 0,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
self._last_limit_status = LimitStatus.OK
|
|
290
|
+
|
|
291
|
+
except Exception as e:
|
|
292
|
+
error_str = str(e).lower()
|
|
293
|
+
if "resource" in error_str and "exhaust" in error_str:
|
|
294
|
+
self._last_limit_status = LimitStatus.RATE_LIMITED
|
|
295
|
+
yield LimitHit(
|
|
296
|
+
error_type=LimitStatus.RATE_LIMITED,
|
|
297
|
+
message=str(e),
|
|
298
|
+
)
|
|
299
|
+
elif "quota" in error_str or "limit" in error_str:
|
|
300
|
+
self._last_limit_status = LimitStatus.QUOTA_EXHAUSTED
|
|
301
|
+
yield LimitHit(
|
|
302
|
+
error_type=LimitStatus.QUOTA_EXHAUSTED,
|
|
303
|
+
message=str(e),
|
|
304
|
+
)
|
|
305
|
+
else:
|
|
306
|
+
yield LimitHit(
|
|
307
|
+
error_type=LimitStatus.UNKNOWN,
|
|
308
|
+
message=str(e),
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
def check_limits(self) -> LimitStatus:
|
|
312
|
+
if not self.is_configured():
|
|
313
|
+
return LimitStatus.NO_KEY
|
|
314
|
+
return self._last_limit_status if self._last_limit_status != LimitStatus.UNKNOWN else LimitStatus.OK
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Groq agent adapter.
|
|
3
|
+
|
|
4
|
+
High speed LPU inference for Llama 3.3, Mixtral, and Gemma models.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
from typing import Any, AsyncIterator
|
|
12
|
+
|
|
13
|
+
from thwip.agents.base import (
|
|
14
|
+
AgentDone,
|
|
15
|
+
AgentEvent,
|
|
16
|
+
BaseAgent,
|
|
17
|
+
Capability,
|
|
18
|
+
LimitHit,
|
|
19
|
+
LimitStatus,
|
|
20
|
+
ModelInfo,
|
|
21
|
+
SubscriptionInfo,
|
|
22
|
+
SubscriptionTier,
|
|
23
|
+
TextDelta,
|
|
24
|
+
TokenUsage,
|
|
25
|
+
ToolUseStart,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class GroqAgent(BaseAgent):
|
|
30
|
+
"""
|
|
31
|
+
Groq LPU Inference Agent.
|
|
32
|
+
|
|
33
|
+
Ultra-fast inference for rapid iteration.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
name = "groq"
|
|
37
|
+
display_name = "Groq"
|
|
38
|
+
company = "Groq"
|
|
39
|
+
description = "Ultra high-speed LPU inference engine"
|
|
40
|
+
website = "https://groq.com"
|
|
41
|
+
|
|
42
|
+
capabilities = {
|
|
43
|
+
Capability.CHAT,
|
|
44
|
+
Capability.FILE_EDIT,
|
|
45
|
+
Capability.FILE_READ,
|
|
46
|
+
Capability.CODE_RUN,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
available_models = [
|
|
50
|
+
ModelInfo(
|
|
51
|
+
id="llama-3.3-70b-versatile",
|
|
52
|
+
name="Llama 3.3 70B (Versatile)",
|
|
53
|
+
context_window=128_000,
|
|
54
|
+
max_output=32_768,
|
|
55
|
+
supports_tools=True,
|
|
56
|
+
supports_streaming=True,
|
|
57
|
+
is_default=True,
|
|
58
|
+
pricing_input=0.59,
|
|
59
|
+
pricing_output=0.79,
|
|
60
|
+
),
|
|
61
|
+
ModelInfo(
|
|
62
|
+
id="mixtral-8x7b-32768",
|
|
63
|
+
name="Mixtral 8x7B",
|
|
64
|
+
context_window=32_768,
|
|
65
|
+
max_output=32_768,
|
|
66
|
+
supports_tools=True,
|
|
67
|
+
supports_streaming=True,
|
|
68
|
+
pricing_input=0.24,
|
|
69
|
+
pricing_output=0.24,
|
|
70
|
+
),
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
74
|
+
self._api_key = api_key
|
|
75
|
+
self._client = None
|
|
76
|
+
self._last_limit_status = LimitStatus.UNKNOWN
|
|
77
|
+
|
|
78
|
+
def _get_api_key(self) -> str | None:
|
|
79
|
+
if self._api_key:
|
|
80
|
+
return self._api_key
|
|
81
|
+
return os.environ.get("GROQ_API_KEY", "").strip() or None
|
|
82
|
+
|
|
83
|
+
def _ensure_client(self) -> Any:
|
|
84
|
+
if self._client is None:
|
|
85
|
+
try:
|
|
86
|
+
from openai import AsyncOpenAI
|
|
87
|
+
key = self._get_api_key()
|
|
88
|
+
self._client = AsyncOpenAI(
|
|
89
|
+
api_key=key or "dummy",
|
|
90
|
+
base_url="https://api.groq.com/openai/v1",
|
|
91
|
+
)
|
|
92
|
+
except ImportError:
|
|
93
|
+
raise RuntimeError("openai package required for Groq adapter.")
|
|
94
|
+
return self._client
|
|
95
|
+
|
|
96
|
+
def is_installed(self) -> bool:
|
|
97
|
+
return self.is_configured() or shutil.which("groq") is not None
|
|
98
|
+
|
|
99
|
+
def is_configured(self) -> bool:
|
|
100
|
+
return bool(self._get_api_key())
|
|
101
|
+
|
|
102
|
+
def get_install_info(self) -> dict[str, str]:
|
|
103
|
+
path = shutil.which("groq") or ""
|
|
104
|
+
return {
|
|
105
|
+
"method": "API / CLI" if path else "API Key Configured",
|
|
106
|
+
"path": path,
|
|
107
|
+
"version": "API v1",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
def get_subscription_info(self) -> SubscriptionInfo:
|
|
111
|
+
key = self._get_api_key()
|
|
112
|
+
if not key:
|
|
113
|
+
return SubscriptionInfo(tier=SubscriptionTier.UNKNOWN, is_active=False, message="No API key found")
|
|
114
|
+
return SubscriptionInfo(tier=SubscriptionTier.FREE, is_active=True, message="Groq API key ready")
|
|
115
|
+
|
|
116
|
+
async def chat(
|
|
117
|
+
self,
|
|
118
|
+
messages: list[dict[str, Any]],
|
|
119
|
+
model: str | None = None,
|
|
120
|
+
system_prompt: str | None = None,
|
|
121
|
+
tools: list[dict[str, Any]] | None = None,
|
|
122
|
+
stream: bool = True,
|
|
123
|
+
) -> AsyncIterator[AgentEvent]:
|
|
124
|
+
import openai
|
|
125
|
+
|
|
126
|
+
client = self._ensure_client()
|
|
127
|
+
model = model or self.get_default_model()
|
|
128
|
+
|
|
129
|
+
api_messages: list[dict[str, Any]] = []
|
|
130
|
+
if system_prompt:
|
|
131
|
+
api_messages.append({"role": "system", "content": system_prompt})
|
|
132
|
+
api_messages.extend(messages)
|
|
133
|
+
|
|
134
|
+
kwargs: dict[str, Any] = {
|
|
135
|
+
"model": model,
|
|
136
|
+
"messages": api_messages,
|
|
137
|
+
"stream": stream,
|
|
138
|
+
}
|
|
139
|
+
if tools:
|
|
140
|
+
kwargs["tools"] = tools
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
if stream:
|
|
144
|
+
response = await client.chat.completions.create(**kwargs)
|
|
145
|
+
total_usage = TokenUsage()
|
|
146
|
+
async for chunk in response:
|
|
147
|
+
if chunk.choices:
|
|
148
|
+
delta = chunk.choices[0].delta
|
|
149
|
+
if delta.content:
|
|
150
|
+
yield TextDelta(content=delta.content)
|
|
151
|
+
if chunk.usage:
|
|
152
|
+
total_usage = TokenUsage(
|
|
153
|
+
input_tokens=chunk.usage.prompt_tokens or 0,
|
|
154
|
+
output_tokens=chunk.usage.completion_tokens or 0,
|
|
155
|
+
)
|
|
156
|
+
yield AgentDone(usage=total_usage)
|
|
157
|
+
self._last_limit_status = LimitStatus.OK
|
|
158
|
+
else:
|
|
159
|
+
response = await client.chat.completions.create(**kwargs)
|
|
160
|
+
msg = response.choices[0].message
|
|
161
|
+
if msg.content:
|
|
162
|
+
yield TextDelta(content=msg.content)
|
|
163
|
+
usage = response.usage
|
|
164
|
+
yield AgentDone(
|
|
165
|
+
usage=TokenUsage(
|
|
166
|
+
input_tokens=usage.prompt_tokens if usage else 0,
|
|
167
|
+
output_tokens=usage.completion_tokens if usage else 0,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
self._last_limit_status = LimitStatus.OK
|
|
171
|
+
|
|
172
|
+
except openai.RateLimitError as e:
|
|
173
|
+
self._last_limit_status = LimitStatus.RATE_LIMITED
|
|
174
|
+
yield LimitHit(error_type=LimitStatus.RATE_LIMITED, message=str(e))
|
|
175
|
+
except Exception as e:
|
|
176
|
+
yield LimitHit(error_type=LimitStatus.UNKNOWN, message=str(e))
|
|
177
|
+
|
|
178
|
+
def check_limits(self) -> LimitStatus:
|
|
179
|
+
if not self.is_configured():
|
|
180
|
+
return LimitStatus.NO_KEY
|
|
181
|
+
return self._last_limit_status if self._last_limit_status != LimitStatus.UNKNOWN else LimitStatus.OK
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ollama agent adapter.
|
|
3
|
+
|
|
4
|
+
Local offline models with unlimited quota, zero cost, and full privacy.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import shutil
|
|
11
|
+
import urllib.request
|
|
12
|
+
import urllib.error
|
|
13
|
+
from typing import Any, AsyncIterator
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from thwip.agents.base import (
|
|
18
|
+
AgentDone,
|
|
19
|
+
AgentEvent,
|
|
20
|
+
BaseAgent,
|
|
21
|
+
Capability,
|
|
22
|
+
LimitStatus,
|
|
23
|
+
ModelInfo,
|
|
24
|
+
SubscriptionInfo,
|
|
25
|
+
SubscriptionTier,
|
|
26
|
+
TextDelta,
|
|
27
|
+
TokenUsage,
|
|
28
|
+
ToolUseStart,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OllamaAgent(BaseAgent):
|
|
33
|
+
"""
|
|
34
|
+
Ollama Local Agent.
|
|
35
|
+
|
|
36
|
+
Runs entirely on-device with zero rate limits and unlimited usage.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
name = "ollama"
|
|
40
|
+
display_name = "Ollama (Local)"
|
|
41
|
+
company = "Ollama"
|
|
42
|
+
description = "Local models running entirely on-device (zero API limits, private)"
|
|
43
|
+
website = "https://ollama.com"
|
|
44
|
+
|
|
45
|
+
capabilities = {
|
|
46
|
+
Capability.CHAT,
|
|
47
|
+
Capability.FILE_EDIT,
|
|
48
|
+
Capability.FILE_READ,
|
|
49
|
+
Capability.CODE_RUN,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def __init__(self, host: str = "http://localhost:11434") -> None:
|
|
53
|
+
self.host = host.rstrip("/")
|
|
54
|
+
self._cached_models: list[ModelInfo] | None = None
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def available_models(self) -> list[ModelInfo]:
|
|
58
|
+
if self._cached_models is not None:
|
|
59
|
+
return self._cached_models
|
|
60
|
+
|
|
61
|
+
# Try to query running Ollama server for downloaded models
|
|
62
|
+
models = []
|
|
63
|
+
try:
|
|
64
|
+
req = urllib.request.Request(f"{self.host}/api/tags")
|
|
65
|
+
with urllib.request.urlopen(req, timeout=1.5) as resp:
|
|
66
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
67
|
+
for m in data.get("models", []):
|
|
68
|
+
name = m.get("name", "")
|
|
69
|
+
models.append(
|
|
70
|
+
ModelInfo(
|
|
71
|
+
id=name,
|
|
72
|
+
name=f"{name} (Local)",
|
|
73
|
+
context_window=32_768,
|
|
74
|
+
max_output=8_192,
|
|
75
|
+
supports_tools=True,
|
|
76
|
+
supports_streaming=True,
|
|
77
|
+
pricing_input=0.0,
|
|
78
|
+
pricing_output=0.0,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
if not models:
|
|
85
|
+
# Defaults
|
|
86
|
+
models = [
|
|
87
|
+
ModelInfo(id="llama3.3", name="Llama 3.3", is_default=True),
|
|
88
|
+
ModelInfo(id="qwen2.5-coder", name="Qwen 2.5 Coder"),
|
|
89
|
+
ModelInfo(id="deepseek-r1", name="DeepSeek R1 Distill"),
|
|
90
|
+
ModelInfo(id="codellama", name="CodeLlama"),
|
|
91
|
+
]
|
|
92
|
+
self._cached_models = models
|
|
93
|
+
return models
|
|
94
|
+
|
|
95
|
+
def is_installed(self) -> bool:
|
|
96
|
+
return shutil.which("ollama") is not None or self._is_server_reachable()
|
|
97
|
+
|
|
98
|
+
def _is_server_reachable(self) -> bool:
|
|
99
|
+
try:
|
|
100
|
+
req = urllib.request.Request(f"{self.host}/api/tags")
|
|
101
|
+
with urllib.request.urlopen(req, timeout=1.0) as resp:
|
|
102
|
+
return resp.status == 200
|
|
103
|
+
except Exception:
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
def is_configured(self) -> bool:
|
|
107
|
+
return self._is_server_reachable()
|
|
108
|
+
|
|
109
|
+
def get_install_info(self) -> dict[str, str]:
|
|
110
|
+
path = shutil.which("ollama") or ""
|
|
111
|
+
return {
|
|
112
|
+
"method": "CLI / Local Daemon" if path else "Local Server",
|
|
113
|
+
"path": path or self.host,
|
|
114
|
+
"version": "Local",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
def get_subscription_info(self) -> SubscriptionInfo:
|
|
118
|
+
return SubscriptionInfo(
|
|
119
|
+
tier=SubscriptionTier.UNLIMITED,
|
|
120
|
+
is_active=self.is_configured(),
|
|
121
|
+
message="Unlimited offline local compute",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
async def chat(
|
|
125
|
+
self,
|
|
126
|
+
messages: list[dict[str, Any]],
|
|
127
|
+
model: str | None = None,
|
|
128
|
+
system_prompt: str | None = None,
|
|
129
|
+
tools: list[dict[str, Any]] | None = None,
|
|
130
|
+
stream: bool = True,
|
|
131
|
+
) -> AsyncIterator[AgentEvent]:
|
|
132
|
+
model = model or self.get_default_model()
|
|
133
|
+
formatted_messages = []
|
|
134
|
+
if system_prompt:
|
|
135
|
+
formatted_messages.append({"role": "system", "content": system_prompt})
|
|
136
|
+
formatted_messages.extend(messages)
|
|
137
|
+
|
|
138
|
+
payload: dict[str, Any] = {
|
|
139
|
+
"model": model,
|
|
140
|
+
"messages": formatted_messages,
|
|
141
|
+
"stream": stream,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
145
|
+
if stream:
|
|
146
|
+
async with client.stream(
|
|
147
|
+
"POST", f"{self.host}/api/chat", json=payload
|
|
148
|
+
) as resp:
|
|
149
|
+
if resp.status_code != 200:
|
|
150
|
+
yield AgentDone()
|
|
151
|
+
return
|
|
152
|
+
async for line in resp.aiter_lines():
|
|
153
|
+
if not line:
|
|
154
|
+
continue
|
|
155
|
+
try:
|
|
156
|
+
data = json.loads(line)
|
|
157
|
+
msg = data.get("message", {})
|
|
158
|
+
content = msg.get("content", "")
|
|
159
|
+
if content:
|
|
160
|
+
yield TextDelta(content=content)
|
|
161
|
+
if data.get("done"):
|
|
162
|
+
prompt_eval = data.get("prompt_eval_count", 0)
|
|
163
|
+
eval_count = data.get("eval_count", 0)
|
|
164
|
+
yield AgentDone(
|
|
165
|
+
usage=TokenUsage(
|
|
166
|
+
input_tokens=prompt_eval,
|
|
167
|
+
output_tokens=eval_count,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
except json.JSONDecodeError:
|
|
171
|
+
continue
|
|
172
|
+
else:
|
|
173
|
+
resp = await client.post(f"{self.host}/api/chat", json=payload)
|
|
174
|
+
if resp.status_code == 200:
|
|
175
|
+
data = resp.json()
|
|
176
|
+
yield TextDelta(content=data.get("message", {}).get("content", ""))
|
|
177
|
+
yield AgentDone()
|
|
178
|
+
|
|
179
|
+
def check_limits(self) -> LimitStatus:
|
|
180
|
+
return LimitStatus.OK if self.is_configured() else LimitStatus.NO_KEY
|