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,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