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