querri 1.0.2__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.
- querri/__init__.py +51 -0
- querri/_auth.py +818 -0
- querri/_base_client.py +379 -0
- querri/_client.py +395 -0
- querri/_config.py +140 -0
- querri/_convenience.py +538 -0
- querri/_exceptions.py +180 -0
- querri/_pagination.py +278 -0
- querri/_streaming.py +663 -0
- querri/_user_client.py +193 -0
- querri/_version.py +1 -0
- querri/cli/__init__.py +21 -0
- querri/cli/_app.py +246 -0
- querri/cli/_context.py +216 -0
- querri/cli/_image.py +234 -0
- querri/cli/_output.py +293 -0
- querri/cli/audit.py +68 -0
- querri/cli/auth.py +556 -0
- querri/cli/chat.py +1255 -0
- querri/cli/chats.py +358 -0
- querri/cli/dashboards.py +294 -0
- querri/cli/embed.py +220 -0
- querri/cli/files.py +214 -0
- querri/cli/keys.py +304 -0
- querri/cli/policies.py +424 -0
- querri/cli/projects.py +916 -0
- querri/cli/sharing.py +403 -0
- querri/cli/sources.py +532 -0
- querri/cli/steps.py +123 -0
- querri/cli/usage.py +99 -0
- querri/cli/users.py +223 -0
- querri/cli/views.py +449 -0
- querri/cli/whoami.py +78 -0
- querri/py.typed +0 -0
- querri/resources/__init__.py +1 -0
- querri/resources/audit.py +119 -0
- querri/resources/dashboards.py +295 -0
- querri/resources/embed.py +268 -0
- querri/resources/files.py +145 -0
- querri/resources/keys.py +191 -0
- querri/resources/policies.py +492 -0
- querri/resources/projects.py +808 -0
- querri/resources/sharing.py +330 -0
- querri/resources/sources.py +511 -0
- querri/resources/usage.py +106 -0
- querri/resources/users.py +303 -0
- querri/resources/views.py +347 -0
- querri/types/__init__.py +104 -0
- querri/types/audit.py +21 -0
- querri/types/chat.py +51 -0
- querri/types/dashboard.py +51 -0
- querri/types/data.py +61 -0
- querri/types/embed.py +38 -0
- querri/types/file.py +18 -0
- querri/types/key.py +36 -0
- querri/types/policy.py +105 -0
- querri/types/project.py +158 -0
- querri/types/sharing.py +19 -0
- querri/types/usage.py +39 -0
- querri/types/user.py +33 -0
- querri-1.0.2.dist-info/METADATA +310 -0
- querri-1.0.2.dist-info/RECORD +64 -0
- querri-1.0.2.dist-info/WHEEL +4 -0
- querri-1.0.2.dist-info/entry_points.txt +2 -0
querri/_streaming.py
ADDED
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
"""SSE stream handling for chat responses.
|
|
2
|
+
|
|
3
|
+
Parses both Vercel AI SDK v1 format (``0:text``, ``e:error``, ``d:done``)
|
|
4
|
+
and v2 SSE format (``event: text-delta``, ``data: {...}``).
|
|
5
|
+
|
|
6
|
+
v2 event types handled in v0.2.0 (must-parse):
|
|
7
|
+
text-delta, tool-output-available, file, error, finish, terminate, [DONE]
|
|
8
|
+
|
|
9
|
+
Unknown event types are silently ignored for forward compatibility.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from collections.abc import AsyncIterator, Iterator
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from ._exceptions import StreamCancelledError, StreamError, StreamTimeoutError
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger("querri")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Typed event model
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class ChatStreamEvent:
|
|
34
|
+
"""Typed event from a Vercel AI SDK v2 SSE stream.
|
|
35
|
+
|
|
36
|
+
The ``event_type`` field indicates which fields are populated:
|
|
37
|
+
|
|
38
|
+
- ``text-delta``: ``text`` contains the chunk.
|
|
39
|
+
- ``reasoning-delta``: ``reasoning_text`` contains reasoning chunk.
|
|
40
|
+
- ``reasoning-start`` / ``reasoning-end``: reasoning lifecycle markers.
|
|
41
|
+
- ``tool-output-available``: ``tool_name`` and ``tool_data``.
|
|
42
|
+
- ``file``: ``file_url`` and ``media_type``.
|
|
43
|
+
- ``error``: ``error`` message.
|
|
44
|
+
- ``finish``: ``usage`` dict with credits/tokens.
|
|
45
|
+
- ``status-update``: ``text`` has the message, ``raw_data`` has the JSON.
|
|
46
|
+
- ``terminate``: ``terminate_reason`` and ``terminate_message``.
|
|
47
|
+
- Unknown types: ``raw_data`` contains the unparsed data string.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
event_type: str
|
|
51
|
+
text: str | None = None
|
|
52
|
+
reasoning_text: str | None = None
|
|
53
|
+
tool_name: str | None = None
|
|
54
|
+
tool_data: Any | None = None
|
|
55
|
+
file_url: str | None = None
|
|
56
|
+
media_type: str | None = None
|
|
57
|
+
step_result: dict[str, Any] | None = None
|
|
58
|
+
error: str | None = None
|
|
59
|
+
usage: dict[str, Any] | None = None
|
|
60
|
+
terminate_reason: str | None = None
|
|
61
|
+
terminate_message: str | None = None
|
|
62
|
+
raw_data: str | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# SSE line parsing
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_sse_line(line: str) -> tuple[str, str] | None:
|
|
71
|
+
"""Parse a single SSE line into (type_prefix, data).
|
|
72
|
+
|
|
73
|
+
Returns None for empty lines and unparseable lines.
|
|
74
|
+
SSE comments starting with ``:`` are checked for status-update payloads.
|
|
75
|
+
"""
|
|
76
|
+
line = line.strip()
|
|
77
|
+
if not line:
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
# SSE comments — check for status-update payload
|
|
81
|
+
if line.startswith(":"):
|
|
82
|
+
if line.startswith(": status-update "):
|
|
83
|
+
return ("status-update", line[16:])
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
# Vercel AI SDK v1 format: "0:text chunk" or "e:event" or "d:done"
|
|
87
|
+
if len(line) >= 2 and line[1] == ":":
|
|
88
|
+
return (line[0], line[2:])
|
|
89
|
+
|
|
90
|
+
# Standard SSE format: "data: ..."
|
|
91
|
+
if line.startswith("data: "):
|
|
92
|
+
return ("data", line[6:])
|
|
93
|
+
|
|
94
|
+
# SSE event type line: "event: text-delta"
|
|
95
|
+
if line.startswith("event: "):
|
|
96
|
+
return ("event", line[7:])
|
|
97
|
+
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _unquote_text(data: str) -> str:
|
|
102
|
+
"""Strip surrounding quotes and unescape a text chunk."""
|
|
103
|
+
if data.startswith('"') and data.endswith('"'):
|
|
104
|
+
data = data[1:-1]
|
|
105
|
+
data = data.replace("\\n", "\n").replace('\\"', '"').replace("\\\\", "\\")
|
|
106
|
+
return data
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _parse_json_safe(data: str) -> dict[str, Any] | None:
|
|
110
|
+
"""Parse JSON data, return None on failure."""
|
|
111
|
+
try:
|
|
112
|
+
result = json.loads(data)
|
|
113
|
+
return result if isinstance(result, dict) else None
|
|
114
|
+
except (json.JSONDecodeError, TypeError):
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _build_event(event_type: str, data: str) -> ChatStreamEvent:
|
|
119
|
+
"""Build a ChatStreamEvent from an event type and data payload."""
|
|
120
|
+
parsed = _parse_json_safe(data)
|
|
121
|
+
|
|
122
|
+
match event_type:
|
|
123
|
+
case "text-delta":
|
|
124
|
+
text = parsed.get("textDelta", data) if parsed else _unquote_text(data)
|
|
125
|
+
return ChatStreamEvent(event_type=event_type, text=text)
|
|
126
|
+
|
|
127
|
+
case "tool-output-available":
|
|
128
|
+
output = parsed.get("output") if parsed else None
|
|
129
|
+
# Check for step-result subtype (sent when a step completes
|
|
130
|
+
# with full result data including figure_url, qdf, etc.)
|
|
131
|
+
if isinstance(output, dict) and output.get("type") == "step-result":
|
|
132
|
+
return ChatStreamEvent(
|
|
133
|
+
event_type="step-result",
|
|
134
|
+
tool_name=output.get("stepType") or output.get("stepName"),
|
|
135
|
+
step_result=output.get("result"),
|
|
136
|
+
raw_data=data,
|
|
137
|
+
)
|
|
138
|
+
return ChatStreamEvent(
|
|
139
|
+
event_type=event_type,
|
|
140
|
+
tool_name=parsed.get("toolName") if parsed else None,
|
|
141
|
+
tool_data=output,
|
|
142
|
+
raw_data=data,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
case "file":
|
|
146
|
+
return ChatStreamEvent(
|
|
147
|
+
event_type=event_type,
|
|
148
|
+
file_url=parsed.get("url") if parsed else None,
|
|
149
|
+
media_type=parsed.get("mediaType") if parsed else None,
|
|
150
|
+
raw_data=data,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
case "error":
|
|
154
|
+
err_msg = parsed.get("message", data) if parsed else data
|
|
155
|
+
return ChatStreamEvent(event_type=event_type, error=err_msg, raw_data=data)
|
|
156
|
+
|
|
157
|
+
case "finish":
|
|
158
|
+
return ChatStreamEvent(
|
|
159
|
+
event_type=event_type,
|
|
160
|
+
usage=parsed.get("usage") if parsed else None,
|
|
161
|
+
raw_data=data,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
case "terminate":
|
|
165
|
+
return ChatStreamEvent(
|
|
166
|
+
event_type=event_type,
|
|
167
|
+
terminate_reason=parsed.get("reason") if parsed else None,
|
|
168
|
+
terminate_message=parsed.get("message") if parsed else None,
|
|
169
|
+
raw_data=data,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
case "reasoning-delta":
|
|
173
|
+
text = (
|
|
174
|
+
parsed.get("textDelta", parsed.get("delta", data))
|
|
175
|
+
if parsed
|
|
176
|
+
else _unquote_text(data)
|
|
177
|
+
)
|
|
178
|
+
return ChatStreamEvent(event_type=event_type, reasoning_text=text)
|
|
179
|
+
|
|
180
|
+
case "reasoning-start" | "reasoning-end":
|
|
181
|
+
return ChatStreamEvent(event_type=event_type)
|
|
182
|
+
|
|
183
|
+
case "[DONE]":
|
|
184
|
+
return ChatStreamEvent(event_type=event_type)
|
|
185
|
+
|
|
186
|
+
case _:
|
|
187
|
+
# Unknown event type — pass through for forward compatibility
|
|
188
|
+
return ChatStreamEvent(event_type=event_type, raw_data=data)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _build_event_from_json(data: str) -> ChatStreamEvent | None:
|
|
192
|
+
"""Build a ChatStreamEvent from a JSON SSE payload.
|
|
193
|
+
|
|
194
|
+
Handles the server's native JSON SSE format where each line is:
|
|
195
|
+
data: {"type": "...", ...}
|
|
196
|
+
|
|
197
|
+
Maps server event types to ChatStreamEvent types.
|
|
198
|
+
"""
|
|
199
|
+
parsed = _parse_json_safe(data)
|
|
200
|
+
if not parsed or "type" not in parsed:
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
event_type = parsed["type"]
|
|
204
|
+
|
|
205
|
+
match event_type:
|
|
206
|
+
# Text content
|
|
207
|
+
case "text-delta":
|
|
208
|
+
return ChatStreamEvent(
|
|
209
|
+
event_type="text-delta",
|
|
210
|
+
text=parsed.get("textDelta", parsed.get("delta", "")),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Reasoning / thinking
|
|
214
|
+
case "reasoning-delta":
|
|
215
|
+
return ChatStreamEvent(
|
|
216
|
+
event_type="reasoning-delta",
|
|
217
|
+
reasoning_text=parsed.get("textDelta", parsed.get("delta", "")),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Tool calls
|
|
221
|
+
case "tool-call-start":
|
|
222
|
+
return ChatStreamEvent(
|
|
223
|
+
event_type="tool-output-available",
|
|
224
|
+
tool_name=parsed.get("toolName", parsed.get("name")),
|
|
225
|
+
raw_data=data,
|
|
226
|
+
)
|
|
227
|
+
case "tool-call-delta":
|
|
228
|
+
return None # Intermediate tool output, skip
|
|
229
|
+
case "tool-input-available":
|
|
230
|
+
return None # Plan input, skip
|
|
231
|
+
case "tool-output-available":
|
|
232
|
+
output = parsed.get("output") or {}
|
|
233
|
+
# Check for step-result subtype
|
|
234
|
+
if isinstance(output, dict) and output.get("type") == "step-result":
|
|
235
|
+
return ChatStreamEvent(
|
|
236
|
+
event_type="step-result",
|
|
237
|
+
tool_name=output.get("stepType") or output.get("stepName"),
|
|
238
|
+
step_result=output.get("result"),
|
|
239
|
+
raw_data=data,
|
|
240
|
+
)
|
|
241
|
+
return ChatStreamEvent(
|
|
242
|
+
event_type="tool-output-available",
|
|
243
|
+
tool_name=parsed.get("toolName")
|
|
244
|
+
or (
|
|
245
|
+
output.get("message", "").split(" - ")[0].replace("Step ", "")
|
|
246
|
+
if output.get("message")
|
|
247
|
+
else None
|
|
248
|
+
),
|
|
249
|
+
tool_data=output,
|
|
250
|
+
raw_data=data,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Choices (Querri's suggestion cards)
|
|
254
|
+
case "choices":
|
|
255
|
+
summary = parsed.get("summary", "")
|
|
256
|
+
choices = parsed.get("choices", [])
|
|
257
|
+
labels = [c.get("label", c.get("prompt", "")) for c in choices]
|
|
258
|
+
text = summary
|
|
259
|
+
if labels:
|
|
260
|
+
text += "\n" + "\n".join(f" • {label}" for label in labels)
|
|
261
|
+
return ChatStreamEvent(event_type="text-delta", text=text + "\n")
|
|
262
|
+
|
|
263
|
+
# Status updates (SSE comments become events here)
|
|
264
|
+
case "status-update" | "update":
|
|
265
|
+
msg = parsed.get("message", "")
|
|
266
|
+
return ChatStreamEvent(
|
|
267
|
+
event_type="text-delta",
|
|
268
|
+
text=f"\n[{msg}]\n" if msg else None,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# Stream lifecycle
|
|
272
|
+
case "start":
|
|
273
|
+
return None
|
|
274
|
+
case "start-step" | "end-step" | "finish-step":
|
|
275
|
+
return None
|
|
276
|
+
case "reasoning-start" | "reasoning-end":
|
|
277
|
+
return ChatStreamEvent(event_type=event_type)
|
|
278
|
+
case "finish" | "done":
|
|
279
|
+
return ChatStreamEvent(
|
|
280
|
+
event_type="finish",
|
|
281
|
+
usage=parsed.get("usage"),
|
|
282
|
+
raw_data=data,
|
|
283
|
+
)
|
|
284
|
+
case "error":
|
|
285
|
+
return ChatStreamEvent(
|
|
286
|
+
event_type="error",
|
|
287
|
+
error=parsed.get("message", parsed.get("error", data)),
|
|
288
|
+
raw_data=data,
|
|
289
|
+
)
|
|
290
|
+
case "terminate":
|
|
291
|
+
return ChatStreamEvent(
|
|
292
|
+
event_type="terminate",
|
|
293
|
+
terminate_reason=parsed.get("reason"),
|
|
294
|
+
terminate_message=parsed.get("message"),
|
|
295
|
+
raw_data=data,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
case _:
|
|
299
|
+
# Unknown — pass through
|
|
300
|
+
return ChatStreamEvent(event_type=event_type, raw_data=data)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ---------------------------------------------------------------------------
|
|
304
|
+
# Synchronous stream
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class ChatStream:
|
|
309
|
+
"""Synchronous iterator over SSE chat response chunks.
|
|
310
|
+
|
|
311
|
+
Supports both v1 (text-only) and v2 (typed events) iteration:
|
|
312
|
+
|
|
313
|
+
v1 (backward-compatible)::
|
|
314
|
+
|
|
315
|
+
for chunk in stream:
|
|
316
|
+
print(chunk, end="", flush=True)
|
|
317
|
+
|
|
318
|
+
v2 (typed events)::
|
|
319
|
+
|
|
320
|
+
for event in stream.events():
|
|
321
|
+
if event.event_type == "text-delta":
|
|
322
|
+
print(event.text, end="")
|
|
323
|
+
elif event.event_type == "terminate":
|
|
324
|
+
print(f"Stream closed: {event.terminate_reason}")
|
|
325
|
+
"""
|
|
326
|
+
|
|
327
|
+
def __init__(self, response: httpx.Response) -> None:
|
|
328
|
+
"""Wrap an httpx streaming response for SSE chunk parsing.
|
|
329
|
+
|
|
330
|
+
Extracts ``x-message-id`` from response headers for message correlation.
|
|
331
|
+
"""
|
|
332
|
+
self._response = response
|
|
333
|
+
self._text_chunks: list[str] = []
|
|
334
|
+
self._events: list[ChatStreamEvent] = []
|
|
335
|
+
self._done = False
|
|
336
|
+
self._cancelled = False
|
|
337
|
+
self._consumed = False
|
|
338
|
+
self._message_id: str | None = response.headers.get("x-message-id")
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def message_id(self) -> str | None:
|
|
342
|
+
"""Server-assigned message ID from the ``x-message-id`` response header."""
|
|
343
|
+
return self._message_id
|
|
344
|
+
|
|
345
|
+
def __iter__(self) -> Iterator[str]:
|
|
346
|
+
"""Iterate text chunks (backward-compatible v1 API)."""
|
|
347
|
+
try:
|
|
348
|
+
for line in self._response.iter_lines():
|
|
349
|
+
if self._cancelled:
|
|
350
|
+
break
|
|
351
|
+
|
|
352
|
+
parsed = _parse_sse_line(line)
|
|
353
|
+
if parsed is None:
|
|
354
|
+
continue
|
|
355
|
+
|
|
356
|
+
prefix, data = parsed
|
|
357
|
+
|
|
358
|
+
if prefix == "0":
|
|
359
|
+
text = _unquote_text(data)
|
|
360
|
+
self._text_chunks.append(text)
|
|
361
|
+
yield text
|
|
362
|
+
|
|
363
|
+
elif prefix == "e":
|
|
364
|
+
raise StreamError(f"Stream error: {data}")
|
|
365
|
+
|
|
366
|
+
elif prefix == "d":
|
|
367
|
+
self._done = True
|
|
368
|
+
break
|
|
369
|
+
|
|
370
|
+
except httpx.ReadTimeout as exc:
|
|
371
|
+
raise StreamTimeoutError("Stream timed out waiting for data") from exc
|
|
372
|
+
finally:
|
|
373
|
+
self._consumed = True
|
|
374
|
+
self._response.close()
|
|
375
|
+
|
|
376
|
+
def events(self) -> Iterator[ChatStreamEvent]:
|
|
377
|
+
"""Iterate typed events (v2 API).
|
|
378
|
+
|
|
379
|
+
Yields ``ChatStreamEvent`` objects for each SSE event. Handles:
|
|
380
|
+
- v1 prefix format (``0:``, ``e:``, ``d:``)
|
|
381
|
+
- v2 SSE format (``event: text-delta`` / ``data: {...}``)
|
|
382
|
+
- JSON SSE format (``data: {"type": "...", ...}``)
|
|
383
|
+
"""
|
|
384
|
+
current_event_type: str | None = None
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
for line in self._response.iter_lines():
|
|
388
|
+
if self._cancelled:
|
|
389
|
+
break
|
|
390
|
+
|
|
391
|
+
parsed = _parse_sse_line(line)
|
|
392
|
+
if parsed is None:
|
|
393
|
+
continue
|
|
394
|
+
|
|
395
|
+
prefix, data = parsed
|
|
396
|
+
|
|
397
|
+
# Status-update from SSE comment
|
|
398
|
+
if prefix == "status-update":
|
|
399
|
+
parsed_su = _parse_json_safe(data)
|
|
400
|
+
if parsed_su:
|
|
401
|
+
event = ChatStreamEvent(
|
|
402
|
+
event_type="status-update",
|
|
403
|
+
raw_data=data,
|
|
404
|
+
text=parsed_su.get("message"),
|
|
405
|
+
)
|
|
406
|
+
self._events.append(event)
|
|
407
|
+
yield event
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
# v2 SSE: "event: <type>" followed by "data: <payload>"
|
|
411
|
+
if prefix == "event":
|
|
412
|
+
current_event_type = data.strip()
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
if prefix == "data" and current_event_type:
|
|
416
|
+
event = _build_event(current_event_type, data)
|
|
417
|
+
self._events.append(event)
|
|
418
|
+
if event.event_type == "text-delta" and event.text:
|
|
419
|
+
self._text_chunks.append(event.text)
|
|
420
|
+
current_event_type = None
|
|
421
|
+
yield event
|
|
422
|
+
continue
|
|
423
|
+
|
|
424
|
+
# JSON SSE: "data: {"type": "...", ...}" (no preceding event: line)
|
|
425
|
+
if prefix == "data" and data.startswith("{"):
|
|
426
|
+
json_event = _build_event_from_json(data)
|
|
427
|
+
if json_event is not None:
|
|
428
|
+
self._events.append(json_event)
|
|
429
|
+
if json_event.event_type == "text-delta" and json_event.text:
|
|
430
|
+
self._text_chunks.append(json_event.text)
|
|
431
|
+
yield json_event
|
|
432
|
+
continue
|
|
433
|
+
|
|
434
|
+
# "data: [DONE]" — end of stream
|
|
435
|
+
if prefix == "data" and data.strip() == "[DONE]":
|
|
436
|
+
self._done = True
|
|
437
|
+
break
|
|
438
|
+
|
|
439
|
+
# v1 prefix format fallback
|
|
440
|
+
if prefix == "0":
|
|
441
|
+
text = _unquote_text(data)
|
|
442
|
+
event = ChatStreamEvent(event_type="text-delta", text=text)
|
|
443
|
+
self._text_chunks.append(text)
|
|
444
|
+
self._events.append(event)
|
|
445
|
+
yield event
|
|
446
|
+
|
|
447
|
+
elif prefix == "e":
|
|
448
|
+
event = ChatStreamEvent(
|
|
449
|
+
event_type="error", error=data, raw_data=data
|
|
450
|
+
)
|
|
451
|
+
self._events.append(event)
|
|
452
|
+
yield event
|
|
453
|
+
raise StreamError(f"Stream error: {data}")
|
|
454
|
+
|
|
455
|
+
elif prefix == "d":
|
|
456
|
+
event = ChatStreamEvent(event_type="[DONE]")
|
|
457
|
+
self._events.append(event)
|
|
458
|
+
self._done = True
|
|
459
|
+
yield event
|
|
460
|
+
break
|
|
461
|
+
|
|
462
|
+
except httpx.ReadTimeout as exc:
|
|
463
|
+
raise StreamTimeoutError("Stream timed out waiting for data") from exc
|
|
464
|
+
finally:
|
|
465
|
+
self._consumed = True
|
|
466
|
+
self._response.close()
|
|
467
|
+
|
|
468
|
+
def text(self) -> str:
|
|
469
|
+
"""Consume the entire stream and return the full text."""
|
|
470
|
+
if not self._text_chunks and not self._consumed:
|
|
471
|
+
for _ in self:
|
|
472
|
+
pass
|
|
473
|
+
return "".join(self._text_chunks)
|
|
474
|
+
|
|
475
|
+
def _signal_cancel(self) -> None:
|
|
476
|
+
"""Signal-safe cancel — set flag and close, but do NOT raise.
|
|
477
|
+
|
|
478
|
+
Safe to call from signal handlers (e.g. SIGINT). The iteration loop
|
|
479
|
+
checks ``self._cancelled`` and exits cleanly.
|
|
480
|
+
"""
|
|
481
|
+
self._cancelled = True
|
|
482
|
+
self._response.close()
|
|
483
|
+
|
|
484
|
+
def cancel(self) -> None:
|
|
485
|
+
"""Cancel the stream (user-callable path).
|
|
486
|
+
|
|
487
|
+
Raises ``StreamCancelledError`` so callers outside signal handlers
|
|
488
|
+
get an explicit exception.
|
|
489
|
+
"""
|
|
490
|
+
self._signal_cancel()
|
|
491
|
+
raise StreamCancelledError("Stream cancelled by client")
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
# ---------------------------------------------------------------------------
|
|
495
|
+
# Asynchronous stream
|
|
496
|
+
# ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
class AsyncChatStream:
|
|
500
|
+
"""Asynchronous iterator over SSE chat response chunks.
|
|
501
|
+
|
|
502
|
+
Supports both v1 (text-only) and v2 (typed events) iteration:
|
|
503
|
+
|
|
504
|
+
v1::
|
|
505
|
+
|
|
506
|
+
async for chunk in stream:
|
|
507
|
+
print(chunk, end="", flush=True)
|
|
508
|
+
|
|
509
|
+
v2::
|
|
510
|
+
|
|
511
|
+
async for event in stream.events():
|
|
512
|
+
if event.event_type == "text-delta":
|
|
513
|
+
print(event.text, end="")
|
|
514
|
+
"""
|
|
515
|
+
|
|
516
|
+
def __init__(self, response: httpx.Response) -> None:
|
|
517
|
+
"""Wrap an httpx streaming response for SSE chunk parsing.
|
|
518
|
+
|
|
519
|
+
Extracts ``x-message-id`` from response headers for message correlation.
|
|
520
|
+
"""
|
|
521
|
+
self._response = response
|
|
522
|
+
self._text_chunks: list[str] = []
|
|
523
|
+
self._events: list[ChatStreamEvent] = []
|
|
524
|
+
self._done = False
|
|
525
|
+
self._cancelled = False
|
|
526
|
+
self._consumed = False
|
|
527
|
+
self._message_id: str | None = response.headers.get("x-message-id")
|
|
528
|
+
|
|
529
|
+
@property
|
|
530
|
+
def message_id(self) -> str | None:
|
|
531
|
+
"""Server-assigned message ID from the ``x-message-id`` response header."""
|
|
532
|
+
return self._message_id
|
|
533
|
+
|
|
534
|
+
async def __aiter__(self) -> AsyncIterator[str]:
|
|
535
|
+
"""Iterate text chunks (backward-compatible v1 API)."""
|
|
536
|
+
try:
|
|
537
|
+
async for line in self._response.aiter_lines():
|
|
538
|
+
if self._cancelled:
|
|
539
|
+
break
|
|
540
|
+
|
|
541
|
+
parsed = _parse_sse_line(line)
|
|
542
|
+
if parsed is None:
|
|
543
|
+
continue
|
|
544
|
+
|
|
545
|
+
prefix, data = parsed
|
|
546
|
+
|
|
547
|
+
if prefix == "0":
|
|
548
|
+
text = _unquote_text(data)
|
|
549
|
+
self._text_chunks.append(text)
|
|
550
|
+
yield text
|
|
551
|
+
|
|
552
|
+
elif prefix == "e":
|
|
553
|
+
raise StreamError(f"Stream error: {data}")
|
|
554
|
+
|
|
555
|
+
elif prefix == "d":
|
|
556
|
+
self._done = True
|
|
557
|
+
break
|
|
558
|
+
|
|
559
|
+
except httpx.ReadTimeout as exc:
|
|
560
|
+
raise StreamTimeoutError("Stream timed out waiting for data") from exc
|
|
561
|
+
finally:
|
|
562
|
+
self._consumed = True
|
|
563
|
+
await self._response.aclose()
|
|
564
|
+
|
|
565
|
+
async def events(self) -> AsyncIterator[ChatStreamEvent]:
|
|
566
|
+
"""Iterate typed events (v2 API).
|
|
567
|
+
|
|
568
|
+
Yields ``ChatStreamEvent`` objects for each SSE event.
|
|
569
|
+
"""
|
|
570
|
+
current_event_type: str | None = None
|
|
571
|
+
|
|
572
|
+
try:
|
|
573
|
+
async for line in self._response.aiter_lines():
|
|
574
|
+
if self._cancelled:
|
|
575
|
+
break
|
|
576
|
+
|
|
577
|
+
parsed = _parse_sse_line(line)
|
|
578
|
+
if parsed is None:
|
|
579
|
+
continue
|
|
580
|
+
|
|
581
|
+
prefix, data = parsed
|
|
582
|
+
|
|
583
|
+
if prefix == "event":
|
|
584
|
+
current_event_type = data.strip()
|
|
585
|
+
continue
|
|
586
|
+
|
|
587
|
+
if prefix == "data" and current_event_type:
|
|
588
|
+
event = _build_event(current_event_type, data)
|
|
589
|
+
self._events.append(event)
|
|
590
|
+
if event.event_type == "text-delta" and event.text:
|
|
591
|
+
self._text_chunks.append(event.text)
|
|
592
|
+
current_event_type = None
|
|
593
|
+
yield event
|
|
594
|
+
continue
|
|
595
|
+
|
|
596
|
+
# JSON SSE: "data: {"type": "...", ...}"
|
|
597
|
+
if prefix == "data" and data.startswith("{"):
|
|
598
|
+
json_event = _build_event_from_json(data)
|
|
599
|
+
if json_event is not None:
|
|
600
|
+
self._events.append(json_event)
|
|
601
|
+
if json_event.event_type == "text-delta" and json_event.text:
|
|
602
|
+
self._text_chunks.append(json_event.text)
|
|
603
|
+
yield json_event
|
|
604
|
+
continue
|
|
605
|
+
|
|
606
|
+
# "data: [DONE]" — end of stream
|
|
607
|
+
if prefix == "data" and data.strip() == "[DONE]":
|
|
608
|
+
self._done = True
|
|
609
|
+
break
|
|
610
|
+
|
|
611
|
+
# v1 prefix format fallback
|
|
612
|
+
if prefix == "0":
|
|
613
|
+
text = _unquote_text(data)
|
|
614
|
+
event = ChatStreamEvent(event_type="text-delta", text=text)
|
|
615
|
+
self._text_chunks.append(text)
|
|
616
|
+
self._events.append(event)
|
|
617
|
+
yield event
|
|
618
|
+
|
|
619
|
+
elif prefix == "e":
|
|
620
|
+
event = ChatStreamEvent(
|
|
621
|
+
event_type="error", error=data, raw_data=data
|
|
622
|
+
)
|
|
623
|
+
self._events.append(event)
|
|
624
|
+
yield event
|
|
625
|
+
raise StreamError(f"Stream error: {data}")
|
|
626
|
+
|
|
627
|
+
elif prefix == "d":
|
|
628
|
+
event = ChatStreamEvent(event_type="[DONE]")
|
|
629
|
+
self._events.append(event)
|
|
630
|
+
self._done = True
|
|
631
|
+
yield event
|
|
632
|
+
break
|
|
633
|
+
|
|
634
|
+
except httpx.ReadTimeout as exc:
|
|
635
|
+
raise StreamTimeoutError("Stream timed out waiting for data") from exc
|
|
636
|
+
finally:
|
|
637
|
+
self._consumed = True
|
|
638
|
+
await self._response.aclose()
|
|
639
|
+
|
|
640
|
+
async def text(self) -> str:
|
|
641
|
+
"""Consume the entire stream and return the full text."""
|
|
642
|
+
if not self._text_chunks and not self._consumed:
|
|
643
|
+
async for _ in self:
|
|
644
|
+
pass
|
|
645
|
+
return "".join(self._text_chunks)
|
|
646
|
+
|
|
647
|
+
def _signal_cancel(self) -> None:
|
|
648
|
+
"""Signal-safe cancel — set flag and close, but do NOT raise.
|
|
649
|
+
|
|
650
|
+
Note: uses synchronous close even on the async stream because signal
|
|
651
|
+
handlers cannot be async. The httpx response handles this gracefully.
|
|
652
|
+
"""
|
|
653
|
+
self._cancelled = True
|
|
654
|
+
self._response.close()
|
|
655
|
+
|
|
656
|
+
async def cancel(self) -> None:
|
|
657
|
+
"""Cancel the stream and close the response.
|
|
658
|
+
|
|
659
|
+
Always raises ``StreamCancelledError``.
|
|
660
|
+
"""
|
|
661
|
+
self._cancelled = True
|
|
662
|
+
await self._response.aclose()
|
|
663
|
+
raise StreamCancelledError("Stream cancelled by client")
|