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.
Files changed (117) hide show
  1. vtx/__init__.py +63 -0
  2. vtx/async_utils.py +40 -0
  3. vtx/builtin_skills/github/SKILL.md +139 -0
  4. vtx/builtin_skills/init/SKILL.md +74 -0
  5. vtx/builtin_skills/review/SKILL.md +73 -0
  6. vtx/builtin_skills/skill-builder/SKILL.md +133 -0
  7. vtx/cli.py +90 -0
  8. vtx/config.py +741 -0
  9. vtx/context/__init__.py +15 -0
  10. vtx/context/_xml.py +8 -0
  11. vtx/context/agent_mds.py +128 -0
  12. vtx/context/git.py +64 -0
  13. vtx/context/loader.py +41 -0
  14. vtx/context/skills.py +423 -0
  15. vtx/core/__init__.py +47 -0
  16. vtx/core/compaction.py +89 -0
  17. vtx/core/errors.py +17 -0
  18. vtx/core/handoff.py +51 -0
  19. vtx/core/scratchpad.py +54 -0
  20. vtx/core/types.py +197 -0
  21. vtx/defaults/__init__.py +0 -0
  22. vtx/defaults/config.yml +53 -0
  23. vtx/diff_display.py +12 -0
  24. vtx/events.py +224 -0
  25. vtx/gh_cli.py +82 -0
  26. vtx/git_branch.py +90 -0
  27. vtx/headless.py +127 -0
  28. vtx/llm/__init__.py +93 -0
  29. vtx/llm/base.py +217 -0
  30. vtx/llm/context_length.py +150 -0
  31. vtx/llm/dynamic_models.py +735 -0
  32. vtx/llm/model_fetcher.py +279 -0
  33. vtx/llm/models.py +78 -0
  34. vtx/llm/oauth/__init__.py +59 -0
  35. vtx/llm/oauth/copilot.py +358 -0
  36. vtx/llm/oauth/dynamic.py +236 -0
  37. vtx/llm/oauth/openai.py +400 -0
  38. vtx/llm/phase_parser.py +270 -0
  39. vtx/llm/provider.yaml +280 -0
  40. vtx/llm/provider_catalog.py +230 -0
  41. vtx/llm/providers/__init__.py +45 -0
  42. vtx/llm/providers/anthropic_sdk.py +256 -0
  43. vtx/llm/providers/mock.py +249 -0
  44. vtx/llm/providers/openai_sdk.py +246 -0
  45. vtx/llm/providers/sanitize.py +14 -0
  46. vtx/llm/sdk/__init__.py +13 -0
  47. vtx/llm/sdk/anthropic.py +382 -0
  48. vtx/llm/sdk/base.py +82 -0
  49. vtx/llm/sdk/openai.py +344 -0
  50. vtx/llm/tool_parser.py +161 -0
  51. vtx/loop.py +272 -0
  52. vtx/notify.py +109 -0
  53. vtx/permissions.py +114 -0
  54. vtx/prompts/__init__.py +45 -0
  55. vtx/prompts/builder.py +86 -0
  56. vtx/prompts/env.py +58 -0
  57. vtx/prompts/identity.py +166 -0
  58. vtx/prompts/tooling.py +36 -0
  59. vtx/py.typed +0 -0
  60. vtx/runtime.py +580 -0
  61. vtx/session.py +868 -0
  62. vtx/sounds/completion.wav +0 -0
  63. vtx/sounds/error.wav +0 -0
  64. vtx/sounds/permission.wav +0 -0
  65. vtx/themes.py +1104 -0
  66. vtx/tools/__init__.py +68 -0
  67. vtx/tools/_read_image.py +106 -0
  68. vtx/tools/_tool_utils.py +90 -0
  69. vtx/tools/base.py +36 -0
  70. vtx/tools/bash.py +371 -0
  71. vtx/tools/edit.py +261 -0
  72. vtx/tools/find.py +132 -0
  73. vtx/tools/read.py +238 -0
  74. vtx/tools/skill.py +278 -0
  75. vtx/tools/web.py +238 -0
  76. vtx/tools/write.py +88 -0
  77. vtx/tools_manager.py +216 -0
  78. vtx/turn.py +789 -0
  79. vtx/ui/__init__.py +0 -0
  80. vtx/ui/agent_runner.py +417 -0
  81. vtx/ui/app.py +665 -0
  82. vtx/ui/app_protocol.py +29 -0
  83. vtx/ui/autocomplete.py +440 -0
  84. vtx/ui/blocks.py +735 -0
  85. vtx/ui/chat.py +613 -0
  86. vtx/ui/clipboard.py +59 -0
  87. vtx/ui/commands/__init__.py +100 -0
  88. vtx/ui/commands/auth.py +306 -0
  89. vtx/ui/commands/base.py +122 -0
  90. vtx/ui/commands/models.py +144 -0
  91. vtx/ui/commands/sessions.py +388 -0
  92. vtx/ui/commands/settings.py +286 -0
  93. vtx/ui/completion_ui.py +313 -0
  94. vtx/ui/export.py +703 -0
  95. vtx/ui/floating_list.py +370 -0
  96. vtx/ui/formatting.py +287 -0
  97. vtx/ui/input.py +760 -0
  98. vtx/ui/latex.py +349 -0
  99. vtx/ui/launch.py +108 -0
  100. vtx/ui/path_complete.py +228 -0
  101. vtx/ui/prompt_history.py +102 -0
  102. vtx/ui/queue_ui.py +141 -0
  103. vtx/ui/selection_mode.py +18 -0
  104. vtx/ui/session_ui.py +235 -0
  105. vtx/ui/startup.py +124 -0
  106. vtx/ui/styles.py +327 -0
  107. vtx/ui/tool_output.py +34 -0
  108. vtx/ui/tree.py +437 -0
  109. vtx/ui/welcome.py +51 -0
  110. vtx/ui/widgets.py +558 -0
  111. vtx/update_check.py +49 -0
  112. vtx/version.py +22 -0
  113. vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
  114. vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
  115. vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
  116. vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
  117. vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
@@ -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
@@ -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
+ ]