vtx-coding-agent 0.1.1__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.
- vtx/__init__.py +63 -0
- vtx/async_utils.py +40 -0
- vtx/builtin_skills/github/SKILL.md +139 -0
- vtx/builtin_skills/init/SKILL.md +74 -0
- vtx/builtin_skills/review/SKILL.md +73 -0
- vtx/builtin_skills/skill-builder/SKILL.md +133 -0
- vtx/cli.py +90 -0
- vtx/config.py +741 -0
- vtx/context/__init__.py +15 -0
- vtx/context/_xml.py +8 -0
- vtx/context/agent_mds.py +128 -0
- vtx/context/git.py +64 -0
- vtx/context/loader.py +41 -0
- vtx/context/skills.py +423 -0
- vtx/core/__init__.py +47 -0
- vtx/core/compaction.py +89 -0
- vtx/core/errors.py +17 -0
- vtx/core/handoff.py +51 -0
- vtx/core/scratchpad.py +54 -0
- vtx/core/types.py +197 -0
- vtx/defaults/__init__.py +0 -0
- vtx/defaults/config.yml +53 -0
- vtx/diff_display.py +12 -0
- vtx/events.py +224 -0
- vtx/gh_cli.py +82 -0
- vtx/git_branch.py +90 -0
- vtx/headless.py +127 -0
- vtx/llm/__init__.py +93 -0
- vtx/llm/base.py +217 -0
- vtx/llm/context_length.py +150 -0
- vtx/llm/dynamic_models.py +735 -0
- vtx/llm/model_fetcher.py +279 -0
- vtx/llm/models.py +78 -0
- vtx/llm/oauth/__init__.py +59 -0
- vtx/llm/oauth/copilot.py +358 -0
- vtx/llm/oauth/dynamic.py +236 -0
- vtx/llm/oauth/openai.py +400 -0
- vtx/llm/phase_parser.py +270 -0
- vtx/llm/provider.yaml +280 -0
- vtx/llm/provider_catalog.py +230 -0
- vtx/llm/providers/__init__.py +45 -0
- vtx/llm/providers/anthropic_sdk.py +256 -0
- vtx/llm/providers/mock.py +249 -0
- vtx/llm/providers/openai_sdk.py +246 -0
- vtx/llm/providers/sanitize.py +14 -0
- vtx/llm/sdk/__init__.py +13 -0
- vtx/llm/sdk/anthropic.py +382 -0
- vtx/llm/sdk/base.py +82 -0
- vtx/llm/sdk/openai.py +344 -0
- vtx/llm/tool_parser.py +161 -0
- vtx/loop.py +272 -0
- vtx/notify.py +109 -0
- vtx/permissions.py +114 -0
- vtx/prompts/__init__.py +45 -0
- vtx/prompts/builder.py +86 -0
- vtx/prompts/env.py +58 -0
- vtx/prompts/identity.py +166 -0
- vtx/prompts/tooling.py +36 -0
- vtx/py.typed +0 -0
- vtx/runtime.py +580 -0
- vtx/session.py +868 -0
- vtx/sounds/completion.wav +0 -0
- vtx/sounds/error.wav +0 -0
- vtx/sounds/permission.wav +0 -0
- vtx/themes.py +1104 -0
- vtx/tools/__init__.py +68 -0
- vtx/tools/_read_image.py +106 -0
- vtx/tools/_tool_utils.py +90 -0
- vtx/tools/base.py +36 -0
- vtx/tools/bash.py +371 -0
- vtx/tools/edit.py +261 -0
- vtx/tools/find.py +132 -0
- vtx/tools/read.py +238 -0
- vtx/tools/skill.py +278 -0
- vtx/tools/web.py +238 -0
- vtx/tools/write.py +88 -0
- vtx/tools_manager.py +216 -0
- vtx/turn.py +789 -0
- vtx/ui/__init__.py +0 -0
- vtx/ui/agent_runner.py +417 -0
- vtx/ui/app.py +665 -0
- vtx/ui/app_protocol.py +29 -0
- vtx/ui/autocomplete.py +440 -0
- vtx/ui/blocks.py +735 -0
- vtx/ui/chat.py +613 -0
- vtx/ui/clipboard.py +59 -0
- vtx/ui/commands/__init__.py +100 -0
- vtx/ui/commands/auth.py +306 -0
- vtx/ui/commands/base.py +122 -0
- vtx/ui/commands/models.py +144 -0
- vtx/ui/commands/sessions.py +388 -0
- vtx/ui/commands/settings.py +286 -0
- vtx/ui/completion_ui.py +313 -0
- vtx/ui/export.py +703 -0
- vtx/ui/floating_list.py +370 -0
- vtx/ui/formatting.py +287 -0
- vtx/ui/input.py +760 -0
- vtx/ui/latex.py +349 -0
- vtx/ui/launch.py +108 -0
- vtx/ui/path_complete.py +228 -0
- vtx/ui/prompt_history.py +102 -0
- vtx/ui/queue_ui.py +141 -0
- vtx/ui/selection_mode.py +18 -0
- vtx/ui/session_ui.py +235 -0
- vtx/ui/startup.py +124 -0
- vtx/ui/styles.py +327 -0
- vtx/ui/tool_output.py +34 -0
- vtx/ui/tree.py +437 -0
- vtx/ui/welcome.py +51 -0
- vtx/ui/widgets.py +558 -0
- vtx/update_check.py +49 -0
- vtx/version.py +22 -0
- vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
- vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
- vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
- vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
- vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
vtx/llm/oauth/openai.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenAI OAuth flow (ChatGPT/Codex-style OAuth).
|
|
3
|
+
|
|
4
|
+
Stores OAuth credentials locally and provides token refresh support.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import base64
|
|
9
|
+
import contextlib
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import secrets
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
18
|
+
|
|
19
|
+
import aiohttp
|
|
20
|
+
|
|
21
|
+
from vtx import get_config_dir
|
|
22
|
+
|
|
23
|
+
_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
|
24
|
+
_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize"
|
|
25
|
+
_TOKEN_URL = "https://auth.openai.com/oauth/token"
|
|
26
|
+
_REDIRECT_URI = "http://localhost:1455/auth/callback"
|
|
27
|
+
_SCOPE = "openid profile email offline_access"
|
|
28
|
+
_JWT_CLAIM_PATH = "https://api.openai.com/auth"
|
|
29
|
+
_SUCCESS_HTML = """<!doctype html>
|
|
30
|
+
<html lang=\"en\">
|
|
31
|
+
<head><meta charset=\"utf-8\" /><title>Authentication successful</title></head>
|
|
32
|
+
<body><p>Authentication successful. Return to your terminal to continue.</p></body>
|
|
33
|
+
</html>"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class OpenAICredentials:
|
|
38
|
+
refresh: str
|
|
39
|
+
access: str
|
|
40
|
+
expires: int
|
|
41
|
+
account_id: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_openai_auth_path() -> Path:
|
|
45
|
+
return get_config_dir() / "openai_auth.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_openai_credentials() -> OpenAICredentials | None:
|
|
49
|
+
path = get_openai_auth_path()
|
|
50
|
+
if not path.exists():
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
data = json.loads(path.read_text())
|
|
55
|
+
return OpenAICredentials(
|
|
56
|
+
refresh=data["refresh"],
|
|
57
|
+
access=data["access"],
|
|
58
|
+
expires=data["expires"],
|
|
59
|
+
account_id=data["account_id"],
|
|
60
|
+
)
|
|
61
|
+
except (json.JSONDecodeError, KeyError):
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def save_openai_credentials(creds: OpenAICredentials) -> None:
|
|
66
|
+
path = get_openai_auth_path()
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
path.write_text(
|
|
69
|
+
json.dumps(
|
|
70
|
+
{
|
|
71
|
+
"refresh": creds.refresh,
|
|
72
|
+
"access": creds.access,
|
|
73
|
+
"expires": creds.expires,
|
|
74
|
+
"account_id": creds.account_id,
|
|
75
|
+
},
|
|
76
|
+
indent=2,
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
path.chmod(0o600)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def clear_openai_credentials() -> None:
|
|
83
|
+
path = get_openai_auth_path()
|
|
84
|
+
if path.exists():
|
|
85
|
+
path.unlink()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_openai_logged_in() -> bool:
|
|
89
|
+
return load_openai_credentials() is not None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _base64url_encode(data: bytes) -> str:
|
|
93
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _generate_pkce() -> tuple[str, str]:
|
|
97
|
+
verifier = _base64url_encode(secrets.token_bytes(32))
|
|
98
|
+
challenge = _base64url_encode(hashlib.sha256(verifier.encode()).digest())
|
|
99
|
+
return verifier, challenge
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _create_state() -> str:
|
|
103
|
+
return secrets.token_hex(16)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _decode_jwt_payload(token: str) -> dict[str, Any] | None:
|
|
107
|
+
try:
|
|
108
|
+
parts = token.split(".")
|
|
109
|
+
if len(parts) != 3:
|
|
110
|
+
return None
|
|
111
|
+
payload = parts[1]
|
|
112
|
+
if payload is None:
|
|
113
|
+
return None
|
|
114
|
+
padded = payload + "=" * (-len(payload) % 4)
|
|
115
|
+
decoded = base64.urlsafe_b64decode(padded.encode()).decode()
|
|
116
|
+
return json.loads(decoded)
|
|
117
|
+
except Exception:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _extract_account_id(access_token: str) -> str | None:
|
|
122
|
+
payload = _decode_jwt_payload(access_token)
|
|
123
|
+
if not payload:
|
|
124
|
+
return None
|
|
125
|
+
auth = payload.get(_JWT_CLAIM_PATH)
|
|
126
|
+
if not isinstance(auth, dict):
|
|
127
|
+
return None
|
|
128
|
+
account_id = auth.get("chatgpt_account_id")
|
|
129
|
+
return account_id if isinstance(account_id, str) and account_id else None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _build_authorize_url(code_challenge: str, state: str, originator: str) -> str:
|
|
133
|
+
query = urlencode(
|
|
134
|
+
{
|
|
135
|
+
"response_type": "code",
|
|
136
|
+
"client_id": _CLIENT_ID,
|
|
137
|
+
"redirect_uri": _REDIRECT_URI,
|
|
138
|
+
"scope": _SCOPE,
|
|
139
|
+
"code_challenge": code_challenge,
|
|
140
|
+
"code_challenge_method": "S256",
|
|
141
|
+
"state": state,
|
|
142
|
+
"id_token_add_organizations": "true",
|
|
143
|
+
"codex_cli_simplified_flow": "true",
|
|
144
|
+
"originator": originator,
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
return f"{_AUTHORIZE_URL}?{query}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def _exchange_code_for_tokens(code: str, verifier: str) -> OpenAICredentials:
|
|
151
|
+
async with (
|
|
152
|
+
aiohttp.ClientSession() as session,
|
|
153
|
+
session.post(
|
|
154
|
+
_TOKEN_URL,
|
|
155
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
156
|
+
data={
|
|
157
|
+
"grant_type": "authorization_code",
|
|
158
|
+
"client_id": _CLIENT_ID,
|
|
159
|
+
"code": code,
|
|
160
|
+
"code_verifier": verifier,
|
|
161
|
+
"redirect_uri": _REDIRECT_URI,
|
|
162
|
+
},
|
|
163
|
+
) as response,
|
|
164
|
+
):
|
|
165
|
+
if response.status >= 400:
|
|
166
|
+
text = await response.text()
|
|
167
|
+
raise RuntimeError(f"OpenAI OAuth token exchange failed ({response.status}): {text}")
|
|
168
|
+
data = await response.json()
|
|
169
|
+
|
|
170
|
+
access = data.get("access_token")
|
|
171
|
+
refresh = data.get("refresh_token")
|
|
172
|
+
expires_in = data.get("expires_in")
|
|
173
|
+
if (
|
|
174
|
+
not isinstance(access, str)
|
|
175
|
+
or not isinstance(refresh, str)
|
|
176
|
+
or not isinstance(expires_in, int)
|
|
177
|
+
):
|
|
178
|
+
raise RuntimeError("OpenAI OAuth token response missing required fields")
|
|
179
|
+
|
|
180
|
+
account_id = _extract_account_id(access)
|
|
181
|
+
if not account_id:
|
|
182
|
+
raise RuntimeError("Failed to extract chatgpt_account_id from OpenAI OAuth token")
|
|
183
|
+
|
|
184
|
+
return OpenAICredentials(
|
|
185
|
+
access=access,
|
|
186
|
+
refresh=refresh,
|
|
187
|
+
expires=int(time.time() * 1000) + expires_in * 1000,
|
|
188
|
+
account_id=account_id,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def refresh_openai_token(creds: OpenAICredentials) -> OpenAICredentials:
|
|
193
|
+
async with (
|
|
194
|
+
aiohttp.ClientSession() as session,
|
|
195
|
+
session.post(
|
|
196
|
+
_TOKEN_URL,
|
|
197
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
198
|
+
data={
|
|
199
|
+
"grant_type": "refresh_token",
|
|
200
|
+
"refresh_token": creds.refresh,
|
|
201
|
+
"client_id": _CLIENT_ID,
|
|
202
|
+
},
|
|
203
|
+
) as response,
|
|
204
|
+
):
|
|
205
|
+
if response.status >= 400:
|
|
206
|
+
text = await response.text()
|
|
207
|
+
raise RuntimeError(f"OpenAI OAuth token refresh failed ({response.status}): {text}")
|
|
208
|
+
data = await response.json()
|
|
209
|
+
|
|
210
|
+
access = data.get("access_token")
|
|
211
|
+
refresh = data.get("refresh_token")
|
|
212
|
+
expires_in = data.get("expires_in")
|
|
213
|
+
if (
|
|
214
|
+
not isinstance(access, str)
|
|
215
|
+
or not isinstance(refresh, str)
|
|
216
|
+
or not isinstance(expires_in, int)
|
|
217
|
+
):
|
|
218
|
+
raise RuntimeError("OpenAI OAuth refresh response missing required fields")
|
|
219
|
+
|
|
220
|
+
account_id = _extract_account_id(access)
|
|
221
|
+
if not account_id:
|
|
222
|
+
raise RuntimeError("Failed to extract chatgpt_account_id from OpenAI OAuth token")
|
|
223
|
+
|
|
224
|
+
refreshed = OpenAICredentials(
|
|
225
|
+
access=access,
|
|
226
|
+
refresh=refresh,
|
|
227
|
+
expires=int(time.time() * 1000) + expires_in * 1000,
|
|
228
|
+
account_id=account_id,
|
|
229
|
+
)
|
|
230
|
+
save_openai_credentials(refreshed)
|
|
231
|
+
return refreshed
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def _start_callback_server(state: str) -> tuple[asyncio.AbstractServer, asyncio.Future[str]]:
|
|
235
|
+
loop = asyncio.get_running_loop()
|
|
236
|
+
code_future: asyncio.Future[str] = loop.create_future()
|
|
237
|
+
|
|
238
|
+
async def handler(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
239
|
+
try:
|
|
240
|
+
raw = await reader.read(4096)
|
|
241
|
+
request_line = raw.decode(errors="ignore").splitlines()[0] if raw else ""
|
|
242
|
+
parts = request_line.split()
|
|
243
|
+
if len(parts) < 2:
|
|
244
|
+
return
|
|
245
|
+
|
|
246
|
+
path = parts[1]
|
|
247
|
+
parsed = urlparse(path)
|
|
248
|
+
query = parse_qs(parsed.query)
|
|
249
|
+
|
|
250
|
+
if parsed.path != "/auth/callback":
|
|
251
|
+
writer.write(b"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\n\r\nNot found")
|
|
252
|
+
await writer.drain()
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
req_state = (query.get("state") or [None])[0]
|
|
256
|
+
code = (query.get("code") or [None])[0]
|
|
257
|
+
|
|
258
|
+
if req_state != state or not isinstance(code, str) or not code:
|
|
259
|
+
writer.write(
|
|
260
|
+
b"HTTP/1.1 400 Bad Request\r\nContent-Length: 14\r\n\r\nState mismatch"
|
|
261
|
+
)
|
|
262
|
+
await writer.drain()
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
body = _SUCCESS_HTML.encode()
|
|
266
|
+
writer.write(
|
|
267
|
+
b"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n"
|
|
268
|
+
+ f"Content-Length: {len(body)}\r\n\r\n".encode()
|
|
269
|
+
+ body
|
|
270
|
+
)
|
|
271
|
+
await writer.drain()
|
|
272
|
+
|
|
273
|
+
if not code_future.done():
|
|
274
|
+
code_future.set_result(code)
|
|
275
|
+
finally:
|
|
276
|
+
writer.close()
|
|
277
|
+
with contextlib.suppress(Exception):
|
|
278
|
+
await writer.wait_closed()
|
|
279
|
+
|
|
280
|
+
server = await asyncio.start_server(handler, "localhost", 1455)
|
|
281
|
+
return server, code_future
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _parse_manual_input(input_text: str) -> tuple[str | None, str | None]:
|
|
285
|
+
text = input_text.strip()
|
|
286
|
+
if not text:
|
|
287
|
+
return None, None
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
parsed = urlparse(text)
|
|
291
|
+
if parsed.scheme and parsed.netloc:
|
|
292
|
+
query = parse_qs(parsed.query)
|
|
293
|
+
return (query.get("code") or [None])[0], (query.get("state") or [None])[0]
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
|
|
297
|
+
if "code=" in text:
|
|
298
|
+
query = parse_qs(text)
|
|
299
|
+
return (query.get("code") or [None])[0], (query.get("state") or [None])[0]
|
|
300
|
+
|
|
301
|
+
if "#" in text:
|
|
302
|
+
code, st = text.split("#", 1)
|
|
303
|
+
return code or None, st or None
|
|
304
|
+
|
|
305
|
+
return text, None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
async def login(
|
|
309
|
+
on_auth_url: Any | None = None, on_manual_input: Any | None = None, originator: str = "vtx"
|
|
310
|
+
) -> OpenAICredentials:
|
|
311
|
+
verifier, challenge = _generate_pkce()
|
|
312
|
+
state = _create_state()
|
|
313
|
+
auth_url = _build_authorize_url(challenge, state, originator)
|
|
314
|
+
|
|
315
|
+
if on_auth_url:
|
|
316
|
+
on_auth_url(auth_url)
|
|
317
|
+
|
|
318
|
+
code: str | None = None
|
|
319
|
+
server: asyncio.AbstractServer | None = None
|
|
320
|
+
callback_awaitable: asyncio.Future[str] | None = None
|
|
321
|
+
manual_task: asyncio.Task[Any] | None = None
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
try:
|
|
325
|
+
server, callback_awaitable = await _start_callback_server(state)
|
|
326
|
+
except OSError:
|
|
327
|
+
callback_awaitable = None
|
|
328
|
+
|
|
329
|
+
if on_manual_input:
|
|
330
|
+
manual_task = asyncio.create_task(on_manual_input())
|
|
331
|
+
|
|
332
|
+
if not callback_awaitable and not manual_task:
|
|
333
|
+
raise RuntimeError(
|
|
334
|
+
"OpenAI OAuth failed: could not start callback server on port 1455 "
|
|
335
|
+
"and no manual input handler provided."
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if callback_awaitable and manual_task:
|
|
339
|
+
done, pending = await asyncio.wait(
|
|
340
|
+
{callback_awaitable, manual_task}, return_when=asyncio.FIRST_COMPLETED, timeout=300
|
|
341
|
+
)
|
|
342
|
+
for task in pending:
|
|
343
|
+
task.cancel()
|
|
344
|
+
|
|
345
|
+
if callback_awaitable in done:
|
|
346
|
+
code = callback_awaitable.result()
|
|
347
|
+
elif manual_task in done:
|
|
348
|
+
manual_input = manual_task.result()
|
|
349
|
+
parsed_code, parsed_state = _parse_manual_input(str(manual_input))
|
|
350
|
+
if parsed_state and parsed_state != state:
|
|
351
|
+
raise RuntimeError("OpenAI OAuth state mismatch")
|
|
352
|
+
code = parsed_code
|
|
353
|
+
|
|
354
|
+
elif callback_awaitable:
|
|
355
|
+
code = await asyncio.wait_for(callback_awaitable, timeout=300)
|
|
356
|
+
|
|
357
|
+
elif manual_task:
|
|
358
|
+
manual_input = await manual_task
|
|
359
|
+
parsed_code, parsed_state = _parse_manual_input(str(manual_input))
|
|
360
|
+
if parsed_state and parsed_state != state:
|
|
361
|
+
raise RuntimeError("OpenAI OAuth state mismatch")
|
|
362
|
+
code = parsed_code
|
|
363
|
+
|
|
364
|
+
if not code:
|
|
365
|
+
raise TimeoutError(
|
|
366
|
+
"OpenAI OAuth timed out waiting for authorization callback on port 1455."
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
creds = await _exchange_code_for_tokens(code, verifier)
|
|
370
|
+
save_openai_credentials(creds)
|
|
371
|
+
return creds
|
|
372
|
+
|
|
373
|
+
finally:
|
|
374
|
+
if callback_awaitable and not callback_awaitable.done():
|
|
375
|
+
callback_awaitable.cancel()
|
|
376
|
+
if manual_task and not manual_task.done():
|
|
377
|
+
manual_task.cancel()
|
|
378
|
+
if server:
|
|
379
|
+
server.close()
|
|
380
|
+
with contextlib.suppress(Exception):
|
|
381
|
+
await server.wait_closed()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
async def get_valid_openai_credentials() -> OpenAICredentials | None:
|
|
385
|
+
creds = load_openai_credentials()
|
|
386
|
+
if not creds:
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
if time.time() * 1000 >= creds.expires - 60_000:
|
|
390
|
+
try:
|
|
391
|
+
creds = await refresh_openai_token(creds)
|
|
392
|
+
except Exception:
|
|
393
|
+
return None
|
|
394
|
+
|
|
395
|
+
return creds
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
async def get_valid_openai_token() -> str | None:
|
|
399
|
+
creds = await get_valid_openai_credentials()
|
|
400
|
+
return creds.access if creds else None
|
vtx/llm/phase_parser.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Real-time streaming parser for ``<think>`` blocks embedded inside
|
|
3
|
+
``delta.content`` (DeepSeek R1, MiniMax M3, Qwen3, GLM, …).
|
|
4
|
+
|
|
5
|
+
These OpenAI-compat gateways follow the chat-completions spec but, unlike
|
|
6
|
+
OpenAI's own ``o1``/``o3`` series, they don't expose a separate
|
|
7
|
+
``reasoning_content`` field. They wrap their chain-of-thought inside
|
|
8
|
+
``<think>`` tags in the regular content stream.
|
|
9
|
+
|
|
10
|
+
If we let that through to the TUI's Rich-based markdown renderer,
|
|
11
|
+
``<think>`` is interpreted as the start of a raw HTML block, the entire
|
|
12
|
+
response gets swallowed, and the user sees an empty chat log. So we
|
|
13
|
+
have to detect and split the blocks out *before* they reach the renderer.
|
|
14
|
+
|
|
15
|
+
The parser is real-time (handles tags split across SSE chunks) and
|
|
16
|
+
emits typed phase events as boundaries are crossed, so the consumer can
|
|
17
|
+
update the TUI the moment the model transitions between phases — no
|
|
18
|
+
buffering the full response to figure out where thinking ends.
|
|
19
|
+
|
|
20
|
+
For multi-turn conversations, the extracted thinking is round-tripped
|
|
21
|
+
through ``ThinkingContent(signature=INLINE_THINK_SIGNATURE)`` and then
|
|
22
|
+
re-inlined into the assistant content on the next turn so the model sees
|
|
23
|
+
its own reasoning in the original ``<think>`` wire format.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections.abc import Iterator
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Literal, final
|
|
31
|
+
|
|
32
|
+
INLINE_THINK_SIGNATURE = "_inline"
|
|
33
|
+
|
|
34
|
+
_OPEN_TAG = "<think>"
|
|
35
|
+
_CLOSE_TAG = "</think>"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@final
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ThinkStart:
|
|
41
|
+
"""The ``<think>`` opener was just observed."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@final
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ThinkDelta:
|
|
47
|
+
"""A chunk of thinking text streamed in real-time."""
|
|
48
|
+
|
|
49
|
+
text: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@final
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ThinkEnd:
|
|
55
|
+
"""The ``</think>`` closer was just observed."""
|
|
56
|
+
|
|
57
|
+
full_thinking: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@final
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class ResponseStart:
|
|
63
|
+
"""Response text is about to stream."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@final
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class ResponseDelta:
|
|
69
|
+
"""A chunk of response text."""
|
|
70
|
+
|
|
71
|
+
text: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@final
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class ResponseEnd:
|
|
77
|
+
"""Stream finished cleanly."""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
PhaseEvent = ThinkStart | ThinkDelta | ThinkEnd | ResponseStart | ResponseDelta | ResponseEnd
|
|
81
|
+
|
|
82
|
+
Phase = Literal["idle", "thinking", "responding"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _is_prefix_of_close_tag(buffer_tail: str) -> bool:
|
|
86
|
+
"""Check if *buffer_tail* could be the beginning of a ``</think>``
|
|
87
|
+
tag straddling the next chunk. Returns True if the tail matches a
|
|
88
|
+
prefix of ``</think>``."""
|
|
89
|
+
return _CLOSE_TAG.startswith(buffer_tail) or buffer_tail.startswith(
|
|
90
|
+
_CLOSE_TAG[: len(buffer_tail)]
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _is_prefix_of_open_tag(buffer_tail: str) -> bool:
|
|
95
|
+
"""Check if *buffer_tail* could be the beginning of a ``<think>``
|
|
96
|
+
tag straddling the next chunk."""
|
|
97
|
+
return _OPEN_TAG.startswith(buffer_tail) or buffer_tail.startswith(
|
|
98
|
+
_OPEN_TAG[: len(buffer_tail)]
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@final
|
|
103
|
+
class ThinkingPhaseParser:
|
|
104
|
+
"""Real-time streaming parser for ``<think>`` blocks in ``delta.content``."""
|
|
105
|
+
|
|
106
|
+
__slots__ = ("_buffer", "_deferred_think", "_phase", "_response_started", "_think_buffer")
|
|
107
|
+
|
|
108
|
+
def __init__(self) -> None:
|
|
109
|
+
self._buffer: str = ""
|
|
110
|
+
self._phase: Phase = "idle"
|
|
111
|
+
self._think_buffer: list[str] = []
|
|
112
|
+
# When set, the next feed() call will emit ThinkDelta + ThinkEnd for
|
|
113
|
+
# the deferred think content before processing the new text. This
|
|
114
|
+
# is only set when </think> was found in the same chunk as ThinkStart
|
|
115
|
+
# (the opener-split scenario) so the caller can distinguish the two
|
|
116
|
+
# events across chunk boundaries.
|
|
117
|
+
self._deferred_think: str | None = None
|
|
118
|
+
self._response_started: bool = False
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def phase(self) -> Phase:
|
|
122
|
+
return self._phase
|
|
123
|
+
|
|
124
|
+
def feed(self, text: str) -> Iterator[PhaseEvent]:
|
|
125
|
+
if not text:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
# If the previous feed deferred ThinkDelta+ThinkEnd (opener-split case),
|
|
129
|
+
# emit them now before processing the new chunk.
|
|
130
|
+
if self._deferred_think is not None:
|
|
131
|
+
full = self._deferred_think
|
|
132
|
+
self._deferred_think = None
|
|
133
|
+
if full:
|
|
134
|
+
yield ThinkDelta(text=full)
|
|
135
|
+
yield ThinkEnd(full_thinking=full)
|
|
136
|
+
# The buffered remainder after </think> was already stashed;
|
|
137
|
+
# process it as response text together with the new chunk below.
|
|
138
|
+
|
|
139
|
+
# Detect opener-split: the buffer held a partial <think> prefix from
|
|
140
|
+
# the previous chunk. We use this to defer ThinkDelta+ThinkEnd so
|
|
141
|
+
# callers see ThinkStart on its own chunk boundary.
|
|
142
|
+
opener_was_split = (
|
|
143
|
+
self._phase != "thinking"
|
|
144
|
+
and bool(self._buffer)
|
|
145
|
+
and _is_prefix_of_open_tag(self._buffer)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
self._buffer += text
|
|
149
|
+
|
|
150
|
+
open_tag = _OPEN_TAG
|
|
151
|
+
close_tag = _CLOSE_TAG
|
|
152
|
+
open_tag_len = len(open_tag)
|
|
153
|
+
close_tag_len = len(close_tag)
|
|
154
|
+
|
|
155
|
+
while True:
|
|
156
|
+
if self._phase == "thinking":
|
|
157
|
+
end = self._buffer.find(close_tag)
|
|
158
|
+
if end == -1:
|
|
159
|
+
# No close tag yet. Check if the buffer tail could be
|
|
160
|
+
# the start of a partial close tag.
|
|
161
|
+
for i in range(min(close_tag_len - 1, len(self._buffer)), 0, -1):
|
|
162
|
+
tail = self._buffer[-i:]
|
|
163
|
+
if _is_prefix_of_close_tag(tail):
|
|
164
|
+
head = self._buffer[:-i]
|
|
165
|
+
self._buffer = tail
|
|
166
|
+
if head:
|
|
167
|
+
self._think_buffer.append(head)
|
|
168
|
+
yield ThinkDelta(text=head)
|
|
169
|
+
return
|
|
170
|
+
# No partial close tag — emit everything.
|
|
171
|
+
if self._buffer:
|
|
172
|
+
self._think_buffer.append(self._buffer)
|
|
173
|
+
yield ThinkDelta(text=self._buffer)
|
|
174
|
+
self._buffer = ""
|
|
175
|
+
return
|
|
176
|
+
think_chunk = self._buffer[:end]
|
|
177
|
+
remainder = self._buffer[end + close_tag_len :].lstrip("\n")
|
|
178
|
+
if think_chunk:
|
|
179
|
+
self._think_buffer.append(think_chunk)
|
|
180
|
+
full_thinking = "".join(self._think_buffer)
|
|
181
|
+
self._think_buffer = []
|
|
182
|
+
self._phase = "responding"
|
|
183
|
+
self._response_started = False
|
|
184
|
+
if opener_was_split and think_chunk:
|
|
185
|
+
# ThinkStart and </think> both arrived in this feed() call.
|
|
186
|
+
# Defer ThinkDelta+ThinkEnd to the next feed() so that the
|
|
187
|
+
# caller can observe them as separate chunk events.
|
|
188
|
+
self._deferred_think = full_thinking
|
|
189
|
+
self._buffer = remainder
|
|
190
|
+
return
|
|
191
|
+
self._buffer = remainder
|
|
192
|
+
yield ThinkEnd(full_thinking=full_thinking)
|
|
193
|
+
else:
|
|
194
|
+
# In "idle" or "responding" — look for an opener.
|
|
195
|
+
start = self._buffer.find(open_tag)
|
|
196
|
+
if start == -1:
|
|
197
|
+
# Check if the buffer tail could be a partial opener.
|
|
198
|
+
for i in range(min(open_tag_len - 1, len(self._buffer)), 0, -1):
|
|
199
|
+
tail = self._buffer[-i:]
|
|
200
|
+
if _is_prefix_of_open_tag(tail):
|
|
201
|
+
head = self._buffer[:-i]
|
|
202
|
+
self._buffer = tail
|
|
203
|
+
if head:
|
|
204
|
+
for ev in self._wrap_response(head):
|
|
205
|
+
yield ev
|
|
206
|
+
return
|
|
207
|
+
# No partial opener — emit everything.
|
|
208
|
+
if self._buffer:
|
|
209
|
+
for ev in self._wrap_response(self._buffer):
|
|
210
|
+
yield ev
|
|
211
|
+
self._buffer = ""
|
|
212
|
+
return
|
|
213
|
+
head = self._buffer[:start]
|
|
214
|
+
self._buffer = self._buffer[start + open_tag_len :]
|
|
215
|
+
if head:
|
|
216
|
+
for ev in self._wrap_response(head):
|
|
217
|
+
yield ev
|
|
218
|
+
self._phase = "thinking"
|
|
219
|
+
yield ThinkStart()
|
|
220
|
+
|
|
221
|
+
def flush(self) -> Iterator[PhaseEvent]:
|
|
222
|
+
# Drain deferred ThinkEnd from the opener-split scenario.
|
|
223
|
+
# We only emit ThinkEnd here (not ThinkDelta) so _collect() doesn't
|
|
224
|
+
# double-count; ThinkEnd.full_thinking is the authoritative total.
|
|
225
|
+
if self._deferred_think is not None:
|
|
226
|
+
full = self._deferred_think
|
|
227
|
+
self._deferred_think = None
|
|
228
|
+
yield ThinkEnd(full_thinking=full)
|
|
229
|
+
# Fall through: emit any remaining buffered response + ResponseEnd.
|
|
230
|
+
|
|
231
|
+
if self._phase == "thinking":
|
|
232
|
+
if self._buffer:
|
|
233
|
+
self._think_buffer.append(self._buffer)
|
|
234
|
+
self._buffer = ""
|
|
235
|
+
full_thinking = "".join(self._think_buffer)
|
|
236
|
+
self._think_buffer = []
|
|
237
|
+
self._phase = "idle"
|
|
238
|
+
yield ThinkEnd(full_thinking=full_thinking)
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
if self._buffer:
|
|
242
|
+
head = self._buffer
|
|
243
|
+
self._buffer = ""
|
|
244
|
+
yield from self._wrap_response(head)
|
|
245
|
+
self._phase = "idle"
|
|
246
|
+
self._response_started = False
|
|
247
|
+
yield ResponseEnd()
|
|
248
|
+
|
|
249
|
+
def _wrap_response(self, text: str) -> Iterator[PhaseEvent]:
|
|
250
|
+
if not text:
|
|
251
|
+
return
|
|
252
|
+
if not self._response_started:
|
|
253
|
+
self._response_started = True
|
|
254
|
+
self._phase = "responding"
|
|
255
|
+
yield ResponseStart()
|
|
256
|
+
yield ResponseDelta(text=text)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
__all__ = [
|
|
260
|
+
"INLINE_THINK_SIGNATURE",
|
|
261
|
+
"Phase",
|
|
262
|
+
"PhaseEvent",
|
|
263
|
+
"ResponseDelta",
|
|
264
|
+
"ResponseEnd",
|
|
265
|
+
"ResponseStart",
|
|
266
|
+
"ThinkDelta",
|
|
267
|
+
"ThinkEnd",
|
|
268
|
+
"ThinkStart",
|
|
269
|
+
"ThinkingPhaseParser",
|
|
270
|
+
]
|