ragbits-chat 0.0.8.dev23005__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 (56) hide show
  1. ragbits/chat/__init__.py +87 -0
  2. ragbits/chat/_utils.py +40 -0
  3. ragbits/chat/adapters/__init__.py +56 -0
  4. ragbits/chat/adapters/builtin.py +375 -0
  5. ragbits/chat/adapters/pipeline.py +94 -0
  6. ragbits/chat/adapters/protocol.py +185 -0
  7. ragbits/chat/api.py +778 -0
  8. ragbits/chat/auth/__init__.py +11 -0
  9. ragbits/chat/auth/backends.py +177 -0
  10. ragbits/chat/auth/base.py +87 -0
  11. ragbits/chat/auth/types.py +73 -0
  12. ragbits/chat/cli.py +52 -0
  13. ragbits/chat/client/__init__.py +12 -0
  14. ragbits/chat/client/client.py +132 -0
  15. ragbits/chat/client/conversation.py +234 -0
  16. ragbits/chat/client/exceptions.py +11 -0
  17. ragbits/chat/history/__init__.py +0 -0
  18. ragbits/chat/history/compressors/__init__.py +4 -0
  19. ragbits/chat/history/compressors/base.py +31 -0
  20. ragbits/chat/history/compressors/llm.py +86 -0
  21. ragbits/chat/interface/__init__.py +3 -0
  22. ragbits/chat/interface/_interface.py +402 -0
  23. ragbits/chat/interface/forms.py +119 -0
  24. ragbits/chat/interface/summary.py +82 -0
  25. ragbits/chat/interface/types.py +879 -0
  26. ragbits/chat/interface/ui_customization.py +56 -0
  27. ragbits/chat/metrics.py +219 -0
  28. ragbits/chat/persistence/__init__.py +3 -0
  29. ragbits/chat/persistence/base.py +29 -0
  30. ragbits/chat/persistence/file.py +52 -0
  31. ragbits/chat/persistence/sql.py +297 -0
  32. ragbits/chat/providers/__init__.py +9 -0
  33. ragbits/chat/providers/model_provider.py +260 -0
  34. ragbits/chat/py.typed +0 -0
  35. ragbits/chat/ui-build/assets/AuthGuard-B326tmZN.js +1 -0
  36. ragbits/chat/ui-build/assets/ChatHistory-Cp_DhrUx.js +2 -0
  37. ragbits/chat/ui-build/assets/ChatOptionsForm-CNjzbIqN.js +1 -0
  38. ragbits/chat/ui-build/assets/FeedbackForm-CmRSbYPS.js +1 -0
  39. ragbits/chat/ui-build/assets/Login-Djq6QJ18.js +1 -0
  40. ragbits/chat/ui-build/assets/LogoutButton-Cn2L63Hk.js +1 -0
  41. ragbits/chat/ui-build/assets/ShareButton-lYj0v67r.js +1 -0
  42. ragbits/chat/ui-build/assets/UsageButton-B-N1J-sZ.js +1 -0
  43. ragbits/chat/ui-build/assets/authStore-DATNN-ps.js +1 -0
  44. ragbits/chat/ui-build/assets/chunk-IGSAU2ZA-CsJAveMU.js +1 -0
  45. ragbits/chat/ui-build/assets/chunk-SSA7SXE4-BJI2Gxdq.js +1 -0
  46. ragbits/chat/ui-build/assets/index-B3hlerKe.js +131 -0
  47. ragbits/chat/ui-build/assets/index-B7bSwAmw.js +32 -0
  48. ragbits/chat/ui-build/assets/index-C_JcEI3R.js +1 -0
  49. ragbits/chat/ui-build/assets/index-CmsICuOz.css +1 -0
  50. ragbits/chat/ui-build/assets/index-v15bx9Do.js +4 -0
  51. ragbits/chat/ui-build/assets/useMenuTriggerState-CTz3KfPq.js +1 -0
  52. ragbits/chat/ui-build/assets/useSelectableItem-DK6eABKK.js +1 -0
  53. ragbits/chat/ui-build/index.html +13 -0
  54. ragbits_chat-0.0.8.dev23005.dist-info/METADATA +44 -0
  55. ragbits_chat-0.0.8.dev23005.dist-info/RECORD +56 -0
  56. ragbits_chat-0.0.8.dev23005.dist-info/WHEEL +4 -0
@@ -0,0 +1,87 @@
1
+ from ragbits.chat.auth import (
2
+ AuthenticationBackend,
3
+ AuthenticationResponse,
4
+ ListAuthenticationBackend,
5
+ User,
6
+ UserCredentials,
7
+ )
8
+ from ragbits.chat.client import (
9
+ RagbitsChatClient,
10
+ RagbitsConversation,
11
+ SyncRagbitsChatClient,
12
+ SyncRagbitsConversation,
13
+ )
14
+ from ragbits.chat.interface.types import (
15
+ ChatResponse,
16
+ ChatResponseType,
17
+ ChatResponseUnion,
18
+ ClearMessageContent,
19
+ ClearMessageResponse,
20
+ ConversationIdContent,
21
+ ConversationIdResponse,
22
+ ConversationSummaryContent,
23
+ ConversationSummaryResponse,
24
+ ErrorContent,
25
+ ErrorResponse,
26
+ FollowupMessagesContent,
27
+ FollowupMessagesResponse,
28
+ ImageResponse,
29
+ LiveUpdateResponse,
30
+ Message,
31
+ MessageIdContent,
32
+ MessageIdResponse,
33
+ MessageRole,
34
+ Reference,
35
+ ReferenceResponse,
36
+ ResponseContent,
37
+ StateUpdate,
38
+ StateUpdateResponse,
39
+ TextContent,
40
+ TextResponse,
41
+ TodoItemContent,
42
+ TodoItemResponse,
43
+ UsageContent,
44
+ UsageResponse,
45
+ )
46
+
47
+ __all__ = [
48
+ "AuthenticationBackend",
49
+ "AuthenticationResponse",
50
+ "ChatResponse",
51
+ "ChatResponseType",
52
+ "ChatResponseUnion",
53
+ "ClearMessageContent",
54
+ "ClearMessageResponse",
55
+ "ConversationIdContent",
56
+ "ConversationIdResponse",
57
+ "ConversationSummaryContent",
58
+ "ConversationSummaryResponse",
59
+ "ErrorContent",
60
+ "ErrorResponse",
61
+ "FollowupMessagesContent",
62
+ "FollowupMessagesResponse",
63
+ "ImageResponse",
64
+ "ListAuthenticationBackend",
65
+ "LiveUpdateResponse",
66
+ "Message",
67
+ "MessageIdContent",
68
+ "MessageIdResponse",
69
+ "MessageRole",
70
+ "RagbitsChatClient",
71
+ "RagbitsConversation",
72
+ "Reference",
73
+ "ReferenceResponse",
74
+ "ResponseContent",
75
+ "StateUpdate",
76
+ "StateUpdateResponse",
77
+ "SyncRagbitsChatClient",
78
+ "SyncRagbitsConversation",
79
+ "TextContent",
80
+ "TextResponse",
81
+ "TodoItemContent",
82
+ "TodoItemResponse",
83
+ "UsageContent",
84
+ "UsageResponse",
85
+ "User",
86
+ "UserCredentials",
87
+ ]
ragbits/chat/_utils.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+
6
+ from pydantic import TypeAdapter
7
+
8
+ from .interface.types import ChatResponse, ChatResponseUnion
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def build_api_url(base_url: str, path: str) -> str:
14
+ """Join *base_url* and *path* preserving exactly one slash."""
15
+ base = base_url.rstrip("/")
16
+ if not path.startswith("/"):
17
+ path = f"/{path}"
18
+ return f"{base}{path}"
19
+
20
+
21
+ PREFIX = "data: "
22
+
23
+
24
+ def parse_sse_line(line: str) -> ChatResponse | None:
25
+ r"""Parse a single *Server-Sent-Event* line coming from RagbitsAPI.
26
+
27
+ Expected format: ``data: {....json....}\n``
28
+ Returns a *ChatResponse* instance or ``None`` if the line is not a
29
+ data line. Parsing/validation errors are logged (and ``None`` is returned).
30
+ """
31
+ if not line.startswith(PREFIX):
32
+ return None
33
+ try:
34
+ json_payload = line[len(PREFIX) :].strip()
35
+ data = json.loads(json_payload)
36
+ adapter: TypeAdapter[ChatResponseUnion] = TypeAdapter(ChatResponseUnion)
37
+ return adapter.validate_python(data)
38
+ except Exception as exc:
39
+ logger.error("Failed to parse SSE line: %s", exc, exc_info=True)
40
+ return None
@@ -0,0 +1,56 @@
1
+ """Response adapters for transforming chat response streams.
2
+
3
+ This module provides a composable adapter system for transforming chat responses
4
+ through a pipeline of adapters, each handling a specific concern.
5
+
6
+ Example:
7
+ >>> from ragbits.chat.adapters import (
8
+ ... AdapterPipeline,
9
+ ... ChatResponseAdapter,
10
+ ... FilterAdapter,
11
+ ... ToolResultTextAdapter,
12
+ ... )
13
+ >>>
14
+ >>> async def render_products(tool_call):
15
+ ... return f"Found {len(tool_call.result)} products"
16
+ >>>
17
+ >>> pipeline = AdapterPipeline([
18
+ ... ChatResponseAdapter(),
19
+ ... FilterAdapter(exclude_types=(SomeUICommand,)),
20
+ ... ToolResultTextAdapter(
21
+ ... renderers={"show_products": render_products},
22
+ ... pass_through=True,
23
+ ... ),
24
+ ... ])
25
+ >>>
26
+ >>> async for chunk in pipeline.process(chat_stream, context):
27
+ ... print(chunk)
28
+ """
29
+
30
+ from ragbits.chat.adapters.builtin import (
31
+ ChatResponseAdapter,
32
+ FilterAdapter,
33
+ TextAccumulatorAdapter,
34
+ ToolCallAccumulatorAdapter,
35
+ ToolResultTextAdapter,
36
+ UsageAggregatorAdapter,
37
+ )
38
+ from ragbits.chat.adapters.pipeline import AdapterPipeline
39
+ from ragbits.chat.adapters.protocol import (
40
+ AdapterContext,
41
+ BaseAdapter,
42
+ ResponseAdapter,
43
+ )
44
+
45
+ __all__ = [
46
+ "AdapterContext",
47
+ "AdapterPipeline",
48
+ "BaseAdapter",
49
+ "ChatResponseAdapter",
50
+ "FilterAdapter",
51
+ "ResponseAdapter",
52
+ "TextAccumulatorAdapter",
53
+ "ToolCallAccumulatorAdapter",
54
+ "ToolResultTextAdapter",
55
+ "UsageAggregatorAdapter",
56
+ ]
@@ -0,0 +1,375 @@
1
+ """Built-in adapters for common response transformations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from typing import TYPE_CHECKING, Any, AsyncGenerator
7
+
8
+ from ragbits.chat.adapters.protocol import AdapterContext, BaseAdapter
9
+
10
+ if TYPE_CHECKING:
11
+ from ragbits.agents.tool import ToolCallResult
12
+ from ragbits.chat.interface.types import ChatResponse
13
+ from ragbits.core.llms import Usage
14
+
15
+
16
+ class ChatResponseAdapter(BaseAdapter):
17
+ """Extracts text and content from ChatResponse objects.
18
+
19
+ This adapter handles the common case of extracting text from
20
+ ChatResponse objects yielded by production ChatInterface implementations.
21
+
22
+ Example:
23
+ >>> adapter = ChatResponseAdapter()
24
+ >>> pipeline = AdapterPipeline([adapter])
25
+ >>> # Processes TextResponse, extracts text, passes through other types
26
+ """
27
+
28
+ @property
29
+ def input_types(self) -> tuple[type, ...]:
30
+ # Import at runtime to avoid hard dependency
31
+ from ragbits.chat.interface.types import ChatResponse
32
+
33
+ return (ChatResponse,)
34
+
35
+ @property
36
+ def output_types(self) -> tuple[type, ...]:
37
+ return (str, object)
38
+
39
+ async def adapt(
40
+ self,
41
+ chunk: ChatResponse,
42
+ context: AdapterContext,
43
+ ) -> AsyncGenerator[str | Any, None]:
44
+ """Extract text and embedded content from ChatResponse.
45
+
46
+ Args:
47
+ chunk: ChatResponse to process.
48
+ context: Adapter context.
49
+
50
+ Yields:
51
+ Extracted text strings and embedded objects.
52
+ """
53
+ # Import types for isinstance checks
54
+ from ragbits.chat.interface.types import TextContent
55
+
56
+ content = chunk.content
57
+
58
+ # Handle TextContent - extract the text
59
+ if isinstance(content, TextContent):
60
+ if content.text:
61
+ yield content.text
62
+ # Handle other content types - yield the content itself
63
+ elif content is not None:
64
+ yield content
65
+
66
+
67
+ class ToolResultTextAdapter(BaseAdapter):
68
+ """Renders tool call results as human-readable text.
69
+
70
+ Use this adapter to make tool results visible in the conversation
71
+ by rendering them as text. Supports per-tool custom renderers.
72
+
73
+ Example:
74
+ >>> async def render_products(tool_call):
75
+ ... return f"Found {len(tool_call.result)} products"
76
+ ...
77
+ >>> adapter = ToolResultTextAdapter(
78
+ ... renderers={"show_products": render_products},
79
+ ... pass_through=True,
80
+ ... )
81
+ """
82
+
83
+ def __init__(
84
+ self,
85
+ renderers: dict[str, Callable[[ToolCallResult], Awaitable[str]]] | None = None,
86
+ default_renderer: Callable[[ToolCallResult], Awaitable[str]] | None = None,
87
+ pass_through: bool = True,
88
+ ) -> None:
89
+ """Initialize the adapter.
90
+
91
+ Args:
92
+ renderers: Tool name to async render function mapping.
93
+ default_renderer: Fallback renderer for tools without specific renderer.
94
+ pass_through: If True, also yield original ToolCallResult after text.
95
+ """
96
+ self._renderers = renderers or {}
97
+ self._default_renderer = default_renderer
98
+ self._pass_through = pass_through
99
+
100
+ @property
101
+ def input_types(self) -> tuple[type, ...]:
102
+ from ragbits.agents.tool import ToolCallResult
103
+
104
+ return (ToolCallResult,)
105
+
106
+ @property
107
+ def output_types(self) -> tuple[type, ...]:
108
+ from ragbits.agents.tool import ToolCallResult
109
+
110
+ return (str, ToolCallResult)
111
+
112
+ async def adapt(
113
+ self,
114
+ chunk: ToolCallResult,
115
+ context: AdapterContext,
116
+ ) -> AsyncGenerator[str | ToolCallResult, None]:
117
+ """Render tool result as text and optionally pass through.
118
+
119
+ Args:
120
+ chunk: ToolCallResult to process.
121
+ context: Adapter context.
122
+
123
+ Yields:
124
+ Rendered text and/or original ToolCallResult.
125
+ """
126
+ renderer = self._renderers.get(chunk.name, self._default_renderer)
127
+
128
+ if renderer:
129
+ text = await renderer(chunk)
130
+ if text:
131
+ yield text
132
+
133
+ if self._pass_through:
134
+ yield chunk
135
+
136
+
137
+ class FilterAdapter(BaseAdapter):
138
+ """Filters out chunks of specified types.
139
+
140
+ Use this adapter to remove unwanted types from the stream,
141
+ such as UI-specific commands that aren't needed for evaluation.
142
+
143
+ Example:
144
+ >>> adapter = FilterAdapter(
145
+ ... exclude_types=(ShowSlidersCommand, ShowOrderCommand),
146
+ ... )
147
+ >>> # These command types will be filtered out
148
+ """
149
+
150
+ def __init__(
151
+ self,
152
+ exclude_types: tuple[type, ...] = (),
153
+ include_types: tuple[type, ...] | None = None,
154
+ ) -> None:
155
+ """Initialize the adapter.
156
+
157
+ Args:
158
+ exclude_types: Types to filter out (blacklist).
159
+ include_types: If set, only these types pass through (whitelist).
160
+ Takes precedence over exclude_types.
161
+ """
162
+ self._exclude = exclude_types
163
+ self._include = include_types
164
+
165
+ @property
166
+ def input_types(self) -> tuple[type, ...]:
167
+ return (object,)
168
+
169
+ @property
170
+ def output_types(self) -> tuple[type, ...]:
171
+ return (object,)
172
+
173
+ async def adapt(
174
+ self,
175
+ chunk: Any,
176
+ context: AdapterContext,
177
+ ) -> AsyncGenerator[Any, None]:
178
+ """Filter chunks based on type.
179
+
180
+ Args:
181
+ chunk: Any chunk to potentially filter.
182
+ context: Adapter context.
183
+
184
+ Yields:
185
+ The chunk if it passes the filter, nothing otherwise.
186
+ """
187
+ if self._include is not None:
188
+ if isinstance(chunk, self._include):
189
+ yield chunk
190
+ elif not isinstance(chunk, self._exclude):
191
+ yield chunk
192
+
193
+
194
+ class TextAccumulatorAdapter(BaseAdapter):
195
+ """Accumulates text chunks into context for other adapters.
196
+
197
+ This adapter stores text in `context.text_parts` and optionally
198
+ passes through the original text chunks.
199
+
200
+ Example:
201
+ >>> adapter = TextAccumulatorAdapter(emit=True)
202
+ >>> # Text chunks are stored in context.text_parts and passed through
203
+ """
204
+
205
+ def __init__(self, emit: bool = True) -> None:
206
+ """Initialize the adapter.
207
+
208
+ Args:
209
+ emit: If True, also yield text chunks (pass-through).
210
+ """
211
+ self._emit = emit
212
+
213
+ @property
214
+ def input_types(self) -> tuple[type, ...]:
215
+ return (str,)
216
+
217
+ @property
218
+ def output_types(self) -> tuple[type, ...]:
219
+ return (str,) if self._emit else ()
220
+
221
+ async def adapt(
222
+ self,
223
+ chunk: str,
224
+ context: AdapterContext,
225
+ ) -> AsyncGenerator[str, None]:
226
+ """Accumulate text and optionally emit.
227
+
228
+ Args:
229
+ chunk: Text string to accumulate.
230
+ context: Adapter context.
231
+
232
+ Yields:
233
+ The text chunk if emit is True.
234
+ """
235
+ context.text_parts.append(chunk)
236
+ if self._emit:
237
+ yield chunk
238
+
239
+
240
+ class ToolCallAccumulatorAdapter(BaseAdapter):
241
+ """Accumulates tool calls into context for other adapters.
242
+
243
+ This adapter stores ToolCallResult in `context.tool_calls` and
244
+ optionally passes through the original chunks.
245
+
246
+ Example:
247
+ >>> adapter = ToolCallAccumulatorAdapter(emit=True)
248
+ >>> # Tool calls are stored in context.tool_calls and passed through
249
+ """
250
+
251
+ def __init__(self, emit: bool = True) -> None:
252
+ """Initialize the adapter.
253
+
254
+ Args:
255
+ emit: If True, also yield tool call chunks (pass-through).
256
+ """
257
+ self._emit = emit
258
+
259
+ @property
260
+ def input_types(self) -> tuple[type, ...]:
261
+ from ragbits.agents.tool import ToolCallResult
262
+
263
+ return (ToolCallResult,)
264
+
265
+ @property
266
+ def output_types(self) -> tuple[type, ...]:
267
+ from ragbits.agents.tool import ToolCallResult
268
+
269
+ return (ToolCallResult,) if self._emit else ()
270
+
271
+ async def adapt(
272
+ self,
273
+ chunk: ToolCallResult,
274
+ context: AdapterContext,
275
+ ) -> AsyncGenerator[ToolCallResult, None]:
276
+ """Accumulate tool call and optionally emit.
277
+
278
+ Args:
279
+ chunk: ToolCallResult to accumulate.
280
+ context: Adapter context.
281
+
282
+ Yields:
283
+ The tool call if emit is True.
284
+ """
285
+ context.tool_calls.append(chunk)
286
+ if self._emit:
287
+ yield chunk
288
+
289
+
290
+ class UsageAggregatorAdapter(BaseAdapter):
291
+ """Aggregates token usage across chunks.
292
+
293
+ This adapter accumulates Usage objects and can emit per-chunk
294
+ and/or aggregated usage at stream end.
295
+
296
+ Example:
297
+ >>> adapter = UsageAggregatorAdapter(
298
+ ... emit_per_chunk=False,
299
+ ... emit_aggregated_at_end=True,
300
+ ... )
301
+ >>> # Single aggregated Usage emitted at end
302
+ """
303
+
304
+ def __init__(
305
+ self,
306
+ emit_per_chunk: bool = False,
307
+ emit_aggregated_at_end: bool = True,
308
+ ) -> None:
309
+ """Initialize the adapter.
310
+
311
+ Args:
312
+ emit_per_chunk: If True, yield each Usage chunk as received.
313
+ emit_aggregated_at_end: If True, yield aggregated Usage at stream end.
314
+ """
315
+ self._emit_per_chunk = emit_per_chunk
316
+ self._emit_at_end = emit_aggregated_at_end
317
+ self._total: Usage | None = None
318
+
319
+ @property
320
+ def input_types(self) -> tuple[type, ...]:
321
+ from ragbits.core.llms import Usage
322
+
323
+ return (Usage,)
324
+
325
+ @property
326
+ def output_types(self) -> tuple[type, ...]:
327
+ from ragbits.core.llms import Usage
328
+
329
+ return (Usage,)
330
+
331
+ async def adapt(
332
+ self,
333
+ chunk: Usage,
334
+ context: AdapterContext,
335
+ ) -> AsyncGenerator[Usage, None]:
336
+ """Aggregate usage and optionally emit per-chunk.
337
+
338
+ Args:
339
+ chunk: Usage to aggregate.
340
+ context: Adapter context.
341
+
342
+ Yields:
343
+ The Usage chunk if emit_per_chunk is True.
344
+ """
345
+ if self._total is None:
346
+ self._total = chunk
347
+ else:
348
+ self._total = self._total + chunk
349
+
350
+ if self._emit_per_chunk:
351
+ yield chunk
352
+
353
+ async def on_stream_end(self, context: AdapterContext) -> AsyncGenerator[Usage, None]:
354
+ """Emit aggregated usage at stream end.
355
+
356
+ Args:
357
+ context: Adapter context.
358
+
359
+ Yields:
360
+ Aggregated Usage if emit_aggregated_at_end is True and we have data.
361
+ """
362
+ if self._emit_at_end and self._total is not None:
363
+ yield self._total
364
+
365
+ def get_total(self) -> Usage | None:
366
+ """Get the accumulated total usage.
367
+
368
+ Returns:
369
+ The aggregated Usage or None if no usage was recorded.
370
+ """
371
+ return self._total
372
+
373
+ def reset(self) -> None:
374
+ """Reset accumulated usage for reuse."""
375
+ self._total = None
@@ -0,0 +1,94 @@
1
+ """Adapter pipeline for composing multiple response adapters."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, AsyncGenerator
6
+
7
+ from ragbits.chat.adapters.protocol import AdapterContext, ResponseAdapter
8
+
9
+
10
+ class AdapterPipeline:
11
+ """Composes multiple adapters into a single transformation pipeline.
12
+
13
+ Chunks flow through adapters in order. Each adapter may:
14
+ - Transform a chunk (1 -> 1)
15
+ - Expand a chunk (1 -> N)
16
+ - Filter a chunk (1 -> 0)
17
+ - Pass through unchanged (for non-matching types)
18
+
19
+ Example:
20
+ >>> from ragbits.chat.adapters import (
21
+ ... AdapterPipeline,
22
+ ... ChatResponseAdapter,
23
+ ... FilterAdapter,
24
+ ... )
25
+ >>> pipeline = AdapterPipeline([
26
+ ... ChatResponseAdapter(),
27
+ ... FilterAdapter(exclude_types=(SomeCommand,)),
28
+ ... ])
29
+ >>> async for chunk in pipeline.process(stream, context):
30
+ ... print(chunk)
31
+ """
32
+
33
+ def __init__(self, adapters: list[ResponseAdapter] | None = None) -> None:
34
+ """Initialize the pipeline with a list of adapters.
35
+
36
+ Args:
37
+ adapters: List of adapters to compose. Order matters - chunks
38
+ flow through adapters in the order provided.
39
+ """
40
+ self._adapters: list[ResponseAdapter] = adapters or []
41
+
42
+ def add(self, adapter: ResponseAdapter) -> None:
43
+ """Add an adapter to the end of the pipeline.
44
+
45
+ Args:
46
+ adapter: Adapter to add.
47
+ """
48
+ self._adapters.append(adapter)
49
+
50
+ async def process(
51
+ self,
52
+ stream: AsyncGenerator[Any, None],
53
+ context: AdapterContext,
54
+ ) -> AsyncGenerator[Any, None]:
55
+ """Process a stream through all adapters.
56
+
57
+ Args:
58
+ stream: Input async generator of chunks.
59
+ context: Shared context for the current turn.
60
+
61
+ Yields:
62
+ Transformed chunks after passing through all adapters.
63
+ """
64
+ # Notify adapters of stream start
65
+ for adapter in self._adapters:
66
+ await adapter.on_stream_start(context)
67
+
68
+ # Process chunks through pipeline
69
+ async for chunk in stream:
70
+ results: list[Any] = [chunk]
71
+
72
+ for adapter in self._adapters:
73
+ next_results: list[Any] = []
74
+ for item in results:
75
+ if isinstance(item, adapter.input_types):
76
+ async for transformed in adapter.adapt(item, context):
77
+ next_results.append(transformed)
78
+ else:
79
+ # Pass through unchanged
80
+ next_results.append(item)
81
+ results = next_results
82
+
83
+ for result in results:
84
+ yield result
85
+
86
+ # Notify adapters of stream end and collect final emissions
87
+ for adapter in self._adapters:
88
+ async for final_chunk in adapter.on_stream_end(context):
89
+ yield final_chunk
90
+
91
+ def reset(self) -> None:
92
+ """Reset all adapters for reuse."""
93
+ for adapter in self._adapters:
94
+ adapter.reset()