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.
Files changed (64) hide show
  1. querri/__init__.py +51 -0
  2. querri/_auth.py +818 -0
  3. querri/_base_client.py +379 -0
  4. querri/_client.py +395 -0
  5. querri/_config.py +140 -0
  6. querri/_convenience.py +538 -0
  7. querri/_exceptions.py +180 -0
  8. querri/_pagination.py +278 -0
  9. querri/_streaming.py +663 -0
  10. querri/_user_client.py +193 -0
  11. querri/_version.py +1 -0
  12. querri/cli/__init__.py +21 -0
  13. querri/cli/_app.py +246 -0
  14. querri/cli/_context.py +216 -0
  15. querri/cli/_image.py +234 -0
  16. querri/cli/_output.py +293 -0
  17. querri/cli/audit.py +68 -0
  18. querri/cli/auth.py +556 -0
  19. querri/cli/chat.py +1255 -0
  20. querri/cli/chats.py +358 -0
  21. querri/cli/dashboards.py +294 -0
  22. querri/cli/embed.py +220 -0
  23. querri/cli/files.py +214 -0
  24. querri/cli/keys.py +304 -0
  25. querri/cli/policies.py +424 -0
  26. querri/cli/projects.py +916 -0
  27. querri/cli/sharing.py +403 -0
  28. querri/cli/sources.py +532 -0
  29. querri/cli/steps.py +123 -0
  30. querri/cli/usage.py +99 -0
  31. querri/cli/users.py +223 -0
  32. querri/cli/views.py +449 -0
  33. querri/cli/whoami.py +78 -0
  34. querri/py.typed +0 -0
  35. querri/resources/__init__.py +1 -0
  36. querri/resources/audit.py +119 -0
  37. querri/resources/dashboards.py +295 -0
  38. querri/resources/embed.py +268 -0
  39. querri/resources/files.py +145 -0
  40. querri/resources/keys.py +191 -0
  41. querri/resources/policies.py +492 -0
  42. querri/resources/projects.py +808 -0
  43. querri/resources/sharing.py +330 -0
  44. querri/resources/sources.py +511 -0
  45. querri/resources/usage.py +106 -0
  46. querri/resources/users.py +303 -0
  47. querri/resources/views.py +347 -0
  48. querri/types/__init__.py +104 -0
  49. querri/types/audit.py +21 -0
  50. querri/types/chat.py +51 -0
  51. querri/types/dashboard.py +51 -0
  52. querri/types/data.py +61 -0
  53. querri/types/embed.py +38 -0
  54. querri/types/file.py +18 -0
  55. querri/types/key.py +36 -0
  56. querri/types/policy.py +105 -0
  57. querri/types/project.py +158 -0
  58. querri/types/sharing.py +19 -0
  59. querri/types/usage.py +39 -0
  60. querri/types/user.py +33 -0
  61. querri-1.0.2.dist-info/METADATA +310 -0
  62. querri-1.0.2.dist-info/RECORD +64 -0
  63. querri-1.0.2.dist-info/WHEEL +4 -0
  64. 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")