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,338 @@
1
+ """
2
+ OpenAI / Codex agent adapter.
3
+
4
+ Full capabilities: chat, file editing, code execution (sandboxed).
5
+ Detects Codex CLI, OpenAI 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 OpenAIAgent(BaseAgent):
35
+ """
36
+ OpenAI Codex / ChatGPT: coding agent.
37
+
38
+ Capabilities: Chat, file editing, code execution (sandboxed).
39
+ Uses the OpenAI Python SDK.
40
+ """
41
+
42
+ name = "openai"
43
+ display_name = "Codex / OpenAI"
44
+ company = "OpenAI"
45
+ description = "OpenAI's coding agent with sandboxed code execution"
46
+ website = "https://platform.openai.com"
47
+
48
+ capabilities = {
49
+ Capability.CHAT,
50
+ Capability.FILE_EDIT,
51
+ Capability.FILE_READ,
52
+ Capability.CODE_RUN,
53
+ Capability.TERMINAL,
54
+ Capability.IMAGE_GEN,
55
+ Capability.SEARCH,
56
+ }
57
+
58
+ available_models = [
59
+ ModelInfo(
60
+ id="gpt-4.1",
61
+ name="GPT-4.1",
62
+ context_window=1_047_576,
63
+ max_output=32_768,
64
+ supports_tools=True,
65
+ supports_streaming=True,
66
+ supports_vision=True,
67
+ is_default=True,
68
+ pricing_input=2.0,
69
+ pricing_output=8.0,
70
+ ),
71
+ ModelInfo(
72
+ id="o3",
73
+ name="O3",
74
+ context_window=200_000,
75
+ max_output=100_000,
76
+ supports_tools=True,
77
+ supports_streaming=True,
78
+ supports_thinking=True,
79
+ pricing_input=2.0,
80
+ pricing_output=8.0,
81
+ ),
82
+ ModelInfo(
83
+ id="o4-mini",
84
+ name="O4 Mini",
85
+ context_window=200_000,
86
+ max_output=100_000,
87
+ supports_tools=True,
88
+ supports_streaming=True,
89
+ supports_thinking=True,
90
+ pricing_input=1.10,
91
+ pricing_output=4.40,
92
+ ),
93
+ ModelInfo(
94
+ id="gpt-4o",
95
+ name="GPT-4o",
96
+ context_window=128_000,
97
+ max_output=16_384,
98
+ supports_tools=True,
99
+ supports_streaming=True,
100
+ supports_vision=True,
101
+ pricing_input=2.50,
102
+ pricing_output=10.0,
103
+ ),
104
+ ModelInfo(
105
+ id="codex-mini",
106
+ name="Codex Mini",
107
+ context_window=200_000,
108
+ max_output=16_384,
109
+ supports_tools=True,
110
+ supports_streaming=True,
111
+ pricing_input=1.50,
112
+ pricing_output=6.0,
113
+ ),
114
+ ]
115
+
116
+ def __init__(self, api_key: str | None = None) -> None:
117
+ self._api_key = api_key
118
+ self._client = None
119
+ self._last_limit_status = LimitStatus.UNKNOWN
120
+
121
+ def _get_api_key(self) -> str | None:
122
+ if self._api_key:
123
+ return self._api_key
124
+
125
+ key = os.environ.get("OPENAI_API_KEY", "").strip()
126
+ if key:
127
+ return key
128
+
129
+ config_paths = [
130
+ Path.home() / ".config" / "openai" / "config.json",
131
+ Path.home() / ".openai" / "config.json",
132
+ ]
133
+ for p in config_paths:
134
+ if p.is_file():
135
+ try:
136
+ data = json.loads(p.read_text())
137
+ k = data.get("api_key") or data.get("apiKey") or ""
138
+ if k:
139
+ return k
140
+ except (json.JSONDecodeError, OSError):
141
+ continue
142
+
143
+ return None
144
+
145
+ def _ensure_client(self) -> Any:
146
+ if self._client is None:
147
+ try:
148
+ from openai import AsyncOpenAI
149
+ key = self._get_api_key()
150
+ self._client = AsyncOpenAI(api_key=key) if key else AsyncOpenAI()
151
+ except ImportError:
152
+ raise RuntimeError(
153
+ "openai package not installed. Run: pip install openai"
154
+ )
155
+ return self._client
156
+
157
+ # --- Detection ---
158
+
159
+ def is_installed(self) -> bool:
160
+ return self.is_configured() or any(
161
+ shutil.which(cmd) is not None
162
+ for cmd in ("codex", "openai")
163
+ )
164
+
165
+ def is_configured(self) -> bool:
166
+ return bool(self._get_api_key())
167
+
168
+ def get_install_info(self) -> dict[str, str]:
169
+ info: dict[str, str] = {"method": "not installed", "path": "", "version": ""}
170
+
171
+ for cmd in ("codex", "openai"):
172
+ path = shutil.which(cmd)
173
+ if path:
174
+ info["path"] = path
175
+ info["method"] = "npm global" if cmd == "codex" else "pip"
176
+ try:
177
+ result = subprocess.run(
178
+ [cmd, "--version"],
179
+ capture_output=True, text=True, timeout=5,
180
+ )
181
+ if result.returncode == 0:
182
+ info["version"] = result.stdout.strip().split("\n")[0]
183
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
184
+ pass
185
+ break
186
+
187
+ return info
188
+
189
+ def get_subscription_info(self) -> SubscriptionInfo:
190
+ key = self._get_api_key()
191
+ if not key:
192
+ return SubscriptionInfo(
193
+ tier=SubscriptionTier.UNKNOWN,
194
+ is_active=False,
195
+ message="No API key found",
196
+ )
197
+
198
+ if key.startswith("sk-"):
199
+ return SubscriptionInfo(
200
+ tier=SubscriptionTier.PRO,
201
+ is_active=True,
202
+ message="OpenAI API key detected",
203
+ )
204
+
205
+ return SubscriptionInfo(
206
+ tier=SubscriptionTier.UNKNOWN,
207
+ is_active=True,
208
+ message="API key detected",
209
+ )
210
+
211
+ # --- Chat ---
212
+
213
+ async def chat(
214
+ self,
215
+ messages: list[dict[str, Any]],
216
+ model: str | None = None,
217
+ system_prompt: str | None = None,
218
+ tools: list[dict[str, Any]] | None = None,
219
+ stream: bool = True,
220
+ ) -> AsyncIterator[AgentEvent]:
221
+ """Stream a chat response from OpenAI."""
222
+ import openai
223
+
224
+ client = self._ensure_client()
225
+ model = model or self.get_default_model()
226
+
227
+ # Build messages with system prompt
228
+ api_messages: list[dict[str, Any]] = []
229
+ if system_prompt:
230
+ api_messages.append({"role": "system", "content": system_prompt})
231
+ api_messages.extend(messages)
232
+
233
+ kwargs: dict[str, Any] = {
234
+ "model": model,
235
+ "messages": api_messages,
236
+ }
237
+
238
+ if tools:
239
+ kwargs["tools"] = tools
240
+
241
+ # Reasoning models use different params
242
+ model_info = self.get_model_info(model)
243
+ if model_info and model_info.supports_thinking:
244
+ kwargs["max_completion_tokens"] = 100_000
245
+ else:
246
+ kwargs["max_tokens"] = 16_384
247
+
248
+ try:
249
+ if stream:
250
+ response = await client.chat.completions.create(stream=True, **kwargs)
251
+
252
+ collected_content = ""
253
+ total_usage = TokenUsage()
254
+
255
+ async for chunk in response:
256
+ if chunk.choices:
257
+ delta = chunk.choices[0].delta
258
+ if delta.content:
259
+ yield TextDelta(content=delta.content)
260
+ collected_content += delta.content
261
+ # Tool calls
262
+ if delta.tool_calls:
263
+ for tc in delta.tool_calls:
264
+ if tc.function:
265
+ yield ToolUseStart(
266
+ tool_id=tc.id or "",
267
+ tool_name=tc.function.name or "",
268
+ args=json.loads(tc.function.arguments) if tc.function.arguments else {},
269
+ )
270
+
271
+ if chunk.usage:
272
+ total_usage = TokenUsage(
273
+ input_tokens=chunk.usage.prompt_tokens or 0,
274
+ output_tokens=chunk.usage.completion_tokens or 0,
275
+ )
276
+
277
+ yield AgentDone(usage=total_usage)
278
+ self._last_limit_status = LimitStatus.OK
279
+
280
+ else:
281
+ response = await client.chat.completions.create(**kwargs)
282
+
283
+ if response.choices:
284
+ msg = response.choices[0].message
285
+ if msg.content:
286
+ yield TextDelta(content=msg.content)
287
+ if msg.tool_calls:
288
+ for tc in msg.tool_calls:
289
+ yield ToolUseStart(
290
+ tool_id=tc.id,
291
+ tool_name=tc.function.name,
292
+ args=json.loads(tc.function.arguments) if tc.function.arguments else {},
293
+ )
294
+
295
+ usage = response.usage
296
+ yield AgentDone(
297
+ usage=TokenUsage(
298
+ input_tokens=usage.prompt_tokens if usage else 0,
299
+ output_tokens=usage.completion_tokens if usage else 0,
300
+ ),
301
+ stop_reason=response.choices[0].finish_reason if response.choices else "stop",
302
+ )
303
+ self._last_limit_status = LimitStatus.OK
304
+
305
+ except openai.RateLimitError as e:
306
+ self._last_limit_status = LimitStatus.RATE_LIMITED
307
+ retry_after = None
308
+ if hasattr(e, "response") and e.response:
309
+ ra = e.response.headers.get("retry-after")
310
+ if ra:
311
+ try:
312
+ retry_after = float(ra)
313
+ except ValueError:
314
+ pass
315
+ yield LimitHit(
316
+ error_type=LimitStatus.RATE_LIMITED,
317
+ retry_after=retry_after,
318
+ message=str(e),
319
+ )
320
+
321
+ except openai.APIStatusError as e:
322
+ error_str = str(e).lower()
323
+ if "insufficient_quota" in error_str or "quota" in error_str:
324
+ self._last_limit_status = LimitStatus.QUOTA_EXHAUSTED
325
+ yield LimitHit(
326
+ error_type=LimitStatus.QUOTA_EXHAUSTED,
327
+ message=str(e),
328
+ )
329
+ else:
330
+ yield LimitHit(
331
+ error_type=LimitStatus.UNKNOWN,
332
+ message=str(e),
333
+ )
334
+
335
+ def check_limits(self) -> LimitStatus:
336
+ if not self.is_configured():
337
+ return LimitStatus.NO_KEY
338
+ return self._last_limit_status if self._last_limit_status != LimitStatus.UNKNOWN else LimitStatus.OK
@@ -0,0 +1,192 @@
1
+ """
2
+ OpenRouter meta-agent adapter.
3
+
4
+ Access to over 100+ models from multiple companies through a single unified key.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from typing import Any, AsyncIterator
11
+
12
+ from thwip.agents.base import (
13
+ AgentDone,
14
+ AgentEvent,
15
+ BaseAgent,
16
+ Capability,
17
+ LimitHit,
18
+ LimitStatus,
19
+ ModelInfo,
20
+ SubscriptionInfo,
21
+ SubscriptionTier,
22
+ TextDelta,
23
+ TokenUsage,
24
+ ToolUseStart,
25
+ )
26
+
27
+
28
+ class OpenRouterAgent(BaseAgent):
29
+ """
30
+ OpenRouter Unified Gateway Agent.
31
+
32
+ Routes to any model from Anthropic, OpenAI, Meta, Google, Mistral, etc.
33
+ """
34
+
35
+ name = "openrouter"
36
+ display_name = "OpenRouter"
37
+ company = "OpenRouter"
38
+ description = "Universal LLM gateway routing to 100+ models"
39
+ website = "https://openrouter.ai"
40
+
41
+ capabilities = {
42
+ Capability.CHAT,
43
+ Capability.FILE_EDIT,
44
+ Capability.FILE_READ,
45
+ Capability.CODE_RUN,
46
+ Capability.SEARCH,
47
+ }
48
+
49
+ available_models = [
50
+ ModelInfo(
51
+ id="anthropic/claude-3.7-sonnet",
52
+ name="Claude 3.7 Sonnet (via OpenRouter)",
53
+ context_window=200_000,
54
+ is_default=True,
55
+ pricing_input=3.0,
56
+ pricing_output=15.0,
57
+ ),
58
+ ModelInfo(
59
+ id="openai/gpt-4o",
60
+ name="GPT-4o (via OpenRouter)",
61
+ context_window=128_000,
62
+ pricing_input=2.5,
63
+ pricing_output=10.0,
64
+ ),
65
+ ModelInfo(
66
+ id="deepseek/deepseek-r1",
67
+ name="DeepSeek R1 (via OpenRouter)",
68
+ context_window=64_000,
69
+ pricing_input=0.55,
70
+ pricing_output=2.19,
71
+ ),
72
+ ModelInfo(
73
+ id="google/gemini-2.0-flash-001",
74
+ name="Gemini 2.0 Flash (via OpenRouter)",
75
+ context_window=1_000_000,
76
+ pricing_input=0.1,
77
+ pricing_output=0.4,
78
+ ),
79
+ ]
80
+
81
+ def __init__(self, api_key: str | None = None) -> None:
82
+ self._api_key = api_key
83
+ self._client = None
84
+ self._last_limit_status = LimitStatus.UNKNOWN
85
+
86
+ def _get_api_key(self) -> str | None:
87
+ if self._api_key:
88
+ return self._api_key
89
+ return os.environ.get("OPENROUTER_API_KEY", "").strip() or None
90
+
91
+ def _ensure_client(self) -> Any:
92
+ if self._client is None:
93
+ try:
94
+ from openai import AsyncOpenAI
95
+ key = self._get_api_key()
96
+ self._client = AsyncOpenAI(
97
+ api_key=key or "dummy",
98
+ base_url="https://openrouter.ai/api/v1",
99
+ default_headers={
100
+ "HTTP-Referer": "https://github.com/thwip-cli/thwip",
101
+ "X-Title": "thwip",
102
+ },
103
+ )
104
+ except ImportError:
105
+ raise RuntimeError("openai package required for OpenRouter adapter.")
106
+ return self._client
107
+
108
+ def is_installed(self) -> bool:
109
+ return self.is_configured()
110
+
111
+ def is_configured(self) -> bool:
112
+ return bool(self._get_api_key())
113
+
114
+ def get_install_info(self) -> dict[str, str]:
115
+ return {
116
+ "method": "API Gateway",
117
+ "path": "https://openrouter.ai",
118
+ "version": "v1",
119
+ }
120
+
121
+ def get_subscription_info(self) -> SubscriptionInfo:
122
+ key = self._get_api_key()
123
+ return SubscriptionInfo(
124
+ tier=SubscriptionTier.PRO if key else SubscriptionTier.UNKNOWN,
125
+ is_active=bool(key),
126
+ message="OpenRouter account ready" if key else "No API key",
127
+ )
128
+
129
+ async def chat(
130
+ self,
131
+ messages: list[dict[str, Any]],
132
+ model: str | None = None,
133
+ system_prompt: str | None = None,
134
+ tools: list[dict[str, Any]] | None = None,
135
+ stream: bool = True,
136
+ ) -> AsyncIterator[AgentEvent]:
137
+ import openai
138
+
139
+ client = self._ensure_client()
140
+ model = model or self.get_default_model()
141
+
142
+ api_messages: list[dict[str, Any]] = []
143
+ if system_prompt:
144
+ api_messages.append({"role": "system", "content": system_prompt})
145
+ api_messages.extend(messages)
146
+
147
+ kwargs: dict[str, Any] = {
148
+ "model": model,
149
+ "messages": api_messages,
150
+ "stream": stream,
151
+ }
152
+
153
+ try:
154
+ if stream:
155
+ response = await client.chat.completions.create(**kwargs)
156
+ total_usage = TokenUsage()
157
+ async for chunk in response:
158
+ if chunk.choices:
159
+ delta = chunk.choices[0].delta
160
+ if delta.content:
161
+ yield TextDelta(content=delta.content)
162
+ if chunk.usage:
163
+ total_usage = TokenUsage(
164
+ input_tokens=chunk.usage.prompt_tokens or 0,
165
+ output_tokens=chunk.usage.completion_tokens or 0,
166
+ )
167
+ yield AgentDone(usage=total_usage)
168
+ self._last_limit_status = LimitStatus.OK
169
+ else:
170
+ response = await client.chat.completions.create(**kwargs)
171
+ msg = response.choices[0].message
172
+ if msg.content:
173
+ yield TextDelta(content=msg.content)
174
+ usage = response.usage
175
+ yield AgentDone(
176
+ usage=TokenUsage(
177
+ input_tokens=usage.prompt_tokens if usage else 0,
178
+ output_tokens=usage.completion_tokens if usage else 0,
179
+ )
180
+ )
181
+ self._last_limit_status = LimitStatus.OK
182
+
183
+ except openai.RateLimitError as e:
184
+ self._last_limit_status = LimitStatus.RATE_LIMITED
185
+ yield LimitHit(error_type=LimitStatus.RATE_LIMITED, message=str(e))
186
+ except Exception as e:
187
+ yield LimitHit(error_type=LimitStatus.UNKNOWN, message=str(e))
188
+
189
+ def check_limits(self) -> LimitStatus:
190
+ if not self.is_configured():
191
+ return LimitStatus.NO_KEY
192
+ return self._last_limit_status if self._last_limit_status != LimitStatus.UNKNOWN else LimitStatus.OK