chore: import upstream snapshot with attribution
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.vercel
|
||||
@@ -0,0 +1,20 @@
|
||||
from assistant_stream import create_run, RunController
|
||||
from assistant_stream.serialization import DataStreamResponse
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
import asyncio
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"])
|
||||
|
||||
|
||||
@app.post("/api/chat/completions")
|
||||
async def chat_completions():
|
||||
async def run(controller: RunController):
|
||||
controller.append_text("Hello ")
|
||||
await asyncio.sleep(1)
|
||||
controller.append_text("world.")
|
||||
|
||||
return DataStreamResponse(create_run(run))
|
||||
@@ -0,0 +1,2 @@
|
||||
assistant-stream==0.0.31
|
||||
fastapi==0.128.0
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Simon Farshid
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1 @@
|
||||
## assistant-stream
|
||||
@@ -0,0 +1,122 @@
|
||||
# LangGraph Integration for assistant-stream
|
||||
|
||||
This document describes the LangGraph integration for the assistant-stream package.
|
||||
|
||||
## Installation
|
||||
|
||||
To use the LangGraph integration, install the assistant-stream package with the langgraph extra:
|
||||
|
||||
```bash
|
||||
pip install assistant-stream[langgraph]
|
||||
```
|
||||
|
||||
This will install the required dependencies including `langchain-core`.
|
||||
|
||||
## Usage
|
||||
|
||||
The integration exposes `append_langgraph_event`, which folds the events produced by LangGraph's native streaming into the state managed by a `RunController`. It is meant to consume `graph.astream(..., stream_mode=["messages", "updates"])` output directly: the event type is the LangGraph stream mode name, and the payload is the raw chunk LangGraph yields for that mode.
|
||||
|
||||
### Function Signature
|
||||
|
||||
```python
|
||||
def append_langgraph_event(
|
||||
state: Any,
|
||||
_namespace: Any,
|
||||
type: str,
|
||||
payload: Any,
|
||||
) -> None
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
- **state**: The state to mutate, normally `controller.state`. It is read and written with dictionary-style access.
|
||||
- **_namespace**: The LangGraph namespace for the event. It is accepted for forward compatibility and is currently unused.
|
||||
- **type**: The LangGraph stream mode that produced the event, either `"messages"` or `"updates"`.
|
||||
- **payload**: The raw chunk LangGraph yields for that stream mode, described below.
|
||||
|
||||
### Event Types
|
||||
|
||||
#### Message events (`type="messages"`)
|
||||
|
||||
The payload is the `(message, metadata)` tuple that LangGraph yields in `"messages"` mode, where `message` is a single `BaseMessage` (often an `AIMessageChunk`) and `metadata` is currently unused.
|
||||
|
||||
The function will:
|
||||
|
||||
- Create a `messages` list in the state if it does not exist.
|
||||
- Convert the message to a plain dict with `model_dump()`.
|
||||
- Merge into an existing message when the `id` (or `tool_call_id`) matches: for an `AIMessageChunk` the message is merged with `add_ai_message_chunks`, then patched into state with granular `set` / `append-text` operations where possible. This lets streaming text and tool-call argument chunks update only the field that changed instead of sending the whole message again. If the shape cannot be represented safely as object-stream operations, the helper falls back to replacing the message.
|
||||
- Append the message when no existing id matches.
|
||||
|
||||
```python
|
||||
from langchain_core.messages import AIMessageChunk
|
||||
|
||||
# one chunk yielded by stream_mode="messages"
|
||||
chunk = (AIMessageChunk(content="Hello", id="msg1"), {})
|
||||
append_langgraph_event(controller.state, namespace, "messages", chunk)
|
||||
```
|
||||
|
||||
#### Updates events (`type="updates"`)
|
||||
|
||||
The payload is the `{node_name: {channel: value}}` dict that LangGraph yields in `"updates"` mode.
|
||||
|
||||
The function will:
|
||||
|
||||
- Write each channel value directly onto the state (`state[channel] = value`), so the node name is not retained.
|
||||
- Skip the `messages` channel, since messages are handled by message events.
|
||||
- Skip a node whose value is not a dict.
|
||||
|
||||
```python
|
||||
updates = {"weather_agent": {"status": "completed", "temperature": 72}}
|
||||
append_langgraph_event(controller.state, namespace, "updates", updates)
|
||||
# state now contains {"status": "completed", "temperature": 72}
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- The state holds plain JSON values (lists, dicts, str, int, bool, None); LangChain messages are converted with `model_dump()` before they are stored.
|
||||
- Event types other than `"messages"` and `"updates"` are ignored.
|
||||
|
||||
## Example Integration
|
||||
|
||||
The events come straight from `graph.astream`, so a run callback forwards each one to `append_langgraph_event`:
|
||||
|
||||
```python
|
||||
from assistant_stream import RunController, create_run
|
||||
from assistant_stream.modules.langgraph import append_langgraph_event
|
||||
from assistant_stream.serialization import DataStreamResponse
|
||||
|
||||
|
||||
async def run_callback(controller: RunController):
|
||||
async for namespace, event_type, chunk in graph.astream(
|
||||
{"messages": input_messages},
|
||||
stream_mode=["messages", "updates"],
|
||||
subgraphs=True,
|
||||
):
|
||||
append_langgraph_event(controller.state, namespace, event_type, chunk)
|
||||
|
||||
|
||||
stream = create_run(run_callback, state={})
|
||||
response = DataStreamResponse(stream)
|
||||
```
|
||||
|
||||
As the assistant message streams in, its chunks merge by id, so `controller.state["messages"]` ends with a single assistant message:
|
||||
|
||||
```python
|
||||
# controller.state
|
||||
# {
|
||||
# "messages": [
|
||||
# {"type": "human", "content": "What is the weather?", "id": "user1"},
|
||||
# {"type": "ai", "content": "The weather is sunny today.", "id": "ai1"},
|
||||
# ],
|
||||
# }
|
||||
```
|
||||
|
||||
See `python/assistant-transport-backend-langgraph` for a complete server built on this pattern, including subgraph state via `get_tool_call_subgraph_state`.
|
||||
|
||||
## Testing
|
||||
|
||||
The integration is covered by unit tests that exercise `append_langgraph_event` against a real state proxy:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/test_langgraph.py
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "assistant-stream"
|
||||
version = "0.0.34"
|
||||
description = ""
|
||||
authors = [
|
||||
{ name="Simon Farshid", email="simon@assistant-ui.com" },
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10,<4.0"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
dependencies = [
|
||||
"starlette>=0.37.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
langgraph = ["langchain-core>=0.3.0"]
|
||||
redis = ["redis>=5"]
|
||||
dev = ["pytest<8", "redis>=5"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/assistant-ui/assistant-ui"
|
||||
Issues = "https://github.com/assistant-ui/assistant-ui/issues"
|
||||
@@ -0,0 +1,20 @@
|
||||
from assistant_stream.serialization.assistant_stream_response import (
|
||||
AssistantStreamResponse,
|
||||
)
|
||||
from assistant_stream.create_run import (
|
||||
create_run,
|
||||
RunController,
|
||||
)
|
||||
|
||||
try:
|
||||
from assistant_stream.modules.langgraph import append_langgraph_event, get_tool_call_subgraph_state
|
||||
|
||||
__all__ = [
|
||||
"AssistantStreamResponse",
|
||||
"create_run",
|
||||
"RunController",
|
||||
"append_langgraph_event",
|
||||
"get_tool_call_subgraph_state",
|
||||
]
|
||||
except ImportError:
|
||||
__all__ = ["AssistantStreamResponse", "create_run", "RunController"]
|
||||
@@ -0,0 +1,99 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
|
||||
|
||||
|
||||
# Define the data classes for different chunk types
|
||||
@dataclass
|
||||
class TextDeltaChunk:
|
||||
text_delta: str
|
||||
type: str = "text-delta"
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReasoningDeltaChunk:
|
||||
reasoning_delta: str
|
||||
type: str = "reasoning-delta"
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCallBeginChunk:
|
||||
tool_call_id: str
|
||||
tool_name: str
|
||||
type: str = "tool-call-begin"
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCallDeltaChunk:
|
||||
tool_call_id: str
|
||||
args_text_delta: str
|
||||
type: str = "tool-call-delta"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolResultChunk:
|
||||
tool_call_id: str
|
||||
result: Any
|
||||
artifact: Any | None = None
|
||||
is_error: bool = False
|
||||
type: str = "tool-result"
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataChunk:
|
||||
data: Any
|
||||
type: str = "data"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ErrorChunk:
|
||||
error: str
|
||||
type: str = "error"
|
||||
|
||||
|
||||
# Define ObjectStream operation types as TypedDict
|
||||
class ObjectStreamSetOperation(TypedDict):
|
||||
path: List[str]
|
||||
value: Any
|
||||
type: Literal["set"]
|
||||
|
||||
|
||||
class ObjectStreamAppendTextOperation(TypedDict):
|
||||
path: List[str]
|
||||
value: str
|
||||
type: Literal["append-text"]
|
||||
|
||||
|
||||
ObjectStreamOperation = Union[ObjectStreamSetOperation, ObjectStreamAppendTextOperation]
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpdateStateChunk:
|
||||
operations: List[ObjectStreamOperation]
|
||||
type: str = "update-state"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceChunk:
|
||||
id: str
|
||||
url: str
|
||||
source_type: str = "url"
|
||||
title: Optional[str] = None
|
||||
type: str = "source"
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
|
||||
# Define the union type for AssistantStreamChunk
|
||||
AssistantStreamChunk = Union[
|
||||
TextDeltaChunk,
|
||||
ReasoningDeltaChunk,
|
||||
ToolCallBeginChunk,
|
||||
ToolCallDeltaChunk,
|
||||
ToolResultChunk,
|
||||
DataChunk,
|
||||
ErrorChunk,
|
||||
UpdateStateChunk,
|
||||
SourceChunk,
|
||||
]
|
||||
@@ -0,0 +1,275 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, AsyncGenerator, Callable, Coroutine, List, Optional, Sequence, Union
|
||||
from assistant_stream.assistant_stream_chunk import (
|
||||
AssistantStreamChunk,
|
||||
TextDeltaChunk,
|
||||
ReasoningDeltaChunk,
|
||||
ToolResultChunk,
|
||||
DataChunk,
|
||||
ErrorChunk,
|
||||
SourceChunk,
|
||||
ToolCallBeginChunk,
|
||||
)
|
||||
from assistant_stream.modules.tool_call import (
|
||||
create_tool_call,
|
||||
ToolCallController,
|
||||
generate_openai_style_tool_call_id,
|
||||
)
|
||||
from assistant_stream.state_manager import StateManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReadOnlyCancellationSignal:
|
||||
"""Read-only view over an asyncio.Event used for cancellation."""
|
||||
|
||||
def __init__(self, event: asyncio.Event):
|
||||
self._event = event
|
||||
|
||||
def is_set(self) -> bool:
|
||||
return self._event.is_set()
|
||||
|
||||
async def wait(self) -> bool:
|
||||
return await self._event.wait()
|
||||
|
||||
|
||||
class RunController:
|
||||
def __init__(self, queue, state_data, parent_id: Optional[str] = None):
|
||||
self._queue = queue
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._dispose_callbacks = []
|
||||
self._stream_tasks = []
|
||||
self._state_manager = StateManager(self._put_chunk_nowait, state_data)
|
||||
self._parent_id = parent_id
|
||||
self._cancelled_event = asyncio.Event()
|
||||
self._cancelled_signal = ReadOnlyCancellationSignal(self._cancelled_event)
|
||||
|
||||
def with_parent_id(self, parent_id: str) -> 'RunController':
|
||||
"""Create a new RunController instance with the specified parent_id."""
|
||||
controller = RunController(self._queue, self._state_manager._state_data, parent_id)
|
||||
controller._loop = self._loop
|
||||
controller._dispose_callbacks = self._dispose_callbacks
|
||||
controller._stream_tasks = self._stream_tasks
|
||||
controller._state_manager = self._state_manager
|
||||
controller._cancelled_event = self._cancelled_event
|
||||
controller._cancelled_signal = self._cancelled_signal
|
||||
return controller
|
||||
|
||||
def append_text(self, text_delta: str) -> None:
|
||||
"""Append a text delta to the stream."""
|
||||
chunk = TextDeltaChunk(text_delta=text_delta, parent_id=self._parent_id)
|
||||
self._flush_and_put_chunk(chunk)
|
||||
|
||||
def append_reasoning(self, reasoning_delta: str) -> None:
|
||||
"""Append a reasoning delta to the stream."""
|
||||
chunk = ReasoningDeltaChunk(reasoning_delta=reasoning_delta, parent_id=self._parent_id)
|
||||
self._flush_and_put_chunk(chunk)
|
||||
|
||||
def append_state_text(
|
||||
self, path: Sequence[Union[str, int]], text_delta: str
|
||||
) -> None:
|
||||
"""Append a text delta at a state path using an append-text operation."""
|
||||
self._state_manager.append_text(path, text_delta)
|
||||
|
||||
async def add_tool_call(
|
||||
self, tool_name: str, tool_call_id: str = None
|
||||
) -> ToolCallController:
|
||||
"""Add a tool call to the stream."""
|
||||
if tool_call_id is None:
|
||||
tool_call_id = generate_openai_style_tool_call_id()
|
||||
|
||||
stream, controller = await create_tool_call(tool_name, tool_call_id, self._parent_id)
|
||||
self._dispose_callbacks.append(controller.close)
|
||||
|
||||
self.add_stream(stream)
|
||||
return controller
|
||||
|
||||
def add_tool_result(self, tool_call_id: str, result: Any) -> None:
|
||||
"""Add a tool result to the stream."""
|
||||
chunk = ToolResultChunk(
|
||||
tool_call_id=tool_call_id,
|
||||
result=result,
|
||||
)
|
||||
self._flush_and_put_chunk(chunk)
|
||||
|
||||
def add_stream(self, stream: AsyncGenerator[AssistantStreamChunk, None]) -> None:
|
||||
"""Append a substream to the main stream."""
|
||||
|
||||
async def reader():
|
||||
async for chunk in stream:
|
||||
self._flush_and_put_chunk(chunk)
|
||||
|
||||
task = asyncio.create_task(reader())
|
||||
self._stream_tasks.append(task)
|
||||
|
||||
def add_data(self, data: Any) -> None:
|
||||
"""Emit an event to the main stream."""
|
||||
chunk = DataChunk(data=data)
|
||||
self._flush_and_put_chunk(chunk)
|
||||
|
||||
def add_error(self, error: str) -> None:
|
||||
"""Emit an error to the main stream."""
|
||||
chunk = ErrorChunk(error=error)
|
||||
self._flush_and_put_chunk(chunk)
|
||||
|
||||
def add_source(self, id: str, url: str, title: Optional[str] = None) -> None:
|
||||
"""Add a source to the stream."""
|
||||
chunk = SourceChunk(
|
||||
id=id,
|
||||
url=url,
|
||||
title=title,
|
||||
parent_id=self._parent_id
|
||||
)
|
||||
self._flush_and_put_chunk(chunk)
|
||||
|
||||
def _put_chunk_nowait(self, chunk):
|
||||
"""Helper method to put a chunk in the queue without waiting.
|
||||
|
||||
This is used as a callback for the StateManager.
|
||||
"""
|
||||
self._loop.call_soon_threadsafe(self._queue.put_nowait, chunk)
|
||||
|
||||
def _flush_and_put_chunk(self, chunk):
|
||||
"""Helper method to flush state operations and put a chunk in the queue.
|
||||
|
||||
This ensures state operations are sent before other operations.
|
||||
"""
|
||||
# Flush any pending state operations first
|
||||
self._state_manager.flush()
|
||||
# Add the chunk to the queue
|
||||
self._loop.call_soon_threadsafe(self._queue.put_nowait, chunk)
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Access the state proxy object for making state updates.
|
||||
|
||||
This property provides a proxy object that allows navigating to any path
|
||||
in the state, reading values, and setting values, which will trigger the
|
||||
appropriate state update operation.
|
||||
|
||||
If the state is None, this property returns None directly.
|
||||
You can set the root state directly by assigning to this property.
|
||||
|
||||
Example:
|
||||
controller.state = {"user": {"name": "John"}, "messages": [{"text": "Hi"}]}
|
||||
controller.state["user"]["name"] = "Bob"
|
||||
name = controller.state["user"]["name"]
|
||||
controller.state["messages"][0]["text"] += " chunk" # emits append-text once non-empty
|
||||
|
||||
# Explicit equivalent:
|
||||
controller.append_state_text(["messages", 0, "text"], " chunk")
|
||||
"""
|
||||
return self._state_manager.state
|
||||
|
||||
@state.setter
|
||||
def state(self, value):
|
||||
"""Set the entire state object.
|
||||
|
||||
Args:
|
||||
value: The new state value to set
|
||||
"""
|
||||
self._state_manager.add_operations(
|
||||
[{"type": "set", "path": [], "value": value}]
|
||||
)
|
||||
|
||||
@property
|
||||
def cancelled_event(self) -> ReadOnlyCancellationSignal:
|
||||
"""Expose cancellation signal for cooperative cancellation."""
|
||||
return self._cancelled_signal
|
||||
|
||||
@property
|
||||
def is_cancelled(self) -> bool:
|
||||
"""Return whether this run has been cancelled."""
|
||||
return self._cancelled_event.is_set()
|
||||
|
||||
def _mark_cancelled(self) -> None:
|
||||
"""Set cancellation signal once."""
|
||||
if not self._cancelled_event.is_set():
|
||||
self._cancelled_event.set()
|
||||
|
||||
|
||||
async def create_run(
|
||||
callback: Callable[[RunController], Coroutine[Any, Any, None]],
|
||||
*,
|
||||
state: Any | None = None,
|
||||
) -> AsyncGenerator[AssistantStreamChunk, None]:
|
||||
queue = asyncio.Queue()
|
||||
controller = RunController(queue, state_data=state)
|
||||
|
||||
async def background_task():
|
||||
try:
|
||||
await callback(controller)
|
||||
except Exception as e:
|
||||
controller.add_error(str(e))
|
||||
raise
|
||||
finally:
|
||||
# Flush any pending state updates before disposing
|
||||
controller._state_manager.flush()
|
||||
|
||||
for dispose in controller._dispose_callbacks:
|
||||
dispose()
|
||||
try:
|
||||
for task in controller._stream_tasks:
|
||||
await task
|
||||
finally:
|
||||
asyncio.get_running_loop().call_soon_threadsafe(queue.put_nowait, None)
|
||||
|
||||
task = asyncio.create_task(background_task())
|
||||
ended_normally = False
|
||||
|
||||
try:
|
||||
while True:
|
||||
chunk = await controller._queue.get()
|
||||
if chunk is None:
|
||||
ended_normally = True
|
||||
break
|
||||
yield chunk
|
||||
controller._queue.task_done()
|
||||
finally:
|
||||
if ended_normally:
|
||||
# The `None` sentinel is queued at the end of `background_task`, so
|
||||
# normal stream completion implies `task` is already done here.
|
||||
# `result()` preserves normal-path error propagation.
|
||||
task.result()
|
||||
else:
|
||||
controller._mark_cancelled()
|
||||
# Yield to the event loop to allow the cancel signal to propagate.
|
||||
await asyncio.sleep(0)
|
||||
if not task.done():
|
||||
# Give callbacks a brief chance to observe `is_cancelled`
|
||||
# and exit cooperatively before forcing cancellation.
|
||||
# 50ms keeps disconnect cleanup responsive without immediately
|
||||
# interrupting callbacks that can stop themselves quickly.
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=0.05)
|
||||
except asyncio.TimeoutError:
|
||||
# Timeout means cooperative shutdown did not finish in time.
|
||||
pass
|
||||
except Exception:
|
||||
# The stream consumer already disconnected, so suppress callback errors
|
||||
# but keep a log signal for postmortem debugging.
|
||||
logger.warning(
|
||||
"Suppressed callback exception during early-close grace period",
|
||||
exc_info=True,
|
||||
)
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
try:
|
||||
# `shield()` lets caller-initiated cancellation interrupt `aclose()`
|
||||
# without conflating it with our own forced `task.cancel()`.
|
||||
await asyncio.shield(task)
|
||||
except asyncio.CancelledError:
|
||||
if task.cancelled():
|
||||
# Expected forced cancellation for early-close cleanup.
|
||||
pass
|
||||
else:
|
||||
# Preserve caller-initiated cancellation (e.g. wait_for timeout).
|
||||
raise
|
||||
except Exception:
|
||||
# The stream consumer already disconnected, so suppress callback errors
|
||||
# but keep a log signal for postmortem debugging.
|
||||
logger.warning(
|
||||
"Suppressed callback exception after forced early-close cancellation",
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -0,0 +1,276 @@
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from assistant_stream.create_run import RunController
|
||||
from langchain_core.messages.ai import AIMessageChunk, add_ai_message_chunks
|
||||
from langchain_core.messages.tool import ToolMessage
|
||||
|
||||
|
||||
def _plain(value: Any) -> Any:
|
||||
if hasattr(value, "_get_value"):
|
||||
return value._get_value()
|
||||
return value
|
||||
|
||||
|
||||
def _message_matches(existing_message: Any, message_dict: Dict[str, Any]) -> bool:
|
||||
existing_message = _plain(existing_message)
|
||||
if not isinstance(existing_message, dict):
|
||||
return False
|
||||
|
||||
message_id = message_dict.get("id")
|
||||
if message_id is not None and existing_message.get("id") == message_id:
|
||||
return True
|
||||
|
||||
tool_call_id = message_dict.get("tool_call_id")
|
||||
return (
|
||||
tool_call_id is not None
|
||||
and existing_message.get("tool_call_id") == tool_call_id
|
||||
)
|
||||
|
||||
|
||||
def _find_existing_message_index(
|
||||
messages: Any, message_dict: Dict[str, Any]
|
||||
) -> int | None:
|
||||
for i, existing_message in enumerate(messages):
|
||||
if _message_matches(existing_message, message_dict):
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def _can_patch_value(current_value: Any, next_value: Any) -> bool:
|
||||
# Keep these guards in sync with _patch_child so the later mutation cannot
|
||||
# partially apply before discovering an unsupported deletion/truncation.
|
||||
current_value = _plain(current_value)
|
||||
|
||||
if current_value == next_value:
|
||||
return True
|
||||
|
||||
if isinstance(current_value, dict) and isinstance(next_value, dict):
|
||||
current_keys = set(current_value.keys())
|
||||
next_keys = set(next_value.keys())
|
||||
return current_keys.issubset(next_keys) and all(
|
||||
_can_patch_value(current_value[key], next_value[key])
|
||||
for key in current_keys
|
||||
)
|
||||
|
||||
if isinstance(current_value, list) and isinstance(next_value, list):
|
||||
return len(current_value) <= len(next_value) and all(
|
||||
_can_patch_value(item, next_value[index])
|
||||
for index, item in enumerate(current_value)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _patch_child(parent: Any, key: str | int, next_value: Any) -> None:
|
||||
"""Patch through StateProxy containers so writes emit granular object ops."""
|
||||
current_value = _plain(parent[key])
|
||||
|
||||
if current_value == next_value:
|
||||
return
|
||||
|
||||
if isinstance(current_value, dict) and isinstance(next_value, dict):
|
||||
current_keys = set(current_value.keys())
|
||||
next_keys = set(next_value.keys())
|
||||
if not current_keys.issubset(next_keys):
|
||||
raise ValueError("Cannot represent deleted dictionary keys as object ops")
|
||||
|
||||
target = parent[key]
|
||||
for child_key in next_keys:
|
||||
if child_key not in current_value:
|
||||
target[child_key] = next_value[child_key]
|
||||
else:
|
||||
_patch_child(target, child_key, next_value[child_key])
|
||||
return
|
||||
|
||||
if isinstance(current_value, list) and isinstance(next_value, list):
|
||||
if len(next_value) < len(current_value):
|
||||
raise ValueError("Cannot represent list truncation as object ops")
|
||||
|
||||
target = parent[key]
|
||||
for index, item in enumerate(next_value):
|
||||
if index >= len(current_value):
|
||||
target.append(item)
|
||||
else:
|
||||
_patch_child(target, index, item)
|
||||
return
|
||||
|
||||
parent[key] = next_value
|
||||
|
||||
|
||||
def _patch_message(messages: Any, index: int, next_message: Dict[str, Any]) -> None:
|
||||
current_message = _plain(messages[index])
|
||||
if not isinstance(current_message, dict) or not _can_patch_value(
|
||||
current_message, next_message
|
||||
):
|
||||
messages[index] = next_message
|
||||
return
|
||||
|
||||
_patch_child(messages, index, next_message)
|
||||
|
||||
|
||||
def append_langgraph_event(
|
||||
state: Dict[str, Any], _namespace: str, type: str, payload: Any
|
||||
) -> None:
|
||||
"""
|
||||
Append a LangGraph event to the state object.
|
||||
|
||||
Args:
|
||||
state: The state dictionary to update
|
||||
_namespace: Event namespace (currently unused)
|
||||
type: Event type ('messages' or 'updates')
|
||||
payload: Event payload containing the data to append
|
||||
"""
|
||||
|
||||
if type == "messages":
|
||||
if "messages" not in state:
|
||||
state["messages"] = []
|
||||
|
||||
message = payload[0]
|
||||
message_dict = message.model_dump()
|
||||
|
||||
# Check if this is an AIMessageChunk
|
||||
is_ai_message_chunk = message_dict.get("type") == "AIMessageChunk"
|
||||
if is_ai_message_chunk:
|
||||
message_dict["type"] = "ai"
|
||||
existing_message_index = _find_existing_message_index(
|
||||
state["messages"], message_dict
|
||||
)
|
||||
|
||||
if existing_message_index is not None:
|
||||
if is_ai_message_chunk:
|
||||
existing_message = _plain(state["messages"][existing_message_index])
|
||||
new_message_dict = add_ai_message_chunks(
|
||||
AIMessageChunk(**{**existing_message, "type": "AIMessageChunk"}),
|
||||
AIMessageChunk(**{**message_dict, "type": "AIMessageChunk"}),
|
||||
).model_dump()
|
||||
new_message_dict["type"] = "ai"
|
||||
_patch_message(
|
||||
state["messages"],
|
||||
existing_message_index,
|
||||
new_message_dict,
|
||||
)
|
||||
|
||||
else:
|
||||
state["messages"][existing_message_index] = message_dict
|
||||
else:
|
||||
state["messages"].append(message_dict)
|
||||
|
||||
elif type == "updates":
|
||||
for _node_name, channels in payload.items():
|
||||
if not isinstance(channels, dict):
|
||||
continue
|
||||
for channel_name, channel_value in channels.items():
|
||||
if channel_name == "messages":
|
||||
continue
|
||||
|
||||
state[channel_name] = channel_value
|
||||
|
||||
|
||||
def get_tool_call_subgraph_state(
|
||||
controller: RunController,
|
||||
namespace: Tuple[str, ...],
|
||||
subgraph_node: Union[str, List[str], Callable[[List[str]], bool]],
|
||||
default_state: Dict[str, Any],
|
||||
*,
|
||||
artifact_field_name: Optional[str] = None,
|
||||
tool_name: Union[str, List[str]] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the state for a tool call subgraph by traversing the namespace and checking for subgraph nodes.
|
||||
Ensures there's a ToolMessage as the last message and returns its artifact field value.
|
||||
|
||||
Args:
|
||||
controller: The run controller managing the state
|
||||
subgraph_node: Node name(s) to check against, or a function that checks node names
|
||||
namespace: Tuple of strings in format 'node_name:task_id'
|
||||
artifact_field_name: Optional field name to extract from artifact
|
||||
default_state: Default state to use if artifact field is None
|
||||
|
||||
Returns:
|
||||
The artifact field value from the ToolMessage. If the last message is already a ToolMessage,
|
||||
returns its artifact field. If it's an AI message with tool calls, creates a ToolMessage
|
||||
and returns the appropriate artifact field value.
|
||||
"""
|
||||
# Helper function to check if a node is a subgraph node
|
||||
def is_subgraph_node(node_name: str) -> bool:
|
||||
if isinstance(subgraph_node, str):
|
||||
return node_name == subgraph_node
|
||||
elif isinstance(subgraph_node, list):
|
||||
return node_name in subgraph_node
|
||||
elif callable(subgraph_node):
|
||||
return subgraph_node([node_name])
|
||||
return False
|
||||
|
||||
def is_subgraph_tool(tool: str) -> bool:
|
||||
if isinstance(tool_name, str):
|
||||
return tool == tool_name
|
||||
elif isinstance(tool_name, list):
|
||||
return tool in tool_name
|
||||
return True
|
||||
|
||||
# Start with the controller's state
|
||||
if controller.state is None:
|
||||
controller.state = default_state
|
||||
current_state = controller.state
|
||||
|
||||
# Traverse each level of the namespace
|
||||
for namespace_part in namespace:
|
||||
# Split the namespace part to get node_name
|
||||
node_name = namespace_part.split(':')[0]
|
||||
|
||||
# Check if this node is a subgraph node
|
||||
if is_subgraph_node(node_name):
|
||||
# Check for messages in the current state
|
||||
if "messages" not in current_state:
|
||||
return current_state
|
||||
|
||||
messages = current_state["messages"]
|
||||
if not messages or len(messages) == 0:
|
||||
return current_state
|
||||
|
||||
# Get the last message
|
||||
last_message = messages[-1]
|
||||
|
||||
# Check if it's an AI message
|
||||
if last_message["type"] == "ai":
|
||||
# Check if the AI message has tool calls
|
||||
tool_calls = last_message.get("tool_calls", [])
|
||||
if not tool_calls:
|
||||
# No tool calls, return current state
|
||||
return current_state
|
||||
|
||||
# Get the last tool call
|
||||
last_tool_call = tool_calls[-1]
|
||||
if not is_subgraph_tool(last_tool_call["name"]):
|
||||
return current_state
|
||||
|
||||
|
||||
# Create a new tool message for this tool call
|
||||
tool_message = ToolMessage(
|
||||
tool_call_id=last_tool_call["id"],
|
||||
name=last_tool_call["name"],
|
||||
artifact={} if artifact_field_name else default_state,
|
||||
content="",
|
||||
additional_kwargs={
|
||||
"streaming": True
|
||||
}
|
||||
).model_dump()
|
||||
|
||||
messages.append(tool_message)
|
||||
last_message = tool_message
|
||||
|
||||
# Check if last message is already a ToolMessage
|
||||
if last_message["type"] == "tool":
|
||||
# Last message is already a ToolMessage, extract and return artifact field
|
||||
if "artifact" not in last_message:
|
||||
last_message["artifact"] = {} if artifact_field_name else default_state
|
||||
artifact = last_message["artifact"]
|
||||
|
||||
if artifact_field_name:
|
||||
if artifact_field_name not in artifact:
|
||||
artifact[artifact_field_name] = default_state
|
||||
return artifact[artifact_field_name]
|
||||
else:
|
||||
return artifact
|
||||
|
||||
return current_state
|
||||
@@ -0,0 +1,305 @@
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
from assistant_stream.create_run import RunController
|
||||
from langchain_core.messages.base import BaseMessage, message_to_dict
|
||||
|
||||
|
||||
# def merge_ai_message_chunk_dicts(
|
||||
# chunk1_dict: Dict[str, Any], chunk2_dict: Dict[str, Any]
|
||||
# ) -> None:
|
||||
# """
|
||||
# Merge two AIMessageChunk dictionaries by modifying chunk1_dict in-place.
|
||||
|
||||
# This function is designed to work with proxy objects that send updates
|
||||
# over the network, so it only sets complete values at each field path.
|
||||
|
||||
# Args:
|
||||
# chunk1_dict: Dictionary representation of first AIMessageChunk (will be modified)
|
||||
# chunk2_dict: Dictionary representation of second AIMessageChunk
|
||||
|
||||
# Returns:
|
||||
# None (modifies chunk1_dict in-place)
|
||||
# """
|
||||
# # Merge content following merge_content logic
|
||||
# _merge_content_inplace(chunk1_dict, chunk2_dict)
|
||||
|
||||
# # Merge additional_kwargs
|
||||
# _merge_dict_field_inplace(chunk1_dict, chunk2_dict, "additional_kwargs")
|
||||
|
||||
# # Merge response_metadata
|
||||
# _merge_dict_field_inplace(chunk1_dict, chunk2_dict, "response_metadata")
|
||||
|
||||
# # Merge tool call chunks
|
||||
# _merge_tool_call_chunks_inplace(chunk1_dict, chunk2_dict)
|
||||
|
||||
# # Merge usage metadata
|
||||
# _merge_usage_metadata_inplace(chunk1_dict, chunk2_dict)
|
||||
|
||||
# # Handle ID selection (prefer non-run IDs)
|
||||
# _merge_id_inplace(chunk1_dict, chunk2_dict)
|
||||
|
||||
# # Ensure type is set
|
||||
# if "type" not in chunk1_dict:
|
||||
# chunk1_dict["type"] = "AIMessageChunk"
|
||||
|
||||
# # Handle example field - check they match if both present
|
||||
# _merge_example_inplace(chunk1_dict, chunk2_dict)
|
||||
|
||||
|
||||
# def _merge_content_inplace(
|
||||
# chunk1_dict: Dict[str, Any], chunk2_dict: Dict[str, Any]
|
||||
# ) -> None:
|
||||
# """Merge content fields following merge_content logic."""
|
||||
# content1 = chunk1_dict.get("content", "")
|
||||
# content2 = chunk2_dict.get("content", "")
|
||||
|
||||
# if isinstance(content1, str):
|
||||
# if isinstance(content2, str):
|
||||
# # Both are strings - concatenate
|
||||
# chunk1_dict["content"] += content2
|
||||
# elif isinstance(content2, list):
|
||||
# # First is string, second is list - prepend string to list
|
||||
# chunk1_dict["content"] = [content1] + content2
|
||||
# # If content2 is empty string, it's a no-op
|
||||
# elif isinstance(content1, list):
|
||||
# if isinstance(content2, str):
|
||||
# # First is list, second is string
|
||||
# if content1 and isinstance(content1[-1], str):
|
||||
# # If last element of list is string, append to it
|
||||
# content1[-1] += content2
|
||||
# elif content2 == "":
|
||||
# # Empty string is a no-op
|
||||
# pass
|
||||
# else:
|
||||
# # Otherwise append as new element
|
||||
# content1.append(content2)
|
||||
# elif isinstance(content2, list):
|
||||
# # Both are lists - merge them
|
||||
# chunk1_dict["content"] = _merge_lists(content1, content2)
|
||||
|
||||
|
||||
# def _merge_dict_field_inplace(
|
||||
# chunk1_dict: Dict[str, Any],
|
||||
# chunk2_dict: Dict[str, Any],
|
||||
# field_name: str
|
||||
# ) -> None:
|
||||
# """Merge a dictionary field from chunk2_dict into chunk1_dict."""
|
||||
# if field_name not in chunk2_dict:
|
||||
# return
|
||||
|
||||
# if field_name not in chunk1_dict:
|
||||
# chunk1_dict[field_name] = {}
|
||||
|
||||
# dict1 = chunk1_dict[field_name]
|
||||
# dict2 = chunk2_dict[field_name]
|
||||
|
||||
# for key, value in dict2.items():
|
||||
# if key not in dict1 or (value is not None and dict1[key] is None):
|
||||
# dict1[key] = value
|
||||
# elif value is None:
|
||||
# continue
|
||||
# elif type(dict1[key]) is not type(value):
|
||||
# raise TypeError(
|
||||
# f'{field_name}["{key}"] already exists in this message, '
|
||||
# "but with a different type."
|
||||
# )
|
||||
# elif isinstance(dict1[key], str):
|
||||
# dict1[key] += value
|
||||
# elif isinstance(dict1[key], dict):
|
||||
# # Recursively merge nested dictionaries
|
||||
# _merge_dicts_inplace(dict1[key], value)
|
||||
# elif isinstance(dict1[key], list):
|
||||
# # Merge lists following merge_lists logic
|
||||
# dict1[key] = _merge_lists(dict1[key], value)
|
||||
# elif dict1[key] == value:
|
||||
# continue
|
||||
# else:
|
||||
# raise TypeError(
|
||||
# f"{field_name} key {key} already exists in left dict and "
|
||||
# f"value has unsupported type {type(dict1[key])}."
|
||||
# )
|
||||
|
||||
|
||||
# def _merge_tool_call_chunks_inplace(
|
||||
# chunk1_dict: Dict[str, Any], chunk2_dict: Dict[str, Any]
|
||||
# ) -> None:
|
||||
# """Merge tool call chunks from chunk2_dict into chunk1_dict."""
|
||||
# tool_call_chunks1 = chunk1_dict.get("tool_call_chunks", [])
|
||||
# tool_call_chunks2 = chunk2_dict.get("tool_call_chunks", [])
|
||||
|
||||
# if tool_call_chunks2:
|
||||
# chunk1_dict["tool_call_chunks"] = _merge_lists(
|
||||
# tool_call_chunks1, tool_call_chunks2
|
||||
# )
|
||||
|
||||
|
||||
# def _merge_usage_metadata_inplace(
|
||||
# chunk1_dict: Dict[str, Any], chunk2_dict: Dict[str, Any]
|
||||
# ) -> None:
|
||||
# """Merge usage metadata following add_usage logic."""
|
||||
# usage1 = chunk1_dict.get("usage_metadata")
|
||||
# usage2 = chunk2_dict.get("usage_metadata")
|
||||
|
||||
# if usage1 or usage2:
|
||||
# if not usage1:
|
||||
# # Only usage2 exists
|
||||
# chunk1_dict["usage_metadata"] = dict(usage2)
|
||||
# elif not usage2:
|
||||
# # Only usage1 exists, already in place
|
||||
# pass
|
||||
# else:
|
||||
# # Both exist - merge them by adding numeric fields
|
||||
# merged_usage = {}
|
||||
|
||||
# # Start with all keys from usage1
|
||||
# for key, value in usage1.items():
|
||||
# merged_usage[key] = value
|
||||
|
||||
# # Add or merge keys from usage2
|
||||
# for key, value in usage2.items():
|
||||
# if key in merged_usage:
|
||||
# # For numeric values, add them
|
||||
# if isinstance(value, (int, float)) and isinstance(
|
||||
# merged_usage[key], (int, float)
|
||||
# ):
|
||||
# merged_usage[key] += value
|
||||
# # For dicts (like input_token_details), recursively merge
|
||||
# elif isinstance(value, dict) and isinstance(
|
||||
# merged_usage[key], dict
|
||||
# ):
|
||||
# merged_dict = dict(merged_usage[key])
|
||||
# for sub_key, sub_value in value.items():
|
||||
# if sub_key in merged_dict and isinstance(
|
||||
# sub_value, (int, float)
|
||||
# ):
|
||||
# merged_dict[sub_key] += sub_value
|
||||
# else:
|
||||
# merged_dict[sub_key] = sub_value
|
||||
# merged_usage[key] = merged_dict
|
||||
# else:
|
||||
# # For other types, take the new value
|
||||
# merged_usage[key] = value
|
||||
# else:
|
||||
# merged_usage[key] = value
|
||||
|
||||
# chunk1_dict["usage_metadata"] = merged_usage
|
||||
|
||||
|
||||
# def _merge_id_inplace(
|
||||
# chunk1_dict: Dict[str, Any], chunk2_dict: Dict[str, Any]
|
||||
# ) -> None:
|
||||
# """Handle ID selection, preferring non-run IDs."""
|
||||
# if "id" not in chunk2_dict:
|
||||
# return
|
||||
|
||||
# chunk2_id = chunk2_dict.get("id")
|
||||
# chunk1_id = chunk1_dict.get("id")
|
||||
|
||||
# # Only update ID if chunk2 has a better ID
|
||||
# if chunk2_id:
|
||||
# if not chunk1_id:
|
||||
# # No ID in chunk1, use chunk2's ID
|
||||
# chunk1_dict["id"] = chunk2_id
|
||||
# elif chunk1_id.startswith("run-") and not chunk2_id.startswith("run-"):
|
||||
# # Prefer non-run IDs
|
||||
# chunk1_dict["id"] = chunk2_id
|
||||
|
||||
|
||||
# def _merge_example_inplace(
|
||||
# chunk1_dict: Dict[str, Any], chunk2_dict: Dict[str, Any]
|
||||
# ) -> None:
|
||||
# """Handle example field merging with validation."""
|
||||
# if "example" in chunk1_dict and "example" in chunk2_dict:
|
||||
# if chunk1_dict["example"] != chunk2_dict["example"]:
|
||||
# raise ValueError(
|
||||
# "Cannot concatenate AIMessageChunks with different example values."
|
||||
# )
|
||||
# elif "example" in chunk2_dict:
|
||||
# chunk1_dict["example"] = chunk2_dict["example"]
|
||||
|
||||
|
||||
# def _merge_dicts_inplace(dict1: Dict[str, Any], dict2: Dict[str, Any]) -> None:
|
||||
# """
|
||||
# Recursively merge dict2 into dict1 in-place.
|
||||
|
||||
# Args:
|
||||
# dict1: Target dictionary (will be modified)
|
||||
# dict2: Source dictionary
|
||||
# """
|
||||
# for key, value in dict2.items():
|
||||
# if key not in dict1 or (value is not None and dict1[key] is None):
|
||||
# dict1[key] = value
|
||||
# elif value is None:
|
||||
# continue
|
||||
# elif type(dict1[key]) is not type(value):
|
||||
# raise TypeError(
|
||||
# f'Key "{key}" already exists in dictionary, '
|
||||
# "but with a different type."
|
||||
# )
|
||||
# elif isinstance(dict1[key], str):
|
||||
# dict1[key] += value
|
||||
# elif isinstance(dict1[key], dict):
|
||||
# _merge_dicts_inplace(dict1[key], value)
|
||||
# elif isinstance(dict1[key], list):
|
||||
# dict1[key] = _merge_lists(dict1[key], value)
|
||||
# elif dict1[key] == value:
|
||||
# continue
|
||||
# else:
|
||||
# raise TypeError(
|
||||
# f"Key {key} already exists in left dict and "
|
||||
# f"value has unsupported type {type(dict1[key])}."
|
||||
# )
|
||||
|
||||
|
||||
# def _merge_lists(
|
||||
# left: Optional[List[Any]], right: Optional[List[Any]]
|
||||
# ) -> Optional[List[Any]]:
|
||||
# """
|
||||
# Merge two lists following merge_lists logic.
|
||||
|
||||
# Handles special merging for elements with 'index' fields.
|
||||
|
||||
# Args:
|
||||
# left: First list (or None)
|
||||
# right: Second list (or None)
|
||||
|
||||
# Returns:
|
||||
# Merged list (or None if both inputs are None)
|
||||
# """
|
||||
# if right is None:
|
||||
# return left
|
||||
# if left is None:
|
||||
# return right.copy() if isinstance(right, list) else right
|
||||
|
||||
# merged = left.copy() if isinstance(left, list) else list(left)
|
||||
|
||||
# for e in right:
|
||||
# if isinstance(e, dict) and "index" in e and isinstance(e["index"], int):
|
||||
# # Find existing element with same index
|
||||
# to_merge = [
|
||||
# i
|
||||
# for i, e_left in enumerate(merged)
|
||||
# if isinstance(e_left, dict) and e_left.get("index") == e["index"]
|
||||
# ]
|
||||
# if to_merge:
|
||||
# # Merge with existing element at same index
|
||||
# # Remove 'type' key if present (following TODO in merge_lists)
|
||||
# new_e = (
|
||||
# {k: v for k, v in e.items() if k != "type"} if "type" in e else e
|
||||
# )
|
||||
# # Merge dictionaries
|
||||
# for key, value in new_e.items():
|
||||
# if key not in merged[to_merge[0]]:
|
||||
# merged[to_merge[0]][key] = value
|
||||
# elif isinstance(merged[to_merge[0]][key], str) and isinstance(
|
||||
# value, str
|
||||
# ):
|
||||
# merged[to_merge[0]][key] += value
|
||||
# else:
|
||||
# merged[to_merge[0]][key] = value
|
||||
# else:
|
||||
# merged.append(e)
|
||||
# else:
|
||||
# merged.append(e)
|
||||
|
||||
# return merged
|
||||
@@ -0,0 +1,92 @@
|
||||
import asyncio
|
||||
from typing import Any, AsyncGenerator
|
||||
from assistant_stream.assistant_stream_chunk import (
|
||||
AssistantStreamChunk,
|
||||
ToolCallBeginChunk,
|
||||
ToolCallDeltaChunk,
|
||||
ToolResultChunk,
|
||||
)
|
||||
import string
|
||||
import random
|
||||
|
||||
|
||||
def generate_openai_style_tool_call_id():
|
||||
prefix = "call_"
|
||||
characters = string.ascii_letters + string.digits
|
||||
random_id = "".join(random.choices(characters, k=24))
|
||||
return prefix + random_id
|
||||
|
||||
|
||||
class ToolCallController:
|
||||
def __init__(self, queue, tool_name: str, tool_call_id: str, parent_id: str = None):
|
||||
self.tool_name = tool_name
|
||||
self.tool_call_id = tool_call_id
|
||||
self.queue = queue
|
||||
self.loop = asyncio.get_running_loop()
|
||||
|
||||
begin_chunk = ToolCallBeginChunk(
|
||||
tool_call_id=self.tool_call_id,
|
||||
tool_name=self.tool_name,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
self.queue.put_nowait(begin_chunk)
|
||||
|
||||
def append_args_text(self, args_text_delta: str) -> None:
|
||||
"""Append an args text delta to the stream."""
|
||||
chunk = ToolCallDeltaChunk(
|
||||
tool_call_id=self.tool_call_id,
|
||||
args_text_delta=args_text_delta,
|
||||
)
|
||||
self.loop.call_soon_threadsafe(self.queue.put_nowait, chunk)
|
||||
|
||||
def set_result(self, result: Any) -> None:
|
||||
"""
|
||||
Set the result of the tool call.
|
||||
|
||||
Deprecated: Use set_response() instead.
|
||||
"""
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"set_result() is deprecated. Use set_response() instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self.set_response(result)
|
||||
|
||||
def set_response(
|
||||
self, result: Any, *, artifact: Any | None = None, is_error: bool = False
|
||||
) -> None:
|
||||
"""Set the result of the tool call."""
|
||||
|
||||
chunk = ToolResultChunk(
|
||||
tool_call_id=self.tool_call_id,
|
||||
result=result,
|
||||
artifact=artifact,
|
||||
is_error=is_error,
|
||||
)
|
||||
self.loop.call_soon_threadsafe(self.queue.put_nowait, chunk)
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the stream."""
|
||||
self.loop.call_soon_threadsafe(self.queue.put_nowait, None)
|
||||
|
||||
|
||||
async def create_tool_call(
|
||||
tool_name: str,
|
||||
tool_call_id: str,
|
||||
parent_id: str = None,
|
||||
) -> tuple[AsyncGenerator[AssistantStreamChunk, None], ToolCallController]:
|
||||
queue = asyncio.Queue()
|
||||
controller = ToolCallController(queue, tool_name, tool_call_id, parent_id)
|
||||
|
||||
async def stream():
|
||||
while True:
|
||||
chunk = await controller.queue.get()
|
||||
if chunk is None:
|
||||
break
|
||||
yield chunk
|
||||
controller.queue.task_done()
|
||||
|
||||
return stream(), controller
|
||||
@@ -0,0 +1,58 @@
|
||||
from contextlib import suppress
|
||||
|
||||
from assistant_stream.resumable.context import (
|
||||
ResumableStreamContext,
|
||||
create_resumable_stream_context,
|
||||
)
|
||||
from assistant_stream.resumable.errors import (
|
||||
DEFAULT_TTL_MS,
|
||||
ResumableStreamError,
|
||||
ResumableStreamErrorCode,
|
||||
validate_stream_id,
|
||||
)
|
||||
from assistant_stream.resumable.response import (
|
||||
RESUMABLE_STREAM_ID_HEADER,
|
||||
create_resumable_assistant_stream_response,
|
||||
create_resume_assistant_stream_response,
|
||||
)
|
||||
from assistant_stream.resumable.stores.in_memory import (
|
||||
create_in_memory_resumable_stream_store,
|
||||
)
|
||||
from assistant_stream.resumable.types import (
|
||||
CancellationSignal,
|
||||
ResumableStreamEntry,
|
||||
ResumableStreamRole,
|
||||
ResumableStreamStatus,
|
||||
ResumableStreamStore,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CancellationSignal",
|
||||
"DEFAULT_TTL_MS",
|
||||
"RESUMABLE_STREAM_ID_HEADER",
|
||||
"ResumableStreamContext",
|
||||
"ResumableStreamEntry",
|
||||
"ResumableStreamError",
|
||||
"ResumableStreamErrorCode",
|
||||
"ResumableStreamRole",
|
||||
"ResumableStreamStatus",
|
||||
"ResumableStreamStore",
|
||||
"create_in_memory_resumable_stream_store",
|
||||
"create_resumable_assistant_stream_response",
|
||||
"create_resumable_stream_context",
|
||||
"create_resume_assistant_stream_response",
|
||||
"validate_stream_id",
|
||||
]
|
||||
|
||||
with suppress(ImportError):
|
||||
from assistant_stream.resumable.stores.redis import (
|
||||
RedisLikeClient,
|
||||
RedisResumableStreamStore,
|
||||
create_redis_resumable_stream_store,
|
||||
)
|
||||
|
||||
__all__ += [
|
||||
"RedisLikeClient",
|
||||
"RedisResumableStreamStore",
|
||||
"create_redis_resumable_stream_store",
|
||||
]
|
||||
@@ -0,0 +1,165 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
from assistant_stream.resumable.errors import ResumableStreamError
|
||||
from assistant_stream.resumable.types import (
|
||||
ResumableStreamRole,
|
||||
ResumableStreamStatus,
|
||||
ResumableStreamStore,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_hook_logger = logging.getLogger("assistant_stream.resumable")
|
||||
|
||||
MakeStream = Callable[[], AsyncIterator[bytes]]
|
||||
WaitUntil = Callable[[asyncio.Task[None]], None]
|
||||
OnAcquire = Callable[[str, ResumableStreamRole], None]
|
||||
OnAppend = Callable[[str, int], None]
|
||||
OnFinalize = Callable[[str, Literal["done", "error"], str | None], None]
|
||||
OnError = Callable[[str, object], None]
|
||||
|
||||
|
||||
def _call_hook(hook: Callable[..., Any] | None, /, *args: Any) -> None:
|
||||
if hook is None:
|
||||
return
|
||||
try:
|
||||
hook(*args)
|
||||
except Exception:
|
||||
_hook_logger.debug("resumable stream hook failed: %r", hook, exc_info=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResumableStreamContext:
|
||||
_store: ResumableStreamStore
|
||||
_ttl_ms: int | None
|
||||
_wait_until: WaitUntil | None
|
||||
_on_acquire: OnAcquire | None
|
||||
_on_append: OnAppend | None
|
||||
_on_finalize: OnFinalize | None
|
||||
_on_error: OnError | None
|
||||
_tasks: set[asyncio.Task[None]] = field(default_factory=set, repr=False)
|
||||
|
||||
async def run(
|
||||
self, stream_id: str, make_stream: MakeStream
|
||||
) -> AsyncIterator[bytes]:
|
||||
role = await self._store.acquire(
|
||||
stream_id,
|
||||
ttl_ms=self._ttl_ms,
|
||||
)
|
||||
_call_hook(self._on_acquire, stream_id, role)
|
||||
if role == "producer":
|
||||
_start_producer_task(
|
||||
self._store,
|
||||
stream_id,
|
||||
make_stream,
|
||||
tasks=self._tasks,
|
||||
wait_until=self._wait_until,
|
||||
on_append=self._on_append,
|
||||
on_finalize=self._on_finalize,
|
||||
on_error=self._on_error,
|
||||
)
|
||||
return _read_from_store(self._store, stream_id)
|
||||
|
||||
async def resume(self, stream_id: str) -> AsyncIterator[bytes] | None:
|
||||
status = await self._store.status(stream_id)
|
||||
if status == "missing":
|
||||
return None
|
||||
return _read_from_store(self._store, stream_id)
|
||||
|
||||
async def require_resume(self, stream_id: str) -> AsyncIterator[bytes]:
|
||||
status = await self._store.status(stream_id)
|
||||
if status == "missing":
|
||||
raise ResumableStreamError(
|
||||
"missing",
|
||||
f"resumable stream not found: {stream_id}",
|
||||
)
|
||||
return _read_from_store(self._store, stream_id)
|
||||
|
||||
async def status(self, stream_id: str) -> ResumableStreamStatus:
|
||||
return await self._store.status(stream_id)
|
||||
|
||||
async def delete(self, stream_id: str) -> None:
|
||||
await self._store.delete(stream_id)
|
||||
|
||||
|
||||
def create_resumable_stream_context(
|
||||
*,
|
||||
store: ResumableStreamStore,
|
||||
ttl_ms: int | None = None,
|
||||
wait_until: WaitUntil | None = None,
|
||||
on_acquire: OnAcquire | None = None,
|
||||
on_append: OnAppend | None = None,
|
||||
on_finalize: OnFinalize | None = None,
|
||||
on_error: OnError | None = None,
|
||||
) -> ResumableStreamContext:
|
||||
return ResumableStreamContext(
|
||||
_store=store,
|
||||
_ttl_ms=ttl_ms,
|
||||
_wait_until=wait_until,
|
||||
_on_acquire=on_acquire,
|
||||
_on_append=on_append,
|
||||
_on_finalize=on_finalize,
|
||||
_on_error=on_error,
|
||||
)
|
||||
|
||||
|
||||
def _start_producer_task(
|
||||
store: ResumableStreamStore,
|
||||
stream_id: str,
|
||||
make_stream: MakeStream,
|
||||
*,
|
||||
tasks: set[asyncio.Task[None]],
|
||||
wait_until: WaitUntil | None,
|
||||
on_append: OnAppend | None,
|
||||
on_finalize: OnFinalize | None,
|
||||
on_error: OnError | None,
|
||||
) -> None:
|
||||
async def _pump() -> None:
|
||||
try:
|
||||
async for chunk in make_stream():
|
||||
await store.append(stream_id, chunk)
|
||||
_call_hook(on_append, stream_id, len(chunk))
|
||||
await store.finalize(stream_id, "done")
|
||||
_call_hook(on_finalize, stream_id, "done", None)
|
||||
except Exception as err:
|
||||
_call_hook(on_error, stream_id, err)
|
||||
message = str(err) if str(err) else repr(err)
|
||||
try:
|
||||
await store.finalize(stream_id, "error", message)
|
||||
_call_hook(on_finalize, stream_id, "error", message)
|
||||
except Exception as finalize_err:
|
||||
logger.error(
|
||||
"resumable stream finalize failed: %s", finalize_err
|
||||
)
|
||||
|
||||
task = asyncio.create_task(_pump())
|
||||
tasks.add(task)
|
||||
task.add_done_callback(tasks.discard)
|
||||
if wait_until is not None:
|
||||
wait_until(task)
|
||||
|
||||
def _done_callback(done_task: asyncio.Task[None]) -> None:
|
||||
try:
|
||||
exc = done_task.exception()
|
||||
except asyncio.CancelledError:
|
||||
return
|
||||
if exc is not None:
|
||||
logger.error("resumable producer task failed: %s", exc)
|
||||
|
||||
task.add_done_callback(_done_callback)
|
||||
|
||||
|
||||
async def _read_from_store(
|
||||
store: ResumableStreamStore, stream_id: str
|
||||
) -> AsyncIterator[bytes]:
|
||||
signal = asyncio.Event()
|
||||
try:
|
||||
async for entry in store.read(stream_id, "", signal):
|
||||
yield entry.chunk
|
||||
finally:
|
||||
signal.set()
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
|
||||
ResumableStreamErrorCode = Literal["missing", "exists", "finalized", "invalid-id"]
|
||||
|
||||
DEFAULT_TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
_STREAM_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.:-]{1,256}$")
|
||||
|
||||
|
||||
class ResumableStreamError(Exception):
|
||||
def __init__(self, code: ResumableStreamErrorCode, message: str) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
def validate_stream_id(stream_id: str) -> None:
|
||||
if not _STREAM_ID_PATTERN.fullmatch(stream_id):
|
||||
raise ResumableStreamError(
|
||||
"invalid-id",
|
||||
f"Invalid streamId: {stream_id} (must match {_STREAM_ID_PATTERN.pattern})",
|
||||
)
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
|
||||
from typing import Any
|
||||
|
||||
from starlette.responses import JSONResponse, Response, StreamingResponse
|
||||
|
||||
from assistant_stream.create_run import RunController, create_run
|
||||
from assistant_stream.resumable.context import ResumableStreamContext
|
||||
from assistant_stream.resumable.errors import ResumableStreamError
|
||||
from assistant_stream.serialization.data_stream import DataStreamEncoder
|
||||
from assistant_stream.serialization.stream_encoder import StreamEncoder
|
||||
|
||||
RESUMABLE_STREAM_ID_HEADER = "x-resumable-stream-id"
|
||||
|
||||
|
||||
async def create_resumable_assistant_stream_response(
|
||||
*,
|
||||
context: ResumableStreamContext,
|
||||
stream_id: str,
|
||||
callback: Callable[[RunController], Coroutine[Any, Any, None]],
|
||||
encoder: StreamEncoder | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
) -> Response:
|
||||
resolved_encoder = encoder if encoder is not None else DataStreamEncoder()
|
||||
|
||||
async def make_stream() -> AsyncIterator[bytes]:
|
||||
async for frame in resolved_encoder.encode_stream(create_run(callback)):
|
||||
yield frame.encode("utf-8")
|
||||
|
||||
body = await context.run(stream_id, make_stream)
|
||||
return StreamingResponse(
|
||||
body,
|
||||
media_type=resolved_encoder.get_media_type(),
|
||||
headers=_merge_headers(headers, stream_id),
|
||||
)
|
||||
|
||||
|
||||
async def create_resume_assistant_stream_response(
|
||||
*,
|
||||
context: ResumableStreamContext,
|
||||
stream_id: str,
|
||||
encoder: StreamEncoder | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
missing_response: Callable[[], Response] | None = None,
|
||||
) -> Response:
|
||||
try:
|
||||
body = await context.resume(stream_id)
|
||||
except ResumableStreamError as err:
|
||||
if err.code != "invalid-id":
|
||||
raise
|
||||
body = None
|
||||
if body is None:
|
||||
if missing_response is not None:
|
||||
return missing_response()
|
||||
return JSONResponse({"error": "stream not found"}, status_code=404)
|
||||
|
||||
resolved_encoder = encoder if encoder is not None else DataStreamEncoder()
|
||||
return StreamingResponse(
|
||||
body,
|
||||
media_type=resolved_encoder.get_media_type(),
|
||||
headers=_merge_headers(headers, stream_id),
|
||||
)
|
||||
|
||||
|
||||
def _merge_headers(
|
||||
extra: Mapping[str, str] | None, stream_id: str
|
||||
) -> dict[str, str]:
|
||||
merged: dict[str, str] = {}
|
||||
if extra is not None:
|
||||
for key, value in extra.items():
|
||||
if key.lower() != RESUMABLE_STREAM_ID_HEADER:
|
||||
merged[key] = value
|
||||
merged[RESUMABLE_STREAM_ID_HEADER] = stream_id
|
||||
return merged
|
||||
@@ -0,0 +1,18 @@
|
||||
from contextlib import suppress
|
||||
|
||||
from assistant_stream.resumable.stores.in_memory import (
|
||||
create_in_memory_resumable_stream_store,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"create_in_memory_resumable_stream_store",
|
||||
]
|
||||
|
||||
with suppress(ImportError):
|
||||
from assistant_stream.resumable.stores.redis import (
|
||||
create_redis_resumable_stream_store,
|
||||
)
|
||||
|
||||
__all__ += [
|
||||
"create_redis_resumable_stream_store",
|
||||
]
|
||||
@@ -0,0 +1,321 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
from assistant_stream.resumable.errors import (
|
||||
DEFAULT_TTL_MS,
|
||||
ResumableStreamError,
|
||||
validate_stream_id,
|
||||
)
|
||||
from assistant_stream.resumable.types import (
|
||||
CancellationSignal,
|
||||
ResumableStreamEntry,
|
||||
ResumableStreamRole,
|
||||
ResumableStreamStatus,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FinalizeMarker:
|
||||
kind: Literal["done", "error"]
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StreamState:
|
||||
entries: list[ResumableStreamEntry] = field(default_factory=list)
|
||||
next_seq: int = 1
|
||||
expires_at: float = 0.0
|
||||
ttl_ms: int = 0
|
||||
final: _FinalizeMarker | None = None
|
||||
waiters: list[asyncio.Event] = field(default_factory=list)
|
||||
|
||||
|
||||
def _cursor_of(seq: int) -> str:
|
||||
if seq == 0:
|
||||
return "0"
|
||||
alphabet = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
n = seq
|
||||
chars: list[str] = []
|
||||
while n:
|
||||
n, rem = divmod(n, 36)
|
||||
chars.append(alphabet[rem])
|
||||
return "".join(reversed(chars))
|
||||
|
||||
|
||||
def _seq_from_cursor(cursor: str) -> int:
|
||||
if cursor == "":
|
||||
return 0
|
||||
try:
|
||||
return int(cursor, 36)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
class _InMemoryResumableStreamStore:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
default_ttl_ms: int,
|
||||
now: Callable[[], float],
|
||||
max_chunk_bytes: int | None,
|
||||
max_entries_per_stream: int | None,
|
||||
max_streams: int | None,
|
||||
gc_interval_ms: int | None,
|
||||
) -> None:
|
||||
self._streams: dict[str, _StreamState] = {}
|
||||
self._default_ttl_ms = default_ttl_ms
|
||||
self._now = now
|
||||
self._max_chunk_bytes = max_chunk_bytes
|
||||
self._max_entries_per_stream = max_entries_per_stream
|
||||
self._max_streams = max_streams
|
||||
self._gc_interval_ms = gc_interval_ms
|
||||
self._gc_task: asyncio.Task[None] | None = None
|
||||
self._disposed = False
|
||||
|
||||
def _ensure_gc(self) -> None:
|
||||
if (
|
||||
self._gc_interval_ms is None
|
||||
or self._disposed
|
||||
or self._gc_task is not None
|
||||
):
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
interval_s = self._gc_interval_ms / 1000.0
|
||||
|
||||
async def _gc_loop() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(interval_s)
|
||||
self._evict_expired()
|
||||
|
||||
self._gc_task = loop.create_task(_gc_loop())
|
||||
|
||||
def _evict_expired(self) -> None:
|
||||
t = self._now()
|
||||
expired: list[tuple[str, _StreamState]] = []
|
||||
for stream_id, state in self._streams.items():
|
||||
if state.expires_at <= t:
|
||||
expired.append((stream_id, state))
|
||||
for stream_id, state in expired:
|
||||
del self._streams[stream_id]
|
||||
if state.final is None:
|
||||
state.final = _FinalizeMarker(kind="error", error="Stream expired")
|
||||
self._notify(state)
|
||||
|
||||
def _notify(self, state: _StreamState) -> None:
|
||||
waiters = state.waiters
|
||||
state.waiters = []
|
||||
for event in waiters:
|
||||
event.set()
|
||||
|
||||
def _find_start_index(self, state: _StreamState, cursor: str) -> int:
|
||||
if cursor == "":
|
||||
return 0
|
||||
after = _seq_from_cursor(cursor)
|
||||
lo = 0
|
||||
hi = len(state.entries)
|
||||
while lo < hi:
|
||||
mid = (lo + hi) // 2
|
||||
seq = _seq_from_cursor(state.entries[mid].cursor)
|
||||
if seq <= after:
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid
|
||||
return lo
|
||||
|
||||
async def _wait_for_update(
|
||||
self,
|
||||
state: _StreamState,
|
||||
signal: CancellationSignal,
|
||||
wake_by: float | None,
|
||||
) -> None:
|
||||
if signal.is_set():
|
||||
return
|
||||
if wake_by is not None and wake_by <= 0:
|
||||
return
|
||||
|
||||
event = asyncio.Event()
|
||||
state.waiters.append(event)
|
||||
|
||||
notify_task = asyncio.create_task(event.wait())
|
||||
signal_task = asyncio.create_task(signal.wait())
|
||||
tasks: set[asyncio.Task[object]] = {notify_task, signal_task}
|
||||
if wake_by is not None:
|
||||
tasks.add(asyncio.create_task(asyncio.sleep(wake_by / 1000.0)))
|
||||
|
||||
try:
|
||||
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
for task in tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
for task in tasks:
|
||||
with suppress(asyncio.CancelledError):
|
||||
await task
|
||||
with suppress(ValueError):
|
||||
state.waiters.remove(event)
|
||||
|
||||
def _require_active(self, stream_id: str) -> _StreamState:
|
||||
self._evict_expired()
|
||||
state = self._streams.get(stream_id)
|
||||
if state is None:
|
||||
raise RuntimeError(f"Stream not found: {stream_id}")
|
||||
if state.final is not None:
|
||||
raise ResumableStreamError(
|
||||
"finalized",
|
||||
f"Stream already finalized: {stream_id}",
|
||||
)
|
||||
return state
|
||||
|
||||
async def acquire(
|
||||
self, stream_id: str, *, ttl_ms: int | None = None
|
||||
) -> ResumableStreamRole:
|
||||
self._ensure_gc()
|
||||
validate_stream_id(stream_id)
|
||||
self._evict_expired()
|
||||
if stream_id in self._streams:
|
||||
return "consumer"
|
||||
|
||||
if self._max_streams is not None and len(self._streams) >= self._max_streams:
|
||||
raise RuntimeError("maxStreams exceeded")
|
||||
|
||||
resolved_ttl = ttl_ms if ttl_ms is not None else self._default_ttl_ms
|
||||
self._streams[stream_id] = _StreamState(
|
||||
entries=[],
|
||||
next_seq=1,
|
||||
expires_at=self._now() + resolved_ttl,
|
||||
ttl_ms=resolved_ttl,
|
||||
final=None,
|
||||
waiters=[],
|
||||
)
|
||||
return "producer"
|
||||
|
||||
async def append(self, stream_id: str, chunk: bytes) -> None:
|
||||
self._ensure_gc()
|
||||
validate_stream_id(stream_id)
|
||||
if (
|
||||
self._max_chunk_bytes is not None
|
||||
and len(chunk) > self._max_chunk_bytes
|
||||
):
|
||||
raise RuntimeError(f"Chunk exceeds maxChunkBytes: {len(chunk)}")
|
||||
state = self._require_active(stream_id)
|
||||
if (
|
||||
self._max_entries_per_stream is not None
|
||||
and len(state.entries) >= self._max_entries_per_stream
|
||||
):
|
||||
raise RuntimeError(f"Stream exceeded maxEntriesPerStream: {stream_id}")
|
||||
seq = state.next_seq
|
||||
state.next_seq += 1
|
||||
state.entries.append(ResumableStreamEntry(cursor=_cursor_of(seq), chunk=chunk))
|
||||
state.expires_at = self._now() + state.ttl_ms
|
||||
self._notify(state)
|
||||
|
||||
async def finalize(
|
||||
self,
|
||||
stream_id: str,
|
||||
status: Literal["done", "error"],
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
self._ensure_gc()
|
||||
validate_stream_id(stream_id)
|
||||
self._evict_expired()
|
||||
state = self._streams.get(stream_id)
|
||||
if state is None:
|
||||
raise RuntimeError(f"Stream not found: {stream_id}")
|
||||
if state.final is not None:
|
||||
return
|
||||
if status == "done":
|
||||
state.final = _FinalizeMarker(kind="done")
|
||||
else:
|
||||
state.final = _FinalizeMarker(
|
||||
kind="error", error=error if error is not None else "Stream errored"
|
||||
)
|
||||
state.expires_at = self._now() + state.ttl_ms
|
||||
self._notify(state)
|
||||
|
||||
async def read(
|
||||
self, stream_id: str, cursor: str, signal: CancellationSignal
|
||||
) -> AsyncIterator[ResumableStreamEntry]:
|
||||
self._ensure_gc()
|
||||
validate_stream_id(stream_id)
|
||||
self._evict_expired()
|
||||
state = self._streams.get(stream_id)
|
||||
if state is None:
|
||||
raise RuntimeError(f"Stream not found: {stream_id}")
|
||||
|
||||
idx = self._find_start_index(state, cursor)
|
||||
|
||||
while True:
|
||||
if signal.is_set():
|
||||
return
|
||||
|
||||
while idx < len(state.entries):
|
||||
if signal.is_set():
|
||||
return
|
||||
yield state.entries[idx]
|
||||
idx += 1
|
||||
|
||||
if state.final is not None:
|
||||
if state.final.kind == "error":
|
||||
raise RuntimeError(state.final.error or "Stream errored")
|
||||
return
|
||||
|
||||
wake_by = state.expires_at - self._now()
|
||||
await self._wait_for_update(state, signal, wake_by)
|
||||
self._evict_expired()
|
||||
|
||||
async def status(self, stream_id: str) -> ResumableStreamStatus:
|
||||
self._ensure_gc()
|
||||
validate_stream_id(stream_id)
|
||||
self._evict_expired()
|
||||
state = self._streams.get(stream_id)
|
||||
if state is None:
|
||||
return "missing"
|
||||
if state.final is None:
|
||||
return "streaming"
|
||||
return "error" if state.final.kind == "error" else "done"
|
||||
|
||||
async def delete(self, stream_id: str) -> None:
|
||||
self._ensure_gc()
|
||||
validate_stream_id(stream_id)
|
||||
state = self._streams.get(stream_id)
|
||||
if state is None:
|
||||
return
|
||||
del self._streams[stream_id]
|
||||
if state.final is None:
|
||||
state.final = _FinalizeMarker(kind="done")
|
||||
self._notify(state)
|
||||
|
||||
def dispose(self) -> None:
|
||||
self._disposed = True
|
||||
if self._gc_task is not None:
|
||||
self._gc_task.cancel()
|
||||
self._gc_task = None
|
||||
|
||||
|
||||
def create_in_memory_resumable_stream_store(
|
||||
*,
|
||||
default_ttl_ms: int = DEFAULT_TTL_MS,
|
||||
now: Callable[[], float] | None = None,
|
||||
max_chunk_bytes: int | None = None,
|
||||
max_entries_per_stream: int | None = None,
|
||||
max_streams: int | None = None,
|
||||
gc_interval_ms: int | None = None,
|
||||
) -> _InMemoryResumableStreamStore:
|
||||
return _InMemoryResumableStreamStore(
|
||||
default_ttl_ms=default_ttl_ms,
|
||||
now=now if now is not None else (lambda: time.time() * 1000),
|
||||
max_chunk_bytes=max_chunk_bytes,
|
||||
max_entries_per_stream=max_entries_per_stream,
|
||||
max_streams=max_streams,
|
||||
gc_interval_ms=gc_interval_ms,
|
||||
)
|
||||
@@ -0,0 +1,358 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import suppress
|
||||
from typing import Any, Literal, Protocol
|
||||
|
||||
from assistant_stream.resumable.errors import (
|
||||
DEFAULT_TTL_MS,
|
||||
ResumableStreamError,
|
||||
validate_stream_id,
|
||||
)
|
||||
from assistant_stream.resumable.types import (
|
||||
CancellationSignal,
|
||||
ResumableStreamEntry,
|
||||
ResumableStreamRole,
|
||||
ResumableStreamStatus,
|
||||
)
|
||||
|
||||
DEFAULT_POLL_INTERVAL_MS = 100
|
||||
DEFAULT_KEY_PREFIX = "aui:resumable"
|
||||
|
||||
FIELD_CHUNK = "c"
|
||||
FIELD_FIN = "fin"
|
||||
FIELD_ERROR = "error"
|
||||
|
||||
FIN_DONE = "done"
|
||||
FIN_ERROR = "error"
|
||||
|
||||
STREAM_START_ID = "0-0"
|
||||
|
||||
|
||||
class RedisLikeClient(Protocol):
|
||||
async def set_nx(self, key: str, value: str, ttl_sec: int) -> bool: ...
|
||||
|
||||
async def get(self, key: str) -> str | None: ...
|
||||
|
||||
async def exists(self, key: str) -> bool: ...
|
||||
|
||||
async def delete(self, keys: list[str]) -> None: ...
|
||||
|
||||
async def xrange(
|
||||
self, key: str, start: str, end: str
|
||||
) -> list[dict[str, Any]]: ...
|
||||
|
||||
async def pipeline(self, commands: list[dict[str, Any]]) -> None: ...
|
||||
|
||||
|
||||
class RedisResumableStreamStore:
|
||||
def __init__(
|
||||
self,
|
||||
client: RedisLikeClient,
|
||||
*,
|
||||
key_prefix: str = DEFAULT_KEY_PREFIX,
|
||||
default_ttl_ms: int = DEFAULT_TTL_MS,
|
||||
poll_interval_ms: int = DEFAULT_POLL_INTERVAL_MS,
|
||||
max_chunk_bytes: int | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._key_prefix = key_prefix
|
||||
self._default_ttl_ms = default_ttl_ms
|
||||
self._poll_interval_ms = poll_interval_ms
|
||||
self._max_chunk_bytes = max_chunk_bytes
|
||||
|
||||
def _meta_key(self, stream_id: str) -> str:
|
||||
return f"{self._key_prefix}:{{{stream_id}}}:meta"
|
||||
|
||||
def _data_key(self, stream_id: str) -> str:
|
||||
return f"{self._key_prefix}:{{{stream_id}}}:data"
|
||||
|
||||
async def _read_meta(self, stream_id: str) -> dict[str, Any] | None:
|
||||
raw = await self._client.get(self._meta_key(stream_id))
|
||||
if raw is None:
|
||||
return None
|
||||
return _parse_meta(raw)
|
||||
|
||||
async def acquire(
|
||||
self, stream_id: str, *, ttl_ms: int | None = None
|
||||
) -> ResumableStreamRole:
|
||||
validate_stream_id(stream_id)
|
||||
ttl_sec = _ms_to_sec(ttl_ms if ttl_ms is not None else self._default_ttl_ms)
|
||||
meta = json.dumps({"status": "streaming", "ttlSec": ttl_sec})
|
||||
acquired = await self._client.set_nx(self._meta_key(stream_id), meta, ttl_sec)
|
||||
if acquired:
|
||||
await self._client.delete([self._data_key(stream_id)])
|
||||
return "producer"
|
||||
return "consumer"
|
||||
|
||||
async def append(self, stream_id: str, chunk: bytes) -> None:
|
||||
validate_stream_id(stream_id)
|
||||
if (
|
||||
self._max_chunk_bytes is not None
|
||||
and len(chunk) > self._max_chunk_bytes
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"Chunk exceeds maxChunkBytes ({len(chunk)} > {self._max_chunk_bytes})"
|
||||
)
|
||||
data_key = self._data_key(stream_id)
|
||||
meta_key = self._meta_key(stream_id)
|
||||
meta = await self._read_meta(stream_id)
|
||||
if meta is None:
|
||||
raise RuntimeError(f"Stream not found: {stream_id}")
|
||||
if meta.get("status") != "streaming":
|
||||
raise ResumableStreamError(
|
||||
"finalized",
|
||||
f"Stream already finalized: {stream_id}",
|
||||
)
|
||||
ttl_sec = meta.get("ttlSec")
|
||||
if not isinstance(ttl_sec, int):
|
||||
ttl_sec = _ms_to_sec(self._default_ttl_ms)
|
||||
await self._client.pipeline(
|
||||
[
|
||||
{"type": "xAdd", "key": data_key, "fields": {FIELD_CHUNK: chunk}},
|
||||
{"type": "expire", "key": data_key, "ttlSec": ttl_sec},
|
||||
{"type": "expire", "key": meta_key, "ttlSec": ttl_sec},
|
||||
]
|
||||
)
|
||||
|
||||
async def finalize(
|
||||
self,
|
||||
stream_id: str,
|
||||
status: Literal["done", "error"],
|
||||
error: str | None = None,
|
||||
) -> None:
|
||||
validate_stream_id(stream_id)
|
||||
data_key = self._data_key(stream_id)
|
||||
meta_key = self._meta_key(stream_id)
|
||||
existing = await self._read_meta(stream_id)
|
||||
if existing is None:
|
||||
raise RuntimeError(f"Stream not found: {stream_id}")
|
||||
if existing.get("status") != "streaming":
|
||||
return
|
||||
ttl_sec = existing.get("ttlSec")
|
||||
if not isinstance(ttl_sec, int):
|
||||
ttl_sec = _ms_to_sec(self._default_ttl_ms)
|
||||
if status == "error":
|
||||
meta = json.dumps(
|
||||
{
|
||||
"status": "error",
|
||||
"error": error if error is not None else "Stream errored",
|
||||
"ttlSec": ttl_sec,
|
||||
}
|
||||
)
|
||||
else:
|
||||
meta = json.dumps({"status": "done", "ttlSec": ttl_sec})
|
||||
fields: dict[str, str] = {
|
||||
FIELD_FIN: FIN_ERROR if status == "error" else FIN_DONE,
|
||||
}
|
||||
if status == "error":
|
||||
fields[FIELD_ERROR] = error if error is not None else "Stream errored"
|
||||
await self._client.pipeline(
|
||||
[
|
||||
{"type": "set", "key": meta_key, "value": meta, "ttlSec": ttl_sec},
|
||||
{"type": "xAdd", "key": data_key, "fields": fields},
|
||||
{"type": "expire", "key": data_key, "ttlSec": ttl_sec},
|
||||
]
|
||||
)
|
||||
|
||||
async def read(
|
||||
self, stream_id: str, cursor: str, signal: CancellationSignal
|
||||
) -> AsyncIterator[ResumableStreamEntry]:
|
||||
validate_stream_id(stream_id)
|
||||
data_key = self._data_key(stream_id)
|
||||
meta_key = self._meta_key(stream_id)
|
||||
initial_meta = await self._client.get(meta_key)
|
||||
if initial_meta is None:
|
||||
raise RuntimeError(f"Stream not found: {stream_id}")
|
||||
|
||||
last_id = STREAM_START_ID if cursor == "" else cursor
|
||||
|
||||
while True:
|
||||
if signal.is_set():
|
||||
return
|
||||
|
||||
start = "-" if last_id == STREAM_START_ID else f"({last_id}"
|
||||
entries = await self._client.xrange(data_key, start, "+")
|
||||
|
||||
for entry in entries:
|
||||
if signal.is_set():
|
||||
return
|
||||
entry_id = entry["id"]
|
||||
fields = entry["fields"]
|
||||
last_id = entry_id
|
||||
|
||||
fin = _read_string(fields.get(FIELD_FIN))
|
||||
if fin == FIN_DONE:
|
||||
return
|
||||
if fin == FIN_ERROR:
|
||||
raise RuntimeError(
|
||||
_read_string(fields.get(FIELD_ERROR)) or "Stream errored"
|
||||
)
|
||||
|
||||
raw = fields.get(FIELD_CHUNK)
|
||||
if raw is None:
|
||||
continue
|
||||
yield ResumableStreamEntry(cursor=entry_id, chunk=_to_bytes(raw))
|
||||
|
||||
if len(entries) > 0:
|
||||
continue
|
||||
|
||||
still_exists = await self._client.exists(meta_key)
|
||||
if not still_exists:
|
||||
return
|
||||
|
||||
await _sleep(self._poll_interval_ms, signal)
|
||||
|
||||
async def status(self, stream_id: str) -> ResumableStreamStatus:
|
||||
validate_stream_id(stream_id)
|
||||
meta = await self._client.get(self._meta_key(stream_id))
|
||||
if meta is None:
|
||||
return "missing"
|
||||
parsed = _parse_meta(meta)
|
||||
if parsed is None:
|
||||
return "missing"
|
||||
status = parsed.get("status")
|
||||
if status == "streaming":
|
||||
return "streaming"
|
||||
if status == "done":
|
||||
return "done"
|
||||
if status == "error":
|
||||
return "error"
|
||||
return "missing"
|
||||
|
||||
async def delete(self, stream_id: str) -> None:
|
||||
validate_stream_id(stream_id)
|
||||
await self._client.delete(
|
||||
[self._meta_key(stream_id), self._data_key(stream_id)]
|
||||
)
|
||||
|
||||
|
||||
def _ms_to_sec(ms: int) -> int:
|
||||
return max(1, math.ceil(ms / 1000))
|
||||
|
||||
|
||||
def _parse_meta(value: str) -> dict[str, Any] | None:
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return None
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
async def _sleep(ms: int, signal: CancellationSignal) -> None:
|
||||
if signal.is_set():
|
||||
return
|
||||
with suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(signal.wait(), timeout=ms / 1000.0)
|
||||
|
||||
|
||||
def _read_string(value: str | bytes | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return value.decode("utf-8")
|
||||
|
||||
|
||||
def _to_bytes(value: str | bytes) -> bytes:
|
||||
if isinstance(value, bytes):
|
||||
return value
|
||||
return value.encode("utf-8")
|
||||
|
||||
|
||||
class _RedisAsyncioAdapter:
|
||||
def __init__(self, client: Any) -> None:
|
||||
self._client = client
|
||||
|
||||
async def set_nx(self, key: str, value: str, ttl_sec: int) -> bool:
|
||||
result = await self._client.set(key, value, nx=True, ex=ttl_sec)
|
||||
return result is True or result == b"OK" or result == "OK"
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
value = await self._client.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8")
|
||||
return str(value)
|
||||
|
||||
async def exists(self, key: str) -> bool:
|
||||
result = await self._client.exists(key)
|
||||
return int(result) > 0
|
||||
|
||||
async def delete(self, keys: list[str]) -> None:
|
||||
if not keys:
|
||||
return
|
||||
await self._client.delete(*keys)
|
||||
|
||||
async def xrange(
|
||||
self, key: str, start: str, end: str
|
||||
) -> list[dict[str, Any]]:
|
||||
reply = await self._client.xrange(key, min=start, max=end)
|
||||
out: list[dict[str, Any]] = []
|
||||
for entry_id, fields in reply:
|
||||
if isinstance(entry_id, bytes):
|
||||
entry_id = entry_id.decode("utf-8")
|
||||
parsed_fields: dict[str, str | bytes] = {}
|
||||
for field_key, field_value in fields.items():
|
||||
if isinstance(field_key, bytes):
|
||||
field_key = field_key.decode("utf-8")
|
||||
parsed_fields[field_key] = field_value
|
||||
out.append({"id": entry_id, "fields": parsed_fields})
|
||||
return out
|
||||
|
||||
async def pipeline(self, commands: list[dict[str, Any]]) -> None:
|
||||
if not commands:
|
||||
return
|
||||
pipe = self._client.pipeline()
|
||||
for cmd in commands:
|
||||
cmd_type = cmd["type"]
|
||||
if cmd_type == "xAdd":
|
||||
pipe.xadd(cmd["key"], dict(cmd["fields"]))
|
||||
elif cmd_type == "expire":
|
||||
pipe.expire(cmd["key"], cmd["ttlSec"])
|
||||
elif cmd_type == "set":
|
||||
pipe.set(cmd["key"], cmd["value"], ex=cmd["ttlSec"])
|
||||
await pipe.execute()
|
||||
|
||||
|
||||
def create_redis_resumable_stream_store(
|
||||
client: Any,
|
||||
*,
|
||||
key_prefix: str = DEFAULT_KEY_PREFIX,
|
||||
default_ttl_ms: int = DEFAULT_TTL_MS,
|
||||
poll_interval_ms: int = DEFAULT_POLL_INTERVAL_MS,
|
||||
max_chunk_bytes: int | None = None,
|
||||
) -> RedisResumableStreamStore:
|
||||
"""Create a store backed by a ``redis.asyncio.Redis`` client."""
|
||||
try:
|
||||
import redis.asyncio # noqa: F401
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"redis is required for create_redis_resumable_stream_store; "
|
||||
"install with: pip install assistant-stream[redis]"
|
||||
) from exc
|
||||
|
||||
connection_pool = getattr(client, "connection_pool", None)
|
||||
if connection_pool is not None:
|
||||
connection_kwargs = getattr(connection_pool, "connection_kwargs", None)
|
||||
if isinstance(connection_kwargs, dict) and connection_kwargs.get(
|
||||
"decode_responses"
|
||||
):
|
||||
raise ValueError(
|
||||
"redis client must be constructed without decode_responses "
|
||||
"(decode_responses=True cannot round-trip binary stream chunks)"
|
||||
)
|
||||
|
||||
return RedisResumableStreamStore(
|
||||
_RedisAsyncioAdapter(client),
|
||||
key_prefix=key_prefix,
|
||||
default_ttl_ms=default_ttl_ms,
|
||||
poll_interval_ms=poll_interval_ms,
|
||||
max_chunk_bytes=max_chunk_bytes,
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Protocol
|
||||
|
||||
|
||||
ResumableStreamRole = Literal["producer", "consumer"]
|
||||
ResumableStreamStatus = Literal["streaming", "done", "error", "missing"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResumableStreamEntry:
|
||||
cursor: str
|
||||
chunk: bytes
|
||||
|
||||
|
||||
class CancellationSignal(Protocol):
|
||||
def is_set(self) -> bool: ...
|
||||
|
||||
async def wait(self) -> bool: ...
|
||||
|
||||
|
||||
class ResumableStreamStore(Protocol):
|
||||
async def acquire(
|
||||
self, stream_id: str, *, ttl_ms: int | None = None
|
||||
) -> ResumableStreamRole: ...
|
||||
|
||||
async def append(self, stream_id: str, chunk: bytes) -> None: ...
|
||||
|
||||
async def finalize(
|
||||
self,
|
||||
stream_id: str,
|
||||
status: Literal["done", "error"],
|
||||
error: str | None = None,
|
||||
) -> None: ...
|
||||
|
||||
def read(
|
||||
self, stream_id: str, cursor: str, signal: CancellationSignal
|
||||
) -> AsyncIterator[ResumableStreamEntry]: ...
|
||||
|
||||
async def status(self, stream_id: str) -> ResumableStreamStatus: ...
|
||||
|
||||
async def delete(self, stream_id: str) -> None: ...
|
||||
@@ -0,0 +1,21 @@
|
||||
from assistant_stream.serialization.data_stream import (
|
||||
DataStreamEncoder,
|
||||
DataStreamResponse,
|
||||
)
|
||||
from assistant_stream.serialization.openai_stream import (
|
||||
OpenAIStreamEncoder,
|
||||
OpenAIStreamResponse,
|
||||
)
|
||||
from assistant_stream.serialization.assistant_transport import (
|
||||
AssistantTransportEncoder,
|
||||
AssistantTransportResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DataStreamEncoder",
|
||||
"DataStreamResponse",
|
||||
"OpenAIStreamEncoder",
|
||||
"OpenAIStreamResponse",
|
||||
"AssistantTransportEncoder",
|
||||
"AssistantTransportResponse",
|
||||
]
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
from assistant_stream.assistant_stream_chunk import AssistantStreamChunk
|
||||
from assistant_stream.serialization.stream_encoder import StreamEncoder
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
|
||||
class AssistantStreamResponse(StreamingResponse):
|
||||
def __init__(
|
||||
self,
|
||||
stream: AsyncGenerator[AssistantStreamChunk, None],
|
||||
stream_encoder: StreamEncoder,
|
||||
):
|
||||
super().__init__(
|
||||
stream_encoder.encode_stream(stream),
|
||||
media_type=stream_encoder.get_media_type(),
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
from assistant_stream.assistant_stream_chunk import AssistantStreamChunk
|
||||
from assistant_stream.serialization.assistant_stream_response import (
|
||||
AssistantStreamResponse,
|
||||
)
|
||||
from assistant_stream.serialization.stream_encoder import StreamEncoder
|
||||
from assistant_stream.state_proxy import StateProxy
|
||||
from typing import AsyncGenerator, Any
|
||||
import json
|
||||
|
||||
|
||||
class StateProxyJSONEncoder(json.JSONEncoder):
|
||||
"""Custom JSON encoder that can handle StateProxy objects."""
|
||||
|
||||
def default(self, obj: Any) -> Any:
|
||||
if isinstance(obj, StateProxy):
|
||||
return obj._get_value()
|
||||
return super().default(obj)
|
||||
|
||||
|
||||
class AssistantTransportEncoder(StreamEncoder):
|
||||
"""
|
||||
AssistantTransportEncoder encodes AssistantStreamChunks into SSE format
|
||||
and emits [DONE] when the stream completes.
|
||||
"""
|
||||
|
||||
def get_media_type(self) -> str:
|
||||
return "text/event-stream"
|
||||
|
||||
def _chunk_to_dict(self, chunk: AssistantStreamChunk) -> dict[str, Any]:
|
||||
"""Convert a chunk to a JSON-serializable dictionary."""
|
||||
chunk_dict = {"type": chunk.type}
|
||||
|
||||
# Add all attributes from the chunk
|
||||
for key, value in vars(chunk).items():
|
||||
if key != "type": # Already added
|
||||
chunk_dict[self._snake_to_camel(key)] = value
|
||||
|
||||
return chunk_dict
|
||||
|
||||
def _snake_to_camel(self, snake_str: str) -> str:
|
||||
"""Convert snake_case to camelCase."""
|
||||
components = snake_str.split("_")
|
||||
return components[0] + "".join(x.title() for x in components[1:])
|
||||
|
||||
async def encode_stream(
|
||||
self, stream: AsyncGenerator[AssistantStreamChunk, None]
|
||||
) -> AsyncGenerator[str, None]:
|
||||
async for chunk in stream:
|
||||
chunk_dict = self._chunk_to_dict(chunk)
|
||||
chunk_json = json.dumps(chunk_dict, cls=StateProxyJSONEncoder)
|
||||
yield f"data: {chunk_json}\n\n"
|
||||
|
||||
# Emit [DONE] marker when stream completes
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
|
||||
class AssistantTransportResponse(AssistantStreamResponse):
|
||||
def __init__(
|
||||
self,
|
||||
stream: AsyncGenerator[AssistantStreamChunk, None],
|
||||
):
|
||||
super().__init__(stream, AssistantTransportEncoder())
|
||||
@@ -0,0 +1,86 @@
|
||||
from assistant_stream.assistant_stream_chunk import (
|
||||
AssistantStreamChunk,
|
||||
)
|
||||
import json
|
||||
from typing import AsyncGenerator, Any
|
||||
from assistant_stream.serialization.assistant_stream_response import (
|
||||
AssistantStreamResponse,
|
||||
)
|
||||
from assistant_stream.serialization.stream_encoder import StreamEncoder
|
||||
from assistant_stream.state_proxy import StateProxy
|
||||
|
||||
|
||||
class StateProxyJSONEncoder(json.JSONEncoder):
|
||||
"""Custom JSON encoder that can handle StateProxy objects."""
|
||||
def default(self, obj: Any) -> Any:
|
||||
if isinstance(obj, StateProxy):
|
||||
return obj._get_value()
|
||||
return super().default(obj)
|
||||
|
||||
|
||||
class DataStreamEncoder(StreamEncoder):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def encode_chunk(self, chunk: AssistantStreamChunk) -> str:
|
||||
if chunk.type == "text-delta":
|
||||
if hasattr(chunk, 'parent_id') and chunk.parent_id:
|
||||
return f"aui-text-delta:{json.dumps({'textDelta': chunk.text_delta, 'parentId': chunk.parent_id}, cls=StateProxyJSONEncoder)}\n"
|
||||
else:
|
||||
return f"0:{json.dumps(chunk.text_delta, cls=StateProxyJSONEncoder)}\n"
|
||||
elif chunk.type == "reasoning-delta":
|
||||
if hasattr(chunk, 'parent_id') and chunk.parent_id:
|
||||
return f"aui-reasoning-delta:{json.dumps({'reasoningDelta': chunk.reasoning_delta, 'parentId': chunk.parent_id}, cls=StateProxyJSONEncoder)}\n"
|
||||
else:
|
||||
return f"g:{json.dumps(chunk.reasoning_delta, cls=StateProxyJSONEncoder)}\n"
|
||||
elif chunk.type == "tool-call-begin":
|
||||
data = {"toolCallId": chunk.tool_call_id, "toolName": chunk.tool_name}
|
||||
if hasattr(chunk, 'parent_id') and chunk.parent_id:
|
||||
data["parentId"] = chunk.parent_id
|
||||
return f'b:{json.dumps(data, cls=StateProxyJSONEncoder)}\n'
|
||||
elif chunk.type == "tool-call-delta":
|
||||
return f'c:{json.dumps({ "toolCallId": chunk.tool_call_id, "argsTextDelta": chunk.args_text_delta }, cls=StateProxyJSONEncoder)}\n'
|
||||
elif chunk.type == "tool-result":
|
||||
res = {"toolCallId": chunk.tool_call_id, "result": chunk.result}
|
||||
if chunk.artifact is not None:
|
||||
res["artifact"] = chunk.artifact
|
||||
if chunk.is_error:
|
||||
res["isError"] = chunk.is_error
|
||||
return f"a:{json.dumps(res, cls=StateProxyJSONEncoder)}\n"
|
||||
elif chunk.type == "data":
|
||||
return f"2:{json.dumps([chunk.data], cls=StateProxyJSONEncoder)}\n"
|
||||
elif chunk.type == "error":
|
||||
return f"3:{json.dumps(chunk.error, cls=StateProxyJSONEncoder)}\n"
|
||||
elif chunk.type == "source":
|
||||
source_data = {
|
||||
"sourceType": chunk.source_type,
|
||||
"id": chunk.id,
|
||||
"url": chunk.url
|
||||
}
|
||||
if chunk.title is not None:
|
||||
source_data["title"] = chunk.title
|
||||
if hasattr(chunk, 'parent_id') and chunk.parent_id:
|
||||
source_data["parentId"] = chunk.parent_id
|
||||
return f"h:{json.dumps(source_data, cls=StateProxyJSONEncoder)}\n"
|
||||
elif chunk.type == "update-state":
|
||||
return f"aui-state:{json.dumps(chunk.operations, cls=StateProxyJSONEncoder)}\n"
|
||||
|
||||
def get_media_type(self) -> str:
|
||||
return "text/plain"
|
||||
|
||||
async def encode_stream(
|
||||
self, stream: AsyncGenerator[AssistantStreamChunk, None]
|
||||
) -> AsyncGenerator[str, None]:
|
||||
async for chunk in stream:
|
||||
encoded = self.encode_chunk(chunk)
|
||||
if encoded is None:
|
||||
continue
|
||||
yield encoded
|
||||
|
||||
|
||||
class DataStreamResponse(AssistantStreamResponse):
|
||||
def __init__(
|
||||
self,
|
||||
stream: AsyncGenerator[AssistantStreamChunk, None],
|
||||
):
|
||||
super().__init__(stream, DataStreamEncoder())
|
||||
@@ -0,0 +1,82 @@
|
||||
from assistant_stream.assistant_stream_chunk import AssistantStreamChunk
|
||||
import json
|
||||
import time
|
||||
import string
|
||||
import random
|
||||
from typing import AsyncGenerator
|
||||
from assistant_stream.serialization.assistant_stream_response import (
|
||||
AssistantStreamResponse,
|
||||
)
|
||||
from assistant_stream.serialization.stream_encoder import StreamEncoder
|
||||
|
||||
|
||||
def generate_openai_style_id():
|
||||
prefix = "chatcmpl-"
|
||||
characters = string.ascii_letters + string.digits
|
||||
random_id = "".join(random.choices(characters, k=24))
|
||||
return prefix + random_id
|
||||
|
||||
|
||||
class OpenAIStreamEncoder(StreamEncoder):
|
||||
def __init__(self, model="assistant_stream", system_fingerprint="fp_0000000000"):
|
||||
self.id = generate_openai_style_id()
|
||||
self.model = model
|
||||
self.system_fingerprint = system_fingerprint
|
||||
pass
|
||||
|
||||
def get_media_type(self) -> str:
|
||||
return "text/event-stream"
|
||||
|
||||
def _create_chunk(self, delta={}, finish_reason=None):
|
||||
response = {
|
||||
"id": self.id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": self.model,
|
||||
"system_fingerprint": self.system_fingerprint,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": delta,
|
||||
"logprobs": None,
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
],
|
||||
}
|
||||
return f"data: {json.dumps(response, ensure_ascii=False)}\n\n"
|
||||
|
||||
def encode_chunk(self, chunk: AssistantStreamChunk) -> str:
|
||||
"""
|
||||
Encodes the chunk into OpenAI's SSE format.
|
||||
"""
|
||||
if chunk.type == "text-delta":
|
||||
# Construct the delta for text content
|
||||
return self._create_chunk({"content": chunk.text_delta})
|
||||
else:
|
||||
# Handle unknown chunk types gracefully
|
||||
return ""
|
||||
|
||||
async def encode_stream(
|
||||
self, stream: AsyncGenerator[AssistantStreamChunk, None]
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
Asynchronously encodes chunks into SSE-formatted strings.
|
||||
"""
|
||||
async for chunk in stream:
|
||||
encoded_chunk = self.encode_chunk(chunk)
|
||||
if encoded_chunk:
|
||||
yield encoded_chunk
|
||||
|
||||
yield self._create_chunk(finish_reason="stop")
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
|
||||
class OpenAIStreamResponse(AssistantStreamResponse):
|
||||
def __init__(
|
||||
self,
|
||||
stream: AsyncGenerator[AssistantStreamChunk, None],
|
||||
):
|
||||
"""
|
||||
Initializes the response with the OpenAI SSE encoder.
|
||||
"""
|
||||
super().__init__(stream, OpenAIStreamEncoder())
|
||||
@@ -0,0 +1,26 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import AsyncGenerator
|
||||
from assistant_stream.assistant_stream_chunk import AssistantStreamChunk
|
||||
|
||||
|
||||
class StreamEncoder(ABC):
|
||||
"""
|
||||
Abstract base class for stream encoders, requiring an implementation of `encode_stream`.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_media_type(self) -> str:
|
||||
"""
|
||||
Returns the MIME type of the stream.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def encode_stream(
|
||||
self, stream: AsyncGenerator[AssistantStreamChunk, None]
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
Encode the stream of AssistantStreamChunk into a specific format.
|
||||
This method must be implemented by subclasses.
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,208 @@
|
||||
import asyncio
|
||||
from typing import Any, Callable, Dict, List, Sequence, Union
|
||||
|
||||
from assistant_stream.assistant_stream_chunk import (
|
||||
ObjectStreamOperation,
|
||||
UpdateStateChunk,
|
||||
)
|
||||
from assistant_stream.state_proxy import StateProxy
|
||||
|
||||
|
||||
class StateManager:
|
||||
"""Manages state operations with efficient batching and local updates."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
put_chunk_callback: Callable[[UpdateStateChunk], None],
|
||||
state_data: Any | None = None,
|
||||
):
|
||||
"""Initialize with callback for sending state updates."""
|
||||
self._state_data = state_data
|
||||
self._pending_operations = []
|
||||
self._update_scheduled = False
|
||||
self._put_chunk_callback = put_chunk_callback
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._state_proxy = StateProxy(self, [])
|
||||
|
||||
@property
|
||||
def state(self) -> Any:
|
||||
"""Access the state proxy object for making state updates.
|
||||
|
||||
If state is None, returns None directly instead of a proxy.
|
||||
Otherwise returns a proxy object for the state.
|
||||
"""
|
||||
if self._state_data is None:
|
||||
return None
|
||||
return self._state_proxy
|
||||
|
||||
@property
|
||||
def state_data(self) -> Dict[str, Any]:
|
||||
"""Current state data."""
|
||||
return self._state_data
|
||||
|
||||
def add_operations(self, operations: List[ObjectStreamOperation]) -> None:
|
||||
"""Add operations to pending batch and apply locally."""
|
||||
# Apply to local state immediately
|
||||
for operation in operations:
|
||||
self._apply_operation_to_local_state(operation)
|
||||
|
||||
# Add to pending operations
|
||||
self._pending_operations.extend(operations)
|
||||
|
||||
# Schedule batch update if needed
|
||||
if not self._update_scheduled:
|
||||
self._update_scheduled = True
|
||||
self._loop.call_soon_threadsafe(self._flush_updates)
|
||||
|
||||
def append_text(self, path: Sequence[Union[str, int]], value: str) -> None:
|
||||
"""Append text at a path using an explicit append-text delta operation."""
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(
|
||||
f"Can only append str (not '{type(value).__name__}') as text delta"
|
||||
)
|
||||
|
||||
self.add_operations(
|
||||
[
|
||||
{
|
||||
"type": "append-text",
|
||||
"path": [str(segment) for segment in path],
|
||||
"value": value,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
def _flush_updates(self) -> None:
|
||||
"""Send pending operations as a batch."""
|
||||
if self._pending_operations:
|
||||
operations_to_send = self._pending_operations.copy()
|
||||
self._pending_operations.clear()
|
||||
self._put_chunk_callback(UpdateStateChunk(operations=operations_to_send))
|
||||
|
||||
self._update_scheduled = False
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Explicitly flush any pending operations.
|
||||
|
||||
This should be called before the run completes to ensure all state updates are sent.
|
||||
"""
|
||||
if self._pending_operations:
|
||||
self._flush_updates()
|
||||
|
||||
def _apply_operation_to_local_state(self, operation: ObjectStreamOperation) -> None:
|
||||
"""Apply operation to local state."""
|
||||
op_type = operation["type"]
|
||||
|
||||
if op_type == "set":
|
||||
self._update_path(operation["path"], lambda _: operation["value"])
|
||||
|
||||
elif op_type == "append-text":
|
||||
|
||||
def append_text(current):
|
||||
if not isinstance(current, str):
|
||||
path_str = ", ".join(operation["path"])
|
||||
raise TypeError(f"Expected string at path [{path_str}]")
|
||||
return current + operation["value"]
|
||||
|
||||
self._update_path(operation["path"], append_text)
|
||||
|
||||
else:
|
||||
raise TypeError(f"Invalid operation type: {op_type}")
|
||||
|
||||
def get_value_at_path(self, path: List[str]) -> Any:
|
||||
"""Get value at path, raising KeyError for invalid paths."""
|
||||
if not path:
|
||||
return self._state_data
|
||||
|
||||
# If state is None, we can't navigate further
|
||||
if self._state_data is None:
|
||||
raise KeyError(path[0] if path else "")
|
||||
|
||||
current = self._state_data
|
||||
|
||||
for key in path:
|
||||
try:
|
||||
if isinstance(current, list):
|
||||
idx = int(key)
|
||||
if idx < 0 or idx >= len(current):
|
||||
raise KeyError(key)
|
||||
current = current[idx]
|
||||
elif isinstance(current, dict):
|
||||
current = current[key]
|
||||
else:
|
||||
raise KeyError(key)
|
||||
except (ValueError, KeyError, IndexError):
|
||||
raise KeyError(key)
|
||||
|
||||
return current
|
||||
|
||||
def _update_path(self, path: List[str], updater: Callable[[Any], Any]) -> None:
|
||||
"""Update value at path without creating parent objects."""
|
||||
# Handle empty path (update root state)
|
||||
if not path:
|
||||
self._state_data = updater(self._state_data)
|
||||
return
|
||||
|
||||
# Initialize state as empty object if it's null
|
||||
if self._state_data is None:
|
||||
self._state_data = {}
|
||||
|
||||
if not isinstance(self._state_data, (dict, list)):
|
||||
raise KeyError(f"Invalid path: [{', '.join(path)}]")
|
||||
|
||||
key, *rest = path
|
||||
|
||||
# Handle list access
|
||||
if isinstance(self._state_data, list):
|
||||
try:
|
||||
idx = int(key)
|
||||
if idx < 0 or idx > len(self._state_data):
|
||||
raise KeyError(key)
|
||||
|
||||
if not rest:
|
||||
# For direct update
|
||||
if idx == len(self._state_data): # Append case
|
||||
value = updater(None)
|
||||
if value is not None:
|
||||
self._state_data.append(value)
|
||||
else: # Update existing element
|
||||
self._state_data[idx] = updater(self._state_data[idx])
|
||||
else:
|
||||
# For nested update
|
||||
if idx == len(self._state_data):
|
||||
raise KeyError(key)
|
||||
|
||||
# Create a copy for the nested update
|
||||
next_state = self._state_data.copy()
|
||||
|
||||
# Create a temporary manager for the nested path
|
||||
temp_manager = type(self)(lambda _: None)
|
||||
temp_manager._state_data = next_state[idx]
|
||||
|
||||
# Update nested path
|
||||
temp_manager._update_path(rest, updater)
|
||||
next_state[idx] = temp_manager._state_data
|
||||
self._state_data = next_state
|
||||
except ValueError:
|
||||
raise KeyError(key)
|
||||
else: # Handle dict access
|
||||
if not rest:
|
||||
# For direct update
|
||||
if key not in self._state_data and updater(None) is None:
|
||||
return
|
||||
self._state_data[key] = updater(self._state_data.get(key))
|
||||
else:
|
||||
# For nested update
|
||||
if key not in self._state_data:
|
||||
raise KeyError(key)
|
||||
|
||||
# Create a copy for the nested update
|
||||
next_state = dict(self._state_data)
|
||||
|
||||
# Create a temporary manager for the nested path
|
||||
temp_manager = type(self)(lambda _: None)
|
||||
temp_manager._state_data = next_state[key]
|
||||
|
||||
# Update nested path
|
||||
temp_manager._update_path(rest, updater)
|
||||
next_state[key] = temp_manager._state_data
|
||||
self._state_data = next_state
|
||||
@@ -0,0 +1,358 @@
|
||||
from typing import Any, List, Optional, Union, TYPE_CHECKING
|
||||
|
||||
|
||||
# Avoid circular import
|
||||
if TYPE_CHECKING:
|
||||
from assistant_stream.state_manager import StateManager
|
||||
|
||||
|
||||
class StateProxy:
|
||||
"""Proxy object for state access and updates using dictionary-style access.
|
||||
|
||||
Example:
|
||||
state_proxy["user"]["name"] = "John"
|
||||
name = state_proxy["user"]["name"]
|
||||
state_proxy["messages"] += "Hello"
|
||||
state_proxy["items"].append("item")
|
||||
"""
|
||||
|
||||
def _get_value(self):
|
||||
return self._manager.get_value_at_path(self._path)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
state_manager: "StateManager",
|
||||
path: Optional[List[str]] | None = None,
|
||||
) -> None:
|
||||
"""Initialize with state manager and current path."""
|
||||
self._manager = state_manager
|
||||
self._path = path or []
|
||||
|
||||
def __getitem__(self, key: Union[str, int]) -> Union["StateProxy", Any]:
|
||||
"""Access nested values with dict-style syntax. Returns primitives directly."""
|
||||
current_value = self._manager.get_value_at_path(self._path)
|
||||
|
||||
# Handle list indexing
|
||||
if isinstance(current_value, list):
|
||||
try:
|
||||
index = int(key)
|
||||
list_len = len(current_value)
|
||||
|
||||
# Handle negative indices
|
||||
if index < 0:
|
||||
index = list_len + index
|
||||
|
||||
# Validate index is in bounds
|
||||
if index < 0 or index >= list_len:
|
||||
raise KeyError(key)
|
||||
|
||||
# Use the normalized index as string key
|
||||
str_key = str(index)
|
||||
except (ValueError, TypeError):
|
||||
raise KeyError(key)
|
||||
else:
|
||||
# For dicts, use string representation of key
|
||||
str_key = str(key)
|
||||
|
||||
# Validate key exists
|
||||
if isinstance(current_value, dict):
|
||||
if str_key not in current_value:
|
||||
raise KeyError(key)
|
||||
elif not isinstance(current_value, list):
|
||||
raise KeyError(key)
|
||||
|
||||
# Get value at path
|
||||
value = self._manager.get_value_at_path(self._path + [str_key])
|
||||
|
||||
# Return primitives directly (including strings)
|
||||
if value is None or isinstance(value, (int, float, bool, str)):
|
||||
return value
|
||||
|
||||
# Return proxy only for collections
|
||||
return StateProxy(self._manager, self._path + [str_key])
|
||||
|
||||
def __setitem__(self, key: Union[str, int], value: Any) -> None:
|
||||
"""Set value with dict-style syntax."""
|
||||
current_value = self._manager.get_value_at_path(self._path)
|
||||
|
||||
# Handle list indexing
|
||||
if isinstance(current_value, list):
|
||||
try:
|
||||
index = int(key)
|
||||
list_len = len(current_value)
|
||||
|
||||
# Handle negative indices
|
||||
if index < 0:
|
||||
index = list_len + index
|
||||
|
||||
# Validate index is in bounds
|
||||
if index < 0 or index >= list_len:
|
||||
raise KeyError(key)
|
||||
|
||||
# Use the normalized index as string key
|
||||
str_key = str(index)
|
||||
except (ValueError, TypeError):
|
||||
raise KeyError(key)
|
||||
else:
|
||||
# For dicts and other types, use string representation of key
|
||||
str_key = str(key)
|
||||
|
||||
target_path = self._path + [str_key]
|
||||
|
||||
# Encode string extensions as append-text. Skip empty current values:
|
||||
# any.startswith("") matches all strings and would convert first writes too.
|
||||
try:
|
||||
current_target_value = self._manager.get_value_at_path(target_path)
|
||||
if (
|
||||
isinstance(current_target_value, str)
|
||||
and isinstance(value, str)
|
||||
and current_target_value
|
||||
and value.startswith(current_target_value)
|
||||
):
|
||||
delta = value[len(current_target_value) :]
|
||||
if delta:
|
||||
self._manager.append_text(target_path, delta)
|
||||
return
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
self._manager.add_operations(
|
||||
[{"type": "set", "path": target_path, "value": value}]
|
||||
)
|
||||
|
||||
def __iadd__(self, other: Any) -> "StateProxy":
|
||||
"""Support += on list-valued proxies.
|
||||
|
||||
String += on a leaf goes through __setitem__ instead, since
|
||||
__getitem__ returns the raw str rather than a proxy.
|
||||
"""
|
||||
current_value = self._manager.get_value_at_path(self._path)
|
||||
|
||||
# String concatenation
|
||||
if isinstance(current_value, str):
|
||||
if not isinstance(other, str):
|
||||
raise TypeError(
|
||||
f"Can only concatenate str (not '{type(other).__name__}') to str"
|
||||
)
|
||||
|
||||
self._manager.append_text(self._path, other)
|
||||
return self
|
||||
|
||||
# List extension
|
||||
if isinstance(current_value, list):
|
||||
try:
|
||||
# Ensure other is iterable
|
||||
iterator = iter(other)
|
||||
|
||||
# Add each item at the end of the list
|
||||
operations = []
|
||||
current_len = len(current_value)
|
||||
|
||||
for i, item in enumerate(iterator):
|
||||
operations.append(
|
||||
{
|
||||
"type": "set",
|
||||
"path": self._path + [str(current_len + i)],
|
||||
"value": item,
|
||||
}
|
||||
)
|
||||
|
||||
if operations:
|
||||
self._manager.add_operations(operations)
|
||||
return self
|
||||
except TypeError:
|
||||
raise TypeError(
|
||||
f"can only concatenate list (not '{type(other).__name__}') to list"
|
||||
)
|
||||
|
||||
raise TypeError(
|
||||
f"unsupported operand type(s) for +=: '{type(current_value).__name__}' and '{type(other).__name__}'"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation of the value."""
|
||||
return repr(self._manager.get_value_at_path(self._path))
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""String representation of the value."""
|
||||
return str(self._manager.get_value_at_path(self._path))
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Length of the value."""
|
||||
return len(self._manager.get_value_at_path(self._path))
|
||||
|
||||
def __contains__(self, item: Any) -> bool:
|
||||
"""Check if item is in the value."""
|
||||
return item in self._manager.get_value_at_path(self._path)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
"""Compare equality with another value."""
|
||||
return self._manager.get_value_at_path(self._path) == other
|
||||
|
||||
def __ne__(self, other: Any) -> bool:
|
||||
"""Compare inequality with another value."""
|
||||
return self._manager.get_value_at_path(self._path) != other
|
||||
|
||||
def __hash__(self) -> int:
|
||||
"""Hash the underlying value if hashable."""
|
||||
value = self._manager.get_value_at_path(self._path)
|
||||
if isinstance(value, (str, int, float, bool, tuple)):
|
||||
return hash(value)
|
||||
raise TypeError(f"unhashable type: '{type(value).__name__}'")
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""Truth value of the underlying value."""
|
||||
return bool(self._manager.get_value_at_path(self._path))
|
||||
|
||||
def __int__(self) -> int:
|
||||
"""Convert to int if possible."""
|
||||
return int(self._manager.get_value_at_path(self._path))
|
||||
|
||||
def __float__(self) -> float:
|
||||
"""Convert to float if possible."""
|
||||
return float(self._manager.get_value_at_path(self._path))
|
||||
|
||||
def __add__(self, other: Any) -> Any:
|
||||
"""Add operation for strings and lists."""
|
||||
value = self._manager.get_value_at_path(self._path)
|
||||
if isinstance(value, str) and isinstance(other, str):
|
||||
return value + other
|
||||
if isinstance(value, list) and hasattr(other, "__iter__"):
|
||||
return value + list(other)
|
||||
return NotImplemented
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""Forward attribute access to the underlying value."""
|
||||
value = self._manager.get_value_at_path(self._path)
|
||||
|
||||
# Handle string methods
|
||||
if isinstance(value, str):
|
||||
attr = getattr(value, name)
|
||||
if callable(attr):
|
||||
|
||||
def method_wrapper(*args, **kwargs):
|
||||
result = attr(*args, **kwargs)
|
||||
return self if result is value else result
|
||||
|
||||
return method_wrapper
|
||||
return attr
|
||||
|
||||
# Forward non-modifying methods for lists and dicts
|
||||
try:
|
||||
attr = getattr(value, name)
|
||||
if callable(attr):
|
||||
return lambda *args, **kwargs: attr(*args, **kwargs)
|
||||
return attr
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
raise AttributeError(
|
||||
f"'{type(value).__name__}' object has no attribute '{name}'"
|
||||
)
|
||||
|
||||
def __iter__(self):
|
||||
"""Make the proxy iterable."""
|
||||
return iter(self._manager.get_value_at_path(self._path))
|
||||
|
||||
# Efficient list operations
|
||||
def append(self, item: Any) -> None:
|
||||
"""Append an item to a list."""
|
||||
value = self._manager.get_value_at_path(self._path)
|
||||
if not isinstance(value, list):
|
||||
raise TypeError(f"'append' not supported for type {type(value).__name__}")
|
||||
|
||||
self._manager.add_operations(
|
||||
[{"type": "set", "path": self._path + [str(len(value))], "value": item}]
|
||||
)
|
||||
|
||||
def extend(self, iterable: Any) -> None:
|
||||
"""Extend a list with items from an iterable."""
|
||||
if isinstance(iterable, StateProxy):
|
||||
iterable = iterable._manager.get_value_at_path(iterable._path)
|
||||
self.__iadd__(iterable)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear a list or dictionary."""
|
||||
value = self._manager.get_value_at_path(self._path)
|
||||
|
||||
if isinstance(value, (list, dict)):
|
||||
empty_value = [] if isinstance(value, list) else {}
|
||||
self._manager.add_operations(
|
||||
[{"type": "set", "path": self._path, "value": empty_value}]
|
||||
)
|
||||
else:
|
||||
raise TypeError(f"'clear' not supported for type {type(value).__name__}")
|
||||
|
||||
# Dictionary operations
|
||||
def get(self, key: Any, default: Any = None) -> Any:
|
||||
"""Get dictionary value with default."""
|
||||
value = self._manager.get_value_at_path(self._path)
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError(f"'get' not supported for type {type(value).__name__}")
|
||||
|
||||
try:
|
||||
return self[key]
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def keys(self):
|
||||
"""Dictionary keys view."""
|
||||
value = self._manager.get_value_at_path(self._path)
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError(f"'keys' not supported for type {type(value).__name__}")
|
||||
return value.keys()
|
||||
|
||||
def values(self):
|
||||
"""Dictionary values view."""
|
||||
value = self._manager.get_value_at_path(self._path)
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError(f"'values' not supported for type {type(value).__name__}")
|
||||
return value.values()
|
||||
|
||||
def items(self):
|
||||
"""Dictionary items view."""
|
||||
value = self._manager.get_value_at_path(self._path)
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError(f"'items' not supported for type {type(value).__name__}")
|
||||
return value.items()
|
||||
|
||||
def setdefault(self, key, default=None):
|
||||
"""Set default value if key doesn't exist."""
|
||||
value = self._manager.get_value_at_path(self._path)
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError(
|
||||
f"'setdefault' not supported for type {type(value).__name__}"
|
||||
)
|
||||
|
||||
if key in value:
|
||||
return self[key]
|
||||
|
||||
self[key] = default
|
||||
return default
|
||||
|
||||
# Unsupported operations that would be inefficient
|
||||
def insert(self, index: int, item: Any) -> None:
|
||||
"""Not supported - would require sending entire list."""
|
||||
raise NotImplementedError("Use indexing or append() instead")
|
||||
|
||||
def pop(self, *args):
|
||||
"""Not supported - would require sending entire collection."""
|
||||
raise NotImplementedError(
|
||||
"Would require sending the entire collection over the network"
|
||||
)
|
||||
|
||||
def remove(self, item: Any) -> None:
|
||||
"""Not supported - would require sending entire list."""
|
||||
raise NotImplementedError(
|
||||
"Would require sending the entire list over the network"
|
||||
)
|
||||
|
||||
def update(self, *args, **kwargs):
|
||||
"""Not supported - would require sending entire dictionary."""
|
||||
raise NotImplementedError("Use individual assignments instead")
|
||||
|
||||
def popitem(self):
|
||||
"""Not supported - would require sending entire dictionary."""
|
||||
raise NotImplementedError(
|
||||
"Would require sending the entire dictionary over the network"
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import pytest
|
||||
from assistant_stream import create_run, RunController
|
||||
from assistant_stream.assistant_stream_chunk import UpdateStateChunk
|
||||
from assistant_stream.serialization.assistant_transport import AssistantTransportEncoder
|
||||
import json
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assistant_transport_encoder_format():
|
||||
"""Test that AssistantTransportEncoder produces SSE format."""
|
||||
encoder = AssistantTransportEncoder()
|
||||
collected_output = []
|
||||
|
||||
async def run_callback(controller: RunController):
|
||||
controller.append_text("Hello")
|
||||
controller.append_text(" world")
|
||||
|
||||
# Create the run and encode it
|
||||
chunks = create_run(run_callback)
|
||||
encoded = encoder.encode_stream(chunks)
|
||||
|
||||
async for line in encoded:
|
||||
collected_output.append(line)
|
||||
|
||||
# Verify SSE format
|
||||
assert len(collected_output) > 0
|
||||
|
||||
# All lines except the last should be SSE formatted chunks
|
||||
for line in collected_output[:-1]:
|
||||
assert line.startswith("data: ")
|
||||
assert line.endswith("\n\n")
|
||||
# Verify it's valid JSON (excluding the "data: " prefix and newlines)
|
||||
json_str = line[6:-2] # Remove "data: " and "\n\n"
|
||||
chunk_data = json.loads(json_str)
|
||||
assert "type" in chunk_data
|
||||
|
||||
# Last line should be [DONE]
|
||||
assert collected_output[-1] == "data: [DONE]\n\n"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assistant_transport_encoder_text_chunks():
|
||||
"""Test that text chunks are properly encoded."""
|
||||
encoder = AssistantTransportEncoder()
|
||||
collected_chunks = []
|
||||
|
||||
async def run_callback(controller: RunController):
|
||||
controller.append_text("Hello")
|
||||
controller.append_text(" world")
|
||||
|
||||
# Create the run and encode it
|
||||
chunks = create_run(run_callback)
|
||||
encoded = encoder.encode_stream(chunks)
|
||||
|
||||
async for line in encoded:
|
||||
if line != "data: [DONE]\n\n":
|
||||
json_str = line[6:-2] # Remove "data: " and "\n\n"
|
||||
chunk_data = json.loads(json_str)
|
||||
collected_chunks.append(chunk_data)
|
||||
|
||||
# Verify we got text-delta chunks with camelCase
|
||||
text_chunks = [c for c in collected_chunks if c["type"] == "text-delta"]
|
||||
assert len(text_chunks) == 2
|
||||
assert text_chunks[0]["textDelta"] == "Hello"
|
||||
assert text_chunks[1]["textDelta"] == " world"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assistant_transport_encoder_reasoning():
|
||||
"""Test that reasoning chunks are properly encoded."""
|
||||
encoder = AssistantTransportEncoder()
|
||||
collected_chunks = []
|
||||
|
||||
async def run_callback(controller: RunController):
|
||||
controller.append_reasoning("Thinking...")
|
||||
|
||||
# Create the run and encode it
|
||||
chunks = create_run(run_callback)
|
||||
encoded = encoder.encode_stream(chunks)
|
||||
|
||||
async for line in encoded:
|
||||
if line != "data: [DONE]\n\n":
|
||||
json_str = line[6:-2]
|
||||
chunk_data = json.loads(json_str)
|
||||
collected_chunks.append(chunk_data)
|
||||
|
||||
# Verify we got reasoning-delta chunks with camelCase
|
||||
reasoning_chunks = [c for c in collected_chunks if c["type"] == "reasoning-delta"]
|
||||
assert len(reasoning_chunks) == 1
|
||||
assert reasoning_chunks[0]["reasoningDelta"] == "Thinking..."
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assistant_transport_encoder_media_type():
|
||||
"""Test that the encoder returns the correct media type."""
|
||||
encoder = AssistantTransportEncoder()
|
||||
assert encoder.get_media_type() == "text/event-stream"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assistant_transport_encoder_tool_calls():
|
||||
"""Test that tool call chunks are properly encoded."""
|
||||
encoder = AssistantTransportEncoder()
|
||||
collected_chunks = []
|
||||
|
||||
async def run_callback(controller: RunController):
|
||||
tool_controller = await controller.add_tool_call("get_weather", "tool_1")
|
||||
tool_controller.append_args_text('{"location": "NYC"}')
|
||||
tool_controller.set_response({"temp": 70})
|
||||
|
||||
# Create the run and encode it
|
||||
chunks = create_run(run_callback)
|
||||
encoded = encoder.encode_stream(chunks)
|
||||
|
||||
async for line in encoded:
|
||||
if line != "data: [DONE]\n\n":
|
||||
json_str = line[6:-2]
|
||||
chunk_data = json.loads(json_str)
|
||||
collected_chunks.append(chunk_data)
|
||||
|
||||
# Verify we got tool-call chunks with camelCase
|
||||
tool_begin_chunks = [c for c in collected_chunks if c["type"] == "tool-call-begin"]
|
||||
assert len(tool_begin_chunks) == 1
|
||||
assert tool_begin_chunks[0]["toolCallId"] == "tool_1"
|
||||
assert tool_begin_chunks[0]["toolName"] == "get_weather"
|
||||
|
||||
tool_delta_chunks = [c for c in collected_chunks if c["type"] == "tool-call-delta"]
|
||||
assert len(tool_delta_chunks) > 0
|
||||
|
||||
tool_result_chunks = [c for c in collected_chunks if c["type"] == "tool-result"]
|
||||
assert len(tool_result_chunks) == 1
|
||||
assert tool_result_chunks[0]["toolCallId"] == "tool_1"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assistant_transport_encoder_update_state_shape():
|
||||
"""Test that update-state chunks preserve operation payload shape."""
|
||||
encoder = AssistantTransportEncoder()
|
||||
operations = [
|
||||
{"type": "append-text", "path": ["messages", "0", "text"], "value": "hi"}
|
||||
]
|
||||
|
||||
async def stream():
|
||||
yield UpdateStateChunk(operations=operations)
|
||||
|
||||
collected_output = [line async for line in encoder.encode_stream(stream())]
|
||||
|
||||
assert collected_output[-1] == "data: [DONE]\n\n"
|
||||
update_state_payload = json.loads(collected_output[0][6:-2])
|
||||
assert update_state_payload == {"type": "update-state", "operations": operations}
|
||||
@@ -0,0 +1,178 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from assistant_stream import RunController, create_run
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_controller_exposes_cancel_signal():
|
||||
observed: dict[str, object] = {}
|
||||
|
||||
async def run_callback(controller: RunController):
|
||||
observed["cancel_signal"] = controller.cancelled_event
|
||||
observed["initial_is_cancelled"] = controller.is_cancelled
|
||||
controller.append_text("hello")
|
||||
|
||||
chunks = [chunk async for chunk in create_run(run_callback)]
|
||||
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].type == "text-delta"
|
||||
cancel_signal = observed["cancel_signal"]
|
||||
assert callable(getattr(cancel_signal, "wait", None))
|
||||
assert callable(getattr(cancel_signal, "is_set", None))
|
||||
assert getattr(cancel_signal, "set", None) is None
|
||||
assert observed["initial_is_cancelled"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_early_stream_close_sets_cancel_signal():
|
||||
callback_done = asyncio.Event()
|
||||
observed: dict[str, object] = {}
|
||||
|
||||
async def run_callback(controller: RunController):
|
||||
controller.append_text("start")
|
||||
for _ in range(100):
|
||||
if controller.is_cancelled:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
observed["is_cancelled_after_close"] = controller.is_cancelled
|
||||
callback_done.set()
|
||||
|
||||
stream = create_run(run_callback)
|
||||
first_chunk = await anext(stream)
|
||||
assert first_chunk.type == "text-delta"
|
||||
|
||||
await stream.aclose()
|
||||
await asyncio.wait_for(callback_done.wait(), timeout=2)
|
||||
|
||||
assert observed["is_cancelled_after_close"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_early_stream_close_stops_background_task():
|
||||
callback_done = asyncio.Event()
|
||||
|
||||
async def run_callback(controller: RunController):
|
||||
controller.append_text("start")
|
||||
try:
|
||||
for _ in range(100):
|
||||
if controller.is_cancelled:
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
finally:
|
||||
callback_done.set()
|
||||
|
||||
stream = create_run(run_callback)
|
||||
first_chunk = await anext(stream)
|
||||
assert first_chunk.type == "text-delta"
|
||||
|
||||
await stream.aclose()
|
||||
await asyncio.wait_for(callback_done.wait(), timeout=0.2)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_normal_completion_does_not_set_cancel_signal():
|
||||
observed: dict[str, object] = {}
|
||||
|
||||
async def run_callback(controller: RunController):
|
||||
controller.append_text("done")
|
||||
observed["is_cancelled_on_complete"] = controller.is_cancelled
|
||||
|
||||
chunks = [chunk async for chunk in create_run(run_callback)]
|
||||
|
||||
assert len(chunks) == 1
|
||||
assert chunks[0].type == "text-delta"
|
||||
assert observed["is_cancelled_on_complete"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_normal_completion_surfaces_callback_exception():
|
||||
chunk_types: list[str] = []
|
||||
|
||||
async def run_callback(controller: RunController):
|
||||
controller.append_text("start")
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
async for chunk in create_run(run_callback):
|
||||
chunk_types.append(chunk.type)
|
||||
|
||||
assert chunk_types == ["text-delta", "error"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_early_stream_close_forces_background_task_cancellation():
|
||||
callback_cancelled = asyncio.Event()
|
||||
callback_finished = asyncio.Event()
|
||||
|
||||
async def run_callback(controller: RunController):
|
||||
controller.append_text("start")
|
||||
try:
|
||||
# Intentionally ignore `is_cancelled` to exercise forced cancellation.
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
except asyncio.CancelledError:
|
||||
callback_cancelled.set()
|
||||
raise
|
||||
finally:
|
||||
callback_finished.set()
|
||||
|
||||
stream = create_run(run_callback)
|
||||
first_chunk = await anext(stream)
|
||||
assert first_chunk.type == "text-delta"
|
||||
|
||||
await stream.aclose()
|
||||
await asyncio.wait_for(callback_cancelled.wait(), timeout=2)
|
||||
await asyncio.wait_for(callback_finished.wait(), timeout=2)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_early_stream_close_does_not_raise_callback_exception():
|
||||
async def run_callback(controller: RunController):
|
||||
controller.append_text("start")
|
||||
await asyncio.sleep(0.01)
|
||||
raise RuntimeError("boom")
|
||||
|
||||
stream = create_run(run_callback)
|
||||
first_chunk = await anext(stream)
|
||||
assert first_chunk.type == "text-delta"
|
||||
|
||||
await stream.aclose()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_early_stream_close_does_not_swallow_close_task_cancellation():
|
||||
callback_finished = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
async def run_callback(controller: RunController):
|
||||
controller.append_text("start")
|
||||
deadline = loop.time() + 0.5
|
||||
try:
|
||||
while loop.time() < deadline:
|
||||
try:
|
||||
await asyncio.sleep(0.01)
|
||||
except asyncio.CancelledError:
|
||||
# Simulate non-cooperative callback behavior: ignore cancellation.
|
||||
continue
|
||||
finally:
|
||||
callback_finished.set()
|
||||
|
||||
stream = create_run(run_callback)
|
||||
first_chunk = await anext(stream)
|
||||
assert first_chunk.type == "text-delta"
|
||||
|
||||
close_task = asyncio.create_task(stream.aclose())
|
||||
await asyncio.sleep(0)
|
||||
close_task.cancel()
|
||||
|
||||
try:
|
||||
done, _ = await asyncio.wait({close_task}, timeout=0.2)
|
||||
assert close_task in done
|
||||
assert close_task.cancelled()
|
||||
finally:
|
||||
await asyncio.wait_for(callback_finished.wait(), timeout=2)
|
||||
if not close_task.done():
|
||||
await asyncio.wait({close_task}, timeout=1)
|
||||
@@ -0,0 +1,17 @@
|
||||
import json
|
||||
|
||||
from assistant_stream.assistant_stream_chunk import UpdateStateChunk
|
||||
from assistant_stream.serialization.data_stream import DataStreamEncoder
|
||||
|
||||
|
||||
def test_data_stream_encoder_update_state_shape() -> None:
|
||||
encoder = DataStreamEncoder()
|
||||
operations = [
|
||||
{"type": "append-text", "path": ["messages", "0", "text"], "value": "hi"}
|
||||
]
|
||||
|
||||
encoded = encoder.encode_chunk(UpdateStateChunk(operations=operations))
|
||||
|
||||
assert encoded.startswith("aui-state:")
|
||||
assert encoded.endswith("\n")
|
||||
assert json.loads(encoded[len("aui-state:") :].strip()) == operations
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Tests for the LangGraph integration (assistant_stream.modules.langgraph).
|
||||
|
||||
These exercise append_langgraph_event against a real StateManager proxy, mirroring
|
||||
how the assistant-transport langgraph backend feeds langgraph's native
|
||||
``stream_mode=["messages", "updates"]`` output into ``controller.state``: the
|
||||
first argument is the state proxy, the event type is langgraph's stream mode name
|
||||
("messages" or "updates"), and a "messages" payload is a ``(message, metadata)``
|
||||
tuple carrying a single message or message chunk.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage
|
||||
|
||||
from assistant_stream.modules.langgraph import append_langgraph_event
|
||||
from assistant_stream.state_manager import StateManager
|
||||
|
||||
|
||||
def _manager(initial: Any) -> StateManager:
|
||||
return StateManager(lambda _chunk: None, initial)
|
||||
|
||||
|
||||
def _manager_with_ops(initial: Any) -> tuple[StateManager, list[dict[str, Any]]]:
|
||||
ops: list[dict[str, Any]] = []
|
||||
return StateManager(lambda chunk: ops.extend(chunk.operations), initial), ops
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_appends_single_message_to_empty_state() -> None:
|
||||
manager = _manager({})
|
||||
|
||||
append_langgraph_event(
|
||||
manager.state, (), "messages", (HumanMessage(content="Hello", id="m1"), {})
|
||||
)
|
||||
|
||||
messages = manager.state_data["messages"]
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["content"] == "Hello"
|
||||
assert messages[0]["id"] == "m1"
|
||||
assert messages[0]["type"] == "human"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_appends_messages_in_order() -> None:
|
||||
manager = _manager({"messages": []})
|
||||
|
||||
append_langgraph_event(
|
||||
manager.state, (), "messages", (HumanMessage(content="A", id="a"), {})
|
||||
)
|
||||
append_langgraph_event(
|
||||
manager.state, (), "messages", (AIMessage(content="B", id="b"), {})
|
||||
)
|
||||
|
||||
assert [m["content"] for m in manager.state_data["messages"]] == ["A", "B"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_merges_ai_message_chunks_by_id() -> None:
|
||||
manager = _manager({"messages": []})
|
||||
|
||||
append_langgraph_event(
|
||||
manager.state, (), "messages", (AIMessageChunk(content="Hello", id="m1"), {})
|
||||
)
|
||||
append_langgraph_event(
|
||||
manager.state, (), "messages", (AIMessageChunk(content=" world", id="m1"), {})
|
||||
)
|
||||
|
||||
messages = manager.state_data["messages"]
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["content"] == "Hello world"
|
||||
assert messages[0]["id"] == "m1"
|
||||
assert messages[0]["type"] == "ai"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_merging_ai_message_chunk_emits_content_append_text_delta() -> None:
|
||||
manager, ops = _manager_with_ops({"messages": []})
|
||||
|
||||
append_langgraph_event(
|
||||
manager.state, (), "messages", (AIMessageChunk(content="Hello", id="m1"), {})
|
||||
)
|
||||
manager.flush()
|
||||
ops.clear()
|
||||
|
||||
append_langgraph_event(
|
||||
manager.state, (), "messages", (AIMessageChunk(content=" world", id="m1"), {})
|
||||
)
|
||||
manager.flush()
|
||||
|
||||
assert manager.state_data["messages"][0]["content"] == "Hello world"
|
||||
assert {
|
||||
"type": "append-text",
|
||||
"path": ["messages", "0", "content"],
|
||||
"value": " world",
|
||||
} in ops
|
||||
assert not any(
|
||||
op["type"] == "set" and op["path"] == ["messages", "0"] for op in ops
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_merging_ai_message_chunk_handles_plain_dict_messages() -> None:
|
||||
state: dict[str, Any] = {"messages": []}
|
||||
|
||||
append_langgraph_event(
|
||||
state, (), "messages", (AIMessageChunk(content="Hello", id="m1"), {})
|
||||
)
|
||||
append_langgraph_event(
|
||||
state, (), "messages", (AIMessageChunk(content=" world", id="m1"), {})
|
||||
)
|
||||
|
||||
assert state["messages"][0]["content"] == "Hello world"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_merging_ai_message_chunk_patches_nested_tool_call_args() -> None:
|
||||
manager, ops = _manager_with_ops({"messages": []})
|
||||
|
||||
append_langgraph_event(
|
||||
manager.state,
|
||||
(),
|
||||
"messages",
|
||||
(
|
||||
AIMessageChunk(
|
||||
content="",
|
||||
id="m1",
|
||||
tool_call_chunks=[
|
||||
{
|
||||
"name": "search",
|
||||
"args": '{"query"',
|
||||
"id": "call_1",
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
),
|
||||
{},
|
||||
),
|
||||
)
|
||||
manager.flush()
|
||||
ops.clear()
|
||||
|
||||
append_langgraph_event(
|
||||
manager.state,
|
||||
(),
|
||||
"messages",
|
||||
(
|
||||
AIMessageChunk(
|
||||
content="",
|
||||
id="m1",
|
||||
tool_call_chunks=[
|
||||
{
|
||||
"name": None,
|
||||
"args": ':"docs"}',
|
||||
"id": None,
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
),
|
||||
{},
|
||||
),
|
||||
)
|
||||
manager.flush()
|
||||
|
||||
assert (
|
||||
manager.state_data["messages"][0]["tool_call_chunks"][0]["args"]
|
||||
== '{"query":"docs"}'
|
||||
)
|
||||
assert {
|
||||
"type": "append-text",
|
||||
"path": ["messages", "0", "tool_call_chunks", "0", "args"],
|
||||
"value": ':"docs"}',
|
||||
} in ops
|
||||
assert not any(
|
||||
op["type"] == "set" and op["path"] == ["messages", "0"] for op in ops
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_replaces_existing_message_with_same_id() -> None:
|
||||
manager = _manager({"messages": [{"type": "human", "id": "m1", "content": "old"}]})
|
||||
|
||||
append_langgraph_event(
|
||||
manager.state, (), "messages", (HumanMessage(content="new", id="m1"), {})
|
||||
)
|
||||
|
||||
messages = manager.state_data["messages"]
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["content"] == "new"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updates_event_writes_channels_onto_state() -> None:
|
||||
manager = _manager({})
|
||||
|
||||
append_langgraph_event(
|
||||
manager.state, (), "updates", {"agent": {"answer": "42", "messages": "ignored"}}
|
||||
)
|
||||
|
||||
assert manager.state_data["answer"] == "42"
|
||||
assert "messages" not in manager.state_data
|
||||
assert "agent" not in manager.state_data
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updates_event_skips_non_dict_nodes() -> None:
|
||||
manager = _manager({})
|
||||
|
||||
append_langgraph_event(
|
||||
manager.state, (), "updates", {"bad": "not-a-dict", "agent": {"answer": "42"}}
|
||||
)
|
||||
|
||||
assert manager.state_data["answer"] == "42"
|
||||
assert "bad" not in manager.state_data
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_unknown_event_type_is_ignored() -> None:
|
||||
manager = _manager({"existing": "value"})
|
||||
|
||||
append_langgraph_event(manager.state, (), "custom", "anything")
|
||||
|
||||
assert manager.state_data == {"existing": "value"}
|
||||
@@ -0,0 +1,59 @@
|
||||
import asyncio
|
||||
import pytest
|
||||
from assistant_stream import create_run, RunController
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_append_reasoning():
|
||||
"""Test that append_reasoning works correctly."""
|
||||
reasoning_chunks = []
|
||||
|
||||
async def collect_chunks(chunks):
|
||||
async for chunk in chunks:
|
||||
if chunk.type == "reasoning-delta":
|
||||
reasoning_chunks.append(chunk)
|
||||
|
||||
async def run_callback(controller: RunController):
|
||||
controller.append_reasoning("This is ")
|
||||
controller.append_reasoning("reasoning content")
|
||||
|
||||
# Create the run and collect chunks
|
||||
chunks = create_run(run_callback)
|
||||
await collect_chunks(chunks)
|
||||
|
||||
# Verify the reasoning chunks
|
||||
assert len(reasoning_chunks) == 2
|
||||
assert reasoning_chunks[0].reasoning_delta == "This is "
|
||||
assert reasoning_chunks[1].reasoning_delta == "reasoning content"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_mixed_text_and_reasoning():
|
||||
"""Test that append_text and append_reasoning can be used together."""
|
||||
collected_chunks = []
|
||||
|
||||
async def collect_chunks(chunks):
|
||||
async for chunk in chunks:
|
||||
if chunk.type in ["text-delta", "reasoning-delta"]:
|
||||
collected_chunks.append(chunk)
|
||||
|
||||
async def run_callback(controller: RunController):
|
||||
controller.append_text("This is text")
|
||||
controller.append_reasoning("This is reasoning")
|
||||
controller.append_text("More text")
|
||||
controller.append_reasoning("More reasoning")
|
||||
|
||||
# Create the run and collect chunks
|
||||
chunks = create_run(run_callback)
|
||||
await collect_chunks(chunks)
|
||||
|
||||
# Verify the chunks
|
||||
assert len(collected_chunks) == 4
|
||||
assert collected_chunks[0].type == "text-delta"
|
||||
assert collected_chunks[0].text_delta == "This is text"
|
||||
assert collected_chunks[1].type == "reasoning-delta"
|
||||
assert collected_chunks[1].reasoning_delta == "This is reasoning"
|
||||
assert collected_chunks[2].type == "text-delta"
|
||||
assert collected_chunks[2].text_delta == "More text"
|
||||
assert collected_chunks[3].type == "reasoning-delta"
|
||||
assert collected_chunks[3].reasoning_delta == "More reasoning"
|
||||
@@ -0,0 +1,298 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
|
||||
from assistant_stream.resumable import (
|
||||
ResumableStreamError,
|
||||
create_in_memory_resumable_stream_store,
|
||||
create_resumable_stream_context,
|
||||
)
|
||||
|
||||
|
||||
def _bytes(s: str) -> bytes:
|
||||
return s.encode("utf-8")
|
||||
|
||||
|
||||
async def _collect(stream: AsyncIterator[bytes]) -> str:
|
||||
out = bytearray()
|
||||
async for chunk in stream:
|
||||
out.extend(chunk)
|
||||
return out.decode("utf-8")
|
||||
|
||||
|
||||
def _make_string_stream(parts: list[str]) -> AsyncIterator[bytes]:
|
||||
async def gen() -> AsyncIterator[bytes]:
|
||||
for part in parts:
|
||||
yield _bytes(part)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
return gen()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_producer_receives_full_byte_stream() -> None:
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store()
|
||||
)
|
||||
stream = await ctx.run("a", lambda: _make_string_stream(["hello ", "world"]))
|
||||
assert await _collect(stream) == "hello world"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_second_caller_is_consumer_with_identical_bytes() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
ctx = create_resumable_stream_context(store=store)
|
||||
make_stream_calls = 0
|
||||
|
||||
def make_producer() -> AsyncIterator[bytes]:
|
||||
nonlocal make_stream_calls
|
||||
make_stream_calls += 1
|
||||
return _make_string_stream(["one ", "two ", "three"])
|
||||
|
||||
def make_unused() -> AsyncIterator[bytes]:
|
||||
nonlocal make_stream_calls
|
||||
make_stream_calls += 1
|
||||
return _make_string_stream(["should-not-run"])
|
||||
|
||||
producer_stream = await ctx.run("a", make_producer)
|
||||
consumer_stream = await ctx.run("a", make_unused)
|
||||
|
||||
a, b = await asyncio.gather(
|
||||
_collect(producer_stream), _collect(consumer_stream)
|
||||
)
|
||||
assert a == "one two three"
|
||||
assert b == "one two three"
|
||||
assert make_stream_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_late_consumer_after_done_replays_via_resume() -> None:
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store()
|
||||
)
|
||||
producer = await ctx.run(
|
||||
"a", lambda: _make_string_stream(["alpha", "beta", "gamma"])
|
||||
)
|
||||
assert await _collect(producer) == "alphabetagamma"
|
||||
|
||||
replay = await ctx.resume("a")
|
||||
assert replay is not None
|
||||
assert await _collect(replay) == "alphabetagamma"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resume_returns_none_for_missing() -> None:
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store()
|
||||
)
|
||||
assert await ctx.resume("nope") is None
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_require_resume_raises_missing() -> None:
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store()
|
||||
)
|
||||
with pytest.raises(ResumableStreamError) as exc:
|
||||
await ctx.require_resume("nope")
|
||||
assert exc.value.code == "missing"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_require_resume_returns_replay_when_exists() -> None:
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store()
|
||||
)
|
||||
producer = await ctx.run("a", lambda: _make_string_stream(["hi"]))
|
||||
assert await _collect(producer) == "hi"
|
||||
|
||||
replay = await ctx.require_resume("a")
|
||||
assert await _collect(replay) == "hi"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_status_tracks_lifecycle() -> None:
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store()
|
||||
)
|
||||
assert await ctx.status("a") == "missing"
|
||||
stream = await ctx.run("a", lambda: _make_string_stream(["x"]))
|
||||
await _collect(stream)
|
||||
assert await ctx.status("a") == "done"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_removes_stream_state() -> None:
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store()
|
||||
)
|
||||
stream = await ctx.run("a", lambda: _make_string_stream(["x"]))
|
||||
await _collect(stream)
|
||||
assert await ctx.status("a") == "done"
|
||||
await ctx.delete("a")
|
||||
assert await ctx.status("a") == "missing"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_producer_keeps_writing_after_consumer_closes_early() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
ctx = create_resumable_stream_context(store=store)
|
||||
|
||||
producer_emitted = 0
|
||||
|
||||
async def slow_stream() -> AsyncIterator[bytes]:
|
||||
nonlocal producer_emitted
|
||||
for i in range(5):
|
||||
await asyncio.sleep(0.01)
|
||||
yield _bytes(f"chunk{i};")
|
||||
producer_emitted += 1
|
||||
|
||||
stream = await ctx.run("a", lambda: slow_stream())
|
||||
first = await anext(stream)
|
||||
assert first == _bytes("chunk0;")
|
||||
await stream.aclose()
|
||||
|
||||
while producer_emitted < 5:
|
||||
await asyncio.sleep(0.01)
|
||||
while await ctx.status("a") == "streaming":
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
replay = await ctx.resume("a")
|
||||
assert replay is not None
|
||||
assert await _collect(replay) == "chunk0;chunk1;chunk2;chunk3;chunk4;"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_propagates_producer_errors_to_consumers() -> None:
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store()
|
||||
)
|
||||
|
||||
async def failing() -> AsyncIterator[bytes]:
|
||||
yield _bytes("partial;")
|
||||
raise Exception("oops")
|
||||
|
||||
stream = await ctx.run("a", lambda: failing())
|
||||
with pytest.raises(Exception, match="oops"):
|
||||
await _collect(stream)
|
||||
assert await ctx.status("a") == "error"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_wait_until_receives_the_task() -> None:
|
||||
tasks: list[asyncio.Task[None]] = []
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store(),
|
||||
wait_until=lambda t: tasks.append(t),
|
||||
)
|
||||
stream = await ctx.run("a", lambda: _make_string_stream(["x"]))
|
||||
assert await _collect(stream) == "x"
|
||||
assert len(tasks) == 1
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_on_acquire_fires_for_producer_and_consumer() -> None:
|
||||
calls: list[dict[str, str]] = []
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store(),
|
||||
on_acquire=lambda id, role: calls.append({"id": id, "role": role}),
|
||||
)
|
||||
producer = await ctx.run("a", lambda: _make_string_stream(["x"]))
|
||||
consumer = await ctx.run("a", lambda: _make_string_stream(["unused"]))
|
||||
await asyncio.gather(_collect(producer), _collect(consumer))
|
||||
assert calls == [
|
||||
{"id": "a", "role": "producer"},
|
||||
{"id": "a", "role": "consumer"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_on_append_fires_per_chunk_with_byte_length() -> None:
|
||||
calls: list[dict[str, object]] = []
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store(),
|
||||
on_append=lambda id, n: calls.append({"id": id, "byteLength": n}),
|
||||
)
|
||||
stream = await ctx.run("a", lambda: _make_string_stream(["ab", "cde"]))
|
||||
assert await _collect(stream) == "abcde"
|
||||
assert calls == [
|
||||
{"id": "a", "byteLength": 2},
|
||||
{"id": "a", "byteLength": 3},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_on_finalize_fires_on_success() -> None:
|
||||
calls: list[dict[str, object]] = []
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store(),
|
||||
on_finalize=lambda id, status, error: calls.append(
|
||||
{"id": id, "status": status, "error": error}
|
||||
),
|
||||
)
|
||||
stream = await ctx.run("a", lambda: _make_string_stream(["x"]))
|
||||
await _collect(stream)
|
||||
assert calls == [{"id": "a", "status": "done", "error": None}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_on_finalize_and_on_error_fire_on_failure() -> None:
|
||||
finalize_calls: list[dict[str, object]] = []
|
||||
errors: list[dict[str, object]] = []
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store(),
|
||||
on_finalize=lambda id, status, error: finalize_calls.append(
|
||||
{"id": id, "status": status, "error": error}
|
||||
),
|
||||
on_error=lambda id, error: errors.append({"id": id, "error": error}),
|
||||
)
|
||||
|
||||
async def failing() -> AsyncIterator[bytes]:
|
||||
raise Exception("boom")
|
||||
yield b"" # pragma: no cover
|
||||
|
||||
stream = await ctx.run("a", lambda: failing())
|
||||
with pytest.raises(Exception, match="boom"):
|
||||
await _collect(stream)
|
||||
assert finalize_calls == [{"id": "a", "status": "error", "error": "boom"}]
|
||||
assert len(errors) == 1
|
||||
assert errors[0]["id"] == "a"
|
||||
assert str(errors[0]["error"]) == "boom"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_raising_on_acquire_does_not_break_producer_pipeline() -> None:
|
||||
def boom_acquire(_id: str, _role: str) -> None:
|
||||
raise RuntimeError("hook-acquire-boom")
|
||||
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store(),
|
||||
on_acquire=boom_acquire,
|
||||
)
|
||||
stream = await ctx.run("a", lambda: _make_string_stream(["hello ", "world"]))
|
||||
assert await _collect(stream) == "hello world"
|
||||
assert await ctx.status("a") == "done"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_raising_on_error_still_finalizes_error_status() -> None:
|
||||
def boom_error(_id: str, _err: object) -> None:
|
||||
raise RuntimeError("hook-error-boom")
|
||||
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store(),
|
||||
on_error=boom_error,
|
||||
)
|
||||
|
||||
async def failing() -> AsyncIterator[bytes]:
|
||||
yield _bytes("partial;")
|
||||
raise Exception("producer-failed")
|
||||
|
||||
stream = await ctx.run("a", lambda: failing())
|
||||
with pytest.raises(Exception, match="producer-failed"):
|
||||
await _collect(stream)
|
||||
assert await ctx.status("a") == "error"
|
||||
@@ -0,0 +1,308 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from assistant_stream.resumable import (
|
||||
ResumableStreamError,
|
||||
create_in_memory_resumable_stream_store,
|
||||
)
|
||||
from assistant_stream.resumable.types import ResumableStreamEntry
|
||||
|
||||
|
||||
def _bytes(s: str) -> bytes:
|
||||
return s.encode("utf-8")
|
||||
|
||||
|
||||
def _decode(chunk: bytes) -> str:
|
||||
return chunk.decode("utf-8")
|
||||
|
||||
|
||||
async def _drain(iter_) -> list[str]:
|
||||
out: list[str] = []
|
||||
async for entry in iter_:
|
||||
out.append(_decode(entry.chunk))
|
||||
return out
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_elects_exactly_one_producer_per_stream_id() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
first = await store.acquire("a")
|
||||
second = await store.acquire("a")
|
||||
third = await store.acquire("a")
|
||||
assert first == "producer"
|
||||
assert second == "consumer"
|
||||
assert third == "consumer"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_stream_id_with_trailing_newline_is_invalid() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
with pytest.raises(ResumableStreamError) as exc:
|
||||
await store.acquire("valid-id\n")
|
||||
assert exc.value.code == "invalid-id"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_post_finalize_acquire_is_consumer() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
assert await store.acquire("a") == "producer"
|
||||
await store.finalize("a", "done")
|
||||
assert await store.acquire("a") == "consumer"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_isolates_streams_by_id() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
assert await store.acquire("a") == "producer"
|
||||
assert await store.acquire("b") == "producer"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_replays_buffered_entries_and_tails_until_finalize() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
await store.acquire("a")
|
||||
await store.append("a", _bytes("hello "))
|
||||
await store.append("a", _bytes("world"))
|
||||
|
||||
signal = asyncio.Event()
|
||||
collected: list[str] = []
|
||||
|
||||
async def reading() -> None:
|
||||
async for entry in store.read("a", "", signal):
|
||||
collected.append(_decode(entry.chunk))
|
||||
if len(collected) == 3:
|
||||
await store.finalize("a", "done")
|
||||
|
||||
task = asyncio.create_task(reading())
|
||||
await store.append("a", _bytes("!"))
|
||||
await task
|
||||
assert collected == ["hello ", "world", "!"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_status_transitions_missing_streaming_done() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
assert await store.status("a") == "missing"
|
||||
await store.acquire("a")
|
||||
assert await store.status("a") == "streaming"
|
||||
await store.finalize("a", "done")
|
||||
assert await store.status("a") == "done"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_status_reports_error_after_error_finalize() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
await store.acquire("a")
|
||||
await store.finalize("a", "error", "boom")
|
||||
assert await store.status("a") == "error"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_read_throws_after_error_finalize_after_draining() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
await store.acquire("a")
|
||||
await store.append("a", _bytes("partial"))
|
||||
await store.finalize("a", "error", "boom")
|
||||
|
||||
signal = asyncio.Event()
|
||||
seen: list[str] = []
|
||||
with pytest.raises(Exception, match="boom"):
|
||||
async for entry in store.read("a", "", signal):
|
||||
seen.append(_decode(entry.chunk))
|
||||
assert seen == ["partial"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_late_consumer_after_done_replays_everything() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
await store.acquire("a")
|
||||
await store.append("a", _bytes("a"))
|
||||
await store.append("a", _bytes("b"))
|
||||
await store.append("a", _bytes("c"))
|
||||
await store.finalize("a", "done")
|
||||
|
||||
signal = asyncio.Event()
|
||||
assert await _drain(store.read("a", "", signal)) == ["a", "b", "c"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cursor_advances_and_skips_already_seen() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
await store.acquire("a")
|
||||
await store.append("a", _bytes("1"))
|
||||
await store.append("a", _bytes("2"))
|
||||
await store.append("a", _bytes("3"))
|
||||
await store.finalize("a", "done")
|
||||
|
||||
signal = asyncio.Event()
|
||||
seen: list[ResumableStreamEntry] = []
|
||||
async for entry in store.read("a", "", signal):
|
||||
seen.append(entry)
|
||||
assert [_decode(s.chunk) for s in seen] == ["1", "2", "3"]
|
||||
|
||||
after_first = seen[0].cursor
|
||||
assert await _drain(store.read("a", after_first, signal)) == ["2", "3"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_signal_set_terminates_read_without_raising() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
await store.acquire("a")
|
||||
signal = asyncio.Event()
|
||||
collected: list[str] = []
|
||||
|
||||
async def reading() -> None:
|
||||
async for entry in store.read("a", "", signal):
|
||||
collected.append(_decode(entry.chunk))
|
||||
|
||||
task = asyncio.create_task(reading())
|
||||
await store.append("a", _bytes("x"))
|
||||
await asyncio.sleep(0.01)
|
||||
signal.set()
|
||||
await task
|
||||
assert collected == ["x"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_multiple_consumers_read_concurrently() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
await store.acquire("a")
|
||||
signal = asyncio.Event()
|
||||
a = asyncio.create_task(_drain(store.read("a", "", signal)))
|
||||
b = asyncio.create_task(_drain(store.read("a", "", signal)))
|
||||
await store.append("a", _bytes("x"))
|
||||
await store.append("a", _bytes("y"))
|
||||
await store.finalize("a", "done")
|
||||
assert await a == ["x", "y"]
|
||||
assert await b == ["x", "y"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_ends_in_flight_reads_and_status_missing() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
await store.acquire("a")
|
||||
signal = asyncio.Event()
|
||||
reading = asyncio.create_task(_drain(store.read("a", "", signal)))
|
||||
await store.append("a", _bytes("x"))
|
||||
await asyncio.sleep(0)
|
||||
await store.delete("a")
|
||||
assert await reading == ["x"]
|
||||
assert await store.status("a") == "missing"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_expired_streams_evicted_on_next_access() -> None:
|
||||
now = 1_000.0
|
||||
|
||||
def clock() -> float:
|
||||
return now
|
||||
|
||||
store = create_in_memory_resumable_stream_store(default_ttl_ms=100, now=clock)
|
||||
await store.acquire("a")
|
||||
await store.append("a", _bytes("hi"))
|
||||
assert await store.status("a") == "streaming"
|
||||
now += 200
|
||||
assert await store.status("a") == "missing"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_appending_refreshes_ttl() -> None:
|
||||
now = 1_000.0
|
||||
|
||||
def clock() -> float:
|
||||
return now
|
||||
|
||||
store = create_in_memory_resumable_stream_store(default_ttl_ms=100, now=clock)
|
||||
await store.acquire("a")
|
||||
now += 80
|
||||
await store.append("a", _bytes("x"))
|
||||
now += 80
|
||||
assert await store.status("a") == "streaming"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_rejects_append_on_finalized_stream() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
await store.acquire("a")
|
||||
await store.finalize("a", "done")
|
||||
with pytest.raises(ResumableStreamError, match="already finalized") as exc:
|
||||
await store.append("a", _bytes("late"))
|
||||
assert exc.value.code == "finalized"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_rejects_append_on_missing_stream() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
with pytest.raises(Exception, match="Stream not found"):
|
||||
await store.append("a", _bytes("x"))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_finalize_is_idempotent() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
await store.acquire("a")
|
||||
await store.finalize("a", "done")
|
||||
await store.finalize("a", "done")
|
||||
assert await store.status("a") == "done"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_rejects_append_when_chunk_exceeds_max_chunk_bytes() -> None:
|
||||
store = create_in_memory_resumable_stream_store(max_chunk_bytes=4)
|
||||
await store.acquire("a")
|
||||
with pytest.raises(Exception, match="Chunk exceeds maxChunkBytes: 5"):
|
||||
await store.append("a", _bytes("hello"))
|
||||
await store.append("a", _bytes("ok"))
|
||||
assert await store.status("a") == "streaming"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_rejects_append_when_stream_reaches_max_entries() -> None:
|
||||
store = create_in_memory_resumable_stream_store(max_entries_per_stream=2)
|
||||
await store.acquire("a")
|
||||
await store.append("a", _bytes("1"))
|
||||
await store.append("a", _bytes("2"))
|
||||
with pytest.raises(Exception, match="Stream exceeded maxEntriesPerStream: a"):
|
||||
await store.append("a", _bytes("3"))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_rejects_acquire_when_max_streams_exceeded() -> None:
|
||||
store = create_in_memory_resumable_stream_store(max_streams=2)
|
||||
await store.acquire("a")
|
||||
await store.acquire("b")
|
||||
with pytest.raises(Exception, match="maxStreams exceeded"):
|
||||
await store.acquire("c")
|
||||
assert await store.acquire("a") == "consumer"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_gc_sweeper_evicts_expired_streams() -> None:
|
||||
now = 1_000.0
|
||||
|
||||
def clock() -> float:
|
||||
return now
|
||||
|
||||
store = create_in_memory_resumable_stream_store(
|
||||
default_ttl_ms=100,
|
||||
gc_interval_ms=50,
|
||||
now=clock,
|
||||
)
|
||||
await store.acquire("a")
|
||||
await store.append("a", _bytes("hi"))
|
||||
now += 200
|
||||
await asyncio.sleep(0.08)
|
||||
assert await store.status("a") == "missing"
|
||||
store.dispose()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_dispose_clears_gc_and_is_noop_without_gc() -> None:
|
||||
store = create_in_memory_resumable_stream_store(gc_interval_ms=50)
|
||||
await store.acquire("a")
|
||||
store.dispose()
|
||||
store2 = create_in_memory_resumable_stream_store()
|
||||
store2.dispose()
|
||||
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "redis://127.0.0.1:6379")
|
||||
REDIS_TESTS_DISABLED = (
|
||||
os.environ.get("REDIS_URL") is None and os.environ.get("REDIS_TESTS") != "1"
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
REDIS_TESTS_DISABLED,
|
||||
reason="set REDIS_URL or REDIS_TESTS=1 to run redis adapter tests",
|
||||
)
|
||||
|
||||
|
||||
def _bytes(s: str) -> bytes:
|
||||
return s.encode("utf-8")
|
||||
|
||||
|
||||
def _decode(chunk: bytes) -> str:
|
||||
return chunk.decode("utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def redis_store():
|
||||
import redis.asyncio as redis
|
||||
|
||||
from assistant_stream.resumable import create_redis_resumable_stream_store
|
||||
|
||||
client = redis.from_url(REDIS_URL)
|
||||
key_prefix = f"aui:resumable:test:{int(time.time() * 1000)}:{uuid.uuid4().hex[:8]}"
|
||||
store = create_redis_resumable_stream_store(
|
||||
client,
|
||||
key_prefix=key_prefix,
|
||||
poll_interval_ms=25,
|
||||
)
|
||||
try:
|
||||
yield store, client, key_prefix
|
||||
finally:
|
||||
keys = [key async for key in client.scan_iter(match=f"{key_prefix}:*")]
|
||||
if keys:
|
||||
await client.delete(*keys)
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_elects_exactly_one_producer(redis_store) -> None:
|
||||
store, _, _ = redis_store
|
||||
stream_id = f"id-{uuid.uuid4().hex}"
|
||||
first = await store.acquire(stream_id)
|
||||
second = await store.acquire(stream_id)
|
||||
assert first == "producer"
|
||||
assert second == "consumer"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_replays_buffered_and_tails_until_done(redis_store) -> None:
|
||||
store, _, _ = redis_store
|
||||
stream_id = f"id-{uuid.uuid4().hex}"
|
||||
await store.acquire(stream_id)
|
||||
await store.append(stream_id, _bytes("hello"))
|
||||
await store.append(stream_id, _bytes(" world"))
|
||||
|
||||
signal = asyncio.Event()
|
||||
collected: list[str] = []
|
||||
|
||||
async def reading() -> None:
|
||||
async for entry in store.read(stream_id, "", signal):
|
||||
collected.append(_decode(entry.chunk))
|
||||
if len(collected) == 3:
|
||||
await store.finalize(stream_id, "done")
|
||||
|
||||
task = asyncio.create_task(reading())
|
||||
await asyncio.sleep(0.05)
|
||||
await store.append(stream_id, _bytes("!"))
|
||||
await task
|
||||
assert "".join(collected) == "hello world!"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_error_finalize_raises_after_draining(redis_store) -> None:
|
||||
store, _, _ = redis_store
|
||||
stream_id = f"id-{uuid.uuid4().hex}"
|
||||
await store.acquire(stream_id)
|
||||
await store.append(stream_id, _bytes("partial"))
|
||||
await store.finalize(stream_id, "error", "boom")
|
||||
|
||||
signal = asyncio.Event()
|
||||
seen: list[str] = []
|
||||
with pytest.raises(Exception, match="boom"):
|
||||
async for entry in store.read(stream_id, "", signal):
|
||||
seen.append(_decode(entry.chunk))
|
||||
assert seen == ["partial"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_cursor_skip(redis_store) -> None:
|
||||
store, _, _ = redis_store
|
||||
stream_id = f"id-{uuid.uuid4().hex}"
|
||||
await store.acquire(stream_id)
|
||||
await store.append(stream_id, _bytes("1"))
|
||||
await store.append(stream_id, _bytes("2"))
|
||||
await store.append(stream_id, _bytes("3"))
|
||||
await store.finalize(stream_id, "done")
|
||||
|
||||
signal = asyncio.Event()
|
||||
seen: list[tuple[str, str]] = []
|
||||
async for entry in store.read(stream_id, "", signal):
|
||||
seen.append((entry.cursor, _decode(entry.chunk)))
|
||||
assert [t for _, t in seen] == ["1", "2", "3"]
|
||||
|
||||
out: list[str] = []
|
||||
async for entry in store.read(stream_id, seen[0][0], signal):
|
||||
out.append(_decode(entry.chunk))
|
||||
assert out == ["2", "3"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_delete_removes_stream(redis_store) -> None:
|
||||
store, _, _ = redis_store
|
||||
stream_id = f"id-{uuid.uuid4().hex}"
|
||||
await store.acquire(stream_id)
|
||||
await store.append(stream_id, _bytes("x"))
|
||||
assert await store.status(stream_id) == "streaming"
|
||||
await store.delete(stream_id)
|
||||
assert await store.status(stream_id) == "missing"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_binary_round_trip_0_to_255(redis_store) -> None:
|
||||
store, _, _ = redis_store
|
||||
stream_id = f"id-{uuid.uuid4().hex}"
|
||||
await store.acquire(stream_id)
|
||||
|
||||
producer = bytes(i & 0xFF for i in range(512))
|
||||
half = len(producer) // 2
|
||||
await store.append(stream_id, producer[:half])
|
||||
await store.append(stream_id, producer[half:])
|
||||
await store.finalize(stream_id, "done")
|
||||
|
||||
signal = asyncio.Event()
|
||||
replayed = bytearray()
|
||||
async for entry in store.read(stream_id, "", signal):
|
||||
replayed.extend(entry.chunk)
|
||||
assert bytes(replayed) == producer
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_decode_responses_true_is_rejected() -> None:
|
||||
import redis.asyncio as redis
|
||||
|
||||
from assistant_stream.resumable import create_redis_resumable_stream_store
|
||||
|
||||
client = redis.from_url(REDIS_URL, decode_responses=True)
|
||||
try:
|
||||
with pytest.raises(ValueError, match="decode_responses"):
|
||||
create_redis_resumable_stream_store(client)
|
||||
finally:
|
||||
await client.aclose()
|
||||
@@ -0,0 +1,177 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from assistant_stream.create_run import RunController
|
||||
from assistant_stream.resumable import (
|
||||
RESUMABLE_STREAM_ID_HEADER,
|
||||
create_in_memory_resumable_stream_store,
|
||||
create_resumable_assistant_stream_response,
|
||||
create_resumable_stream_context,
|
||||
create_resume_assistant_stream_response,
|
||||
)
|
||||
from assistant_stream.serialization.assistant_transport import (
|
||||
AssistantTransportEncoder,
|
||||
)
|
||||
|
||||
|
||||
async def _collect_body(response: StreamingResponse) -> bytes:
|
||||
parts: list[bytes] = []
|
||||
async for chunk in response.body_iterator:
|
||||
if isinstance(chunk, str):
|
||||
parts.append(chunk.encode("utf-8"))
|
||||
else:
|
||||
parts.append(bytes(chunk))
|
||||
return b"".join(parts)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_producer_response_carries_id_header_and_media_type() -> None:
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store()
|
||||
)
|
||||
|
||||
async def callback(controller: RunController) -> None:
|
||||
controller.append_text("hello ")
|
||||
controller.append_text("world")
|
||||
|
||||
response = await create_resumable_assistant_stream_response(
|
||||
context=ctx,
|
||||
stream_id="s1",
|
||||
callback=callback,
|
||||
)
|
||||
assert isinstance(response, StreamingResponse)
|
||||
assert response.headers[RESUMABLE_STREAM_ID_HEADER] == "s1"
|
||||
assert "text/plain" in response.media_type
|
||||
body = await _collect_body(response)
|
||||
assert b"hello " in body
|
||||
assert b"world" in body
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resume_replays_byte_for_byte() -> None:
|
||||
store = create_in_memory_resumable_stream_store()
|
||||
ctx = create_resumable_stream_context(store=store)
|
||||
|
||||
async def callback(controller: RunController) -> None:
|
||||
controller.append_text("alpha")
|
||||
tool = await controller.add_tool_call("echo", "t1")
|
||||
tool.append_args_text('{"v": 1}')
|
||||
tool.set_response({"ok": True})
|
||||
|
||||
first = await create_resumable_assistant_stream_response(
|
||||
context=ctx,
|
||||
stream_id="s1",
|
||||
callback=callback,
|
||||
)
|
||||
first_bytes = await _collect_body(first)
|
||||
|
||||
second = await create_resume_assistant_stream_response(
|
||||
context=ctx,
|
||||
stream_id="s1",
|
||||
)
|
||||
second_bytes = await _collect_body(second)
|
||||
|
||||
assert second_bytes == first_bytes
|
||||
assert second.headers[RESUMABLE_STREAM_ID_HEADER] == "s1"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resume_returns_404_json_when_missing() -> None:
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store()
|
||||
)
|
||||
response = await create_resume_assistant_stream_response(
|
||||
context=ctx,
|
||||
stream_id="missing",
|
||||
)
|
||||
assert response.status_code == 404
|
||||
body = response.body
|
||||
assert b"stream not found" in body
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_resume_returns_404_json_for_invalid_stream_id() -> None:
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store()
|
||||
)
|
||||
response = await create_resume_assistant_stream_response(
|
||||
context=ctx,
|
||||
stream_id="x" * 300,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
body = response.body
|
||||
assert b"stream not found" in body
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_assistant_transport_encoder_round_trips() -> None:
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store()
|
||||
)
|
||||
|
||||
async def callback(controller: RunController) -> None:
|
||||
controller.append_text("hi")
|
||||
|
||||
response = await create_resumable_assistant_stream_response(
|
||||
context=ctx,
|
||||
stream_id="s1",
|
||||
encoder=AssistantTransportEncoder(),
|
||||
callback=callback,
|
||||
)
|
||||
assert response.media_type == "text/event-stream"
|
||||
text = (await _collect_body(response)).decode("utf-8")
|
||||
assert "text-delta" in text
|
||||
assert "[DONE]" in text
|
||||
|
||||
resume = await create_resume_assistant_stream_response(
|
||||
context=ctx,
|
||||
stream_id="s1",
|
||||
encoder=AssistantTransportEncoder(),
|
||||
)
|
||||
resume_text = (await _collect_body(resume)).decode("utf-8")
|
||||
assert resume_text == text
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_user_headers_merge_but_cannot_override_id() -> None:
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store()
|
||||
)
|
||||
|
||||
async def callback(controller: RunController) -> None:
|
||||
controller.append_text("hi")
|
||||
|
||||
response = await create_resumable_assistant_stream_response(
|
||||
context=ctx,
|
||||
stream_id="s1",
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=0",
|
||||
RESUMABLE_STREAM_ID_HEADER: "spoofed",
|
||||
},
|
||||
callback=callback,
|
||||
)
|
||||
assert response.headers["cache-control"] == "private, max-age=0"
|
||||
assert response.headers[RESUMABLE_STREAM_ID_HEADER] == "s1"
|
||||
await response.body_iterator.aclose()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_differently_cased_user_id_header_is_filtered() -> None:
|
||||
ctx = create_resumable_stream_context(
|
||||
store=create_in_memory_resumable_stream_store()
|
||||
)
|
||||
|
||||
async def callback(controller: RunController) -> None:
|
||||
controller.append_text("hi")
|
||||
|
||||
response = await create_resumable_assistant_stream_response(
|
||||
context=ctx,
|
||||
stream_id="s1",
|
||||
headers={"X-RESUMABLE-STREAM-ID": "spoof"},
|
||||
callback=callback,
|
||||
)
|
||||
assert response.headers[RESUMABLE_STREAM_ID_HEADER] == "s1"
|
||||
assert "spoof" not in response.headers.values()
|
||||
await response.body_iterator.aclose()
|
||||
@@ -0,0 +1,164 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from assistant_stream import RunController, create_run
|
||||
from assistant_stream.state_manager import StateManager
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_append_text_helper_emits_append_text_op() -> None:
|
||||
ops: list[dict[str, Any]] = []
|
||||
manager = StateManager(
|
||||
lambda chunk: ops.extend(chunk.operations),
|
||||
{"messages": [{"text": ""}]},
|
||||
)
|
||||
|
||||
manager.append_text(["messages", "0", "text"], "hi")
|
||||
manager.flush()
|
||||
|
||||
assert ops == [{"type": "append-text", "path": ["messages", "0", "text"], "value": "hi"}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nested_plus_equals_on_string_emits_append_text() -> None:
|
||||
ops: list[dict[str, Any]] = []
|
||||
manager = StateManager(
|
||||
lambda chunk: ops.extend(chunk.operations),
|
||||
{"messages": [{"text": "h"}]},
|
||||
)
|
||||
|
||||
manager.state["messages"][0]["text"] += "i"
|
||||
manager.flush()
|
||||
|
||||
assert ops == [
|
||||
{"type": "append-text", "path": ["messages", "0", "text"], "value": "i"}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_nested_plus_equals_multiple_operations_accumulate() -> None:
|
||||
ops: list[dict[str, Any]] = []
|
||||
manager = StateManager(
|
||||
lambda chunk: ops.extend(chunk.operations),
|
||||
{"messages": [{"text": ""}]},
|
||||
)
|
||||
|
||||
manager.state["messages"][0]["text"] += "Hel"
|
||||
manager.state["messages"][0]["text"] += "lo"
|
||||
manager.flush()
|
||||
|
||||
assert ops == [
|
||||
{"type": "set", "path": ["messages", "0", "text"], "value": "Hel"},
|
||||
{"type": "append-text", "path": ["messages", "0", "text"], "value": "lo"},
|
||||
]
|
||||
assert manager.state_data["messages"][0]["text"] == "Hello"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_streaming_path_emits_append_text_deltas_not_set() -> None:
|
||||
async def run_callback(controller: RunController):
|
||||
controller.append_state_text(["messages", 0, "text"], "Hel")
|
||||
controller.append_state_text(["messages", 0, "text"], "lo")
|
||||
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in create_run(
|
||||
run_callback, state={"messages": [{"text": ""}]}
|
||||
)
|
||||
]
|
||||
operations = [
|
||||
operation
|
||||
for chunk in chunks
|
||||
if chunk.type == "update-state"
|
||||
for operation in chunk.operations
|
||||
]
|
||||
|
||||
assert operations == [
|
||||
{"type": "append-text", "path": ["messages", "0", "text"], "value": "Hel"},
|
||||
{"type": "append-text", "path": ["messages", "0", "text"], "value": "lo"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_string_assignment_non_extension_emits_set() -> None:
|
||||
ops: list[dict[str, Any]] = []
|
||||
manager = StateManager(
|
||||
lambda chunk: ops.extend(chunk.operations),
|
||||
{"messages": [{"text": "hello"}]},
|
||||
)
|
||||
|
||||
manager.state["messages"][0]["text"] = "goodbye"
|
||||
manager.flush()
|
||||
|
||||
assert ops == [{"type": "set", "path": ["messages", "0", "text"], "value": "goodbye"}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_string_assignment_non_string_override_emits_set() -> None:
|
||||
ops: list[dict[str, Any]] = []
|
||||
manager = StateManager(
|
||||
lambda chunk: ops.extend(chunk.operations),
|
||||
{"messages": [{"text": "hello"}]},
|
||||
)
|
||||
|
||||
manager.state["messages"][0]["text"] = 42
|
||||
manager.flush()
|
||||
|
||||
assert ops == [{"type": "set", "path": ["messages", "0", "text"], "value": 42}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_string_assignment_unchanged_value_emits_set() -> None:
|
||||
ops: list[dict[str, Any]] = []
|
||||
manager = StateManager(
|
||||
lambda chunk: ops.extend(chunk.operations),
|
||||
{"messages": [{"text": "hello"}]},
|
||||
)
|
||||
|
||||
manager.state["messages"][0]["text"] = "hello"
|
||||
manager.flush()
|
||||
|
||||
assert ops == [{"type": "set", "path": ["messages", "0", "text"], "value": "hello"}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_empty_string_initial_write_emits_set() -> None:
|
||||
"""First write to a field initialized to "" should emit set, not append-text."""
|
||||
ops: list[dict[str, Any]] = []
|
||||
manager = StateManager(
|
||||
lambda chunk: ops.extend(chunk.operations),
|
||||
{"messages": [{"text": ""}]},
|
||||
)
|
||||
|
||||
manager.state["messages"][0]["text"] = "Hello"
|
||||
manager.flush()
|
||||
|
||||
assert ops == [{"type": "set", "path": ["messages", "0", "text"], "value": "Hello"}]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_run_controller_append_state_text() -> None:
|
||||
"""RunController.append_state_text() should emit append-text operations."""
|
||||
|
||||
async def run_callback(controller: RunController):
|
||||
controller.append_state_text(["user", "name"], "Al")
|
||||
controller.append_state_text(["user", "name"], "ice")
|
||||
|
||||
chunks = [
|
||||
chunk
|
||||
async for chunk in create_run(
|
||||
run_callback, state={"user": {"name": ""}}
|
||||
)
|
||||
]
|
||||
operations = [
|
||||
operation
|
||||
for chunk in chunks
|
||||
if chunk.type == "update-state"
|
||||
for operation in chunk.operations
|
||||
]
|
||||
|
||||
assert operations == [
|
||||
{"type": "append-text", "path": ["user", "name"], "value": "Al"},
|
||||
{"type": "append-text", "path": ["user", "name"], "value": "ice"},
|
||||
]
|
||||
Generated
+1253
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
# Server Configuration
|
||||
HOST=0.0.0.0
|
||||
PORT=8001
|
||||
DEBUG=false
|
||||
LOG_LEVEL=info
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
||||
# OpenAI API Key (for LangChain/LangGraph)
|
||||
OPENAI_API_KEY=your-openai-api-key-here
|
||||
@@ -0,0 +1,196 @@
|
||||
# Assistant Transport Backend with LangGraph
|
||||
|
||||
This is a LangGraph-based implementation of the assistant transport backend, providing streaming chat capabilities using FastAPI, assistant-stream, and LangGraph.
|
||||
|
||||
## Features
|
||||
|
||||
- Streaming responses using LangGraph's astream and astream_events
|
||||
- Synchronization of LangGraph state to the frontend
|
||||
- Support for both message streaming and state updates
|
||||
- DeltaChannel-backed LangGraph message checkpoints (`langgraph>=1.2`)
|
||||
- Optional Postgres checkpoint storage via `langgraph-checkpoint-postgres`
|
||||
- Compatible with the assistant-ui frontend
|
||||
|
||||
## Installation
|
||||
|
||||
### Using uv (Recommended)
|
||||
|
||||
1. Initialize and install dependencies:
|
||||
```bash
|
||||
uv init --name assistant-transport-backend-langgraph --package
|
||||
uv add fastapi uvicorn[standard] assistant-stream pydantic python-dotenv "langgraph>=1.2.0" langgraph-checkpoint-postgres langchain langchain-core langchain-openai httpx
|
||||
# Or simply:
|
||||
uv sync
|
||||
```
|
||||
|
||||
2. Set up environment variables:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env to add your OpenAI API key
|
||||
```
|
||||
|
||||
### Using pip
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Set up environment variables:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env to add your OpenAI API key
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The server can be configured via environment variables:
|
||||
|
||||
- `HOST`: Server host (default: 0.0.0.0)
|
||||
- `PORT`: Server port (default: 8001)
|
||||
- `DEBUG`: Enable debug mode (default: false)
|
||||
- `LOG_LEVEL`: Log level (default: info)
|
||||
- `CORS_ORIGINS`: CORS origins (default: http://localhost:3000)
|
||||
- `OPENAI_API_KEY`: Your OpenAI API key (required)
|
||||
- `LANGGRAPH_POSTGRES_URL`: Optional Postgres connection URL for LangGraph checkpoints
|
||||
- `DATABASE_URL`: Fallback Postgres connection URL when `LANGGRAPH_POSTGRES_URL` is not set
|
||||
|
||||
## Running the Server
|
||||
|
||||
### Using uv
|
||||
```bash
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
Or with uvicorn directly:
|
||||
```bash
|
||||
uv run uvicorn main:app --reload --host 0.0.0.0 --port 8001
|
||||
```
|
||||
|
||||
### Using standard Python
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
Or with uvicorn directly:
|
||||
```bash
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8001
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST /api/chat
|
||||
Main chat endpoint that processes commands and streams responses using LangGraph.
|
||||
|
||||
Request body:
|
||||
```json
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"type": "add-message",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Hello, how are you?"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"system": "Optional system prompt",
|
||||
"state": {}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /health
|
||||
Health check endpoint.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The server receives chat requests at `/api/chat`
|
||||
2. Commands are converted to LangGraph messages (HumanMessage, AIMessage, etc.)
|
||||
3. The LangGraph processes the messages through its nodes using a per-thread checkpoint keyed by the AssistantTransport `threadId`
|
||||
4. Two streaming tasks run concurrently:
|
||||
- `astream` provides state updates
|
||||
- `astream_events` provides message streaming
|
||||
5. Both streams are synchronized to the frontend using `append_langgraph_event`
|
||||
6. The response is streamed back using assistant-stream's DataStreamResponse
|
||||
|
||||
Frontend tools declared by `useAssistantTransportRuntime` are bound to the LangGraph model from the request `tools` payload, but they are not executed by this backend. For example, the `with-assistant-transport` demo keeps `get_weather` frontend-only: the backend streams the tool call, the browser runs the tool and sends an `add-tool-result` command, and LangGraph continues from that result. Server-owned smoke tools such as `calculate_sum`, `save_note`, and `task_tool` still execute inside the backend graph.
|
||||
|
||||
## DeltaChannel Prototype Notes
|
||||
|
||||
The graph's `messages` state uses LangGraph's `DeltaChannel` with a bulk reducer:
|
||||
|
||||
```python
|
||||
def add_messages_delta(state, writes):
|
||||
result = list(state)
|
||||
for write in writes:
|
||||
if isinstance(write, BaseMessage):
|
||||
result = add_messages(result, [write])
|
||||
else:
|
||||
result = add_messages(result, list(write))
|
||||
return result
|
||||
```
|
||||
|
||||
This keeps the assistant-ui API unchanged. The frontend still uses `useAssistantTransportRuntime`; the backend still accepts normal AssistantTransport `add-message` and `add-tool-result` commands; and the response remains the default data-stream encoding. The only required API adjustment is inside the LangGraph state definition: a delta-backed channel reducer receives `(state, writes)` where `writes` is a batch, not the old pairwise `(state, update)` reducer shape.
|
||||
|
||||
Postgres works through LangGraph's async checkpointer path:
|
||||
|
||||
```bash
|
||||
docker run --rm -p 127.0.0.1:55432:5432 \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=assistant_ui \
|
||||
postgres:16-alpine
|
||||
|
||||
LANGGRAPH_POSTGRES_URL=postgresql://postgres:postgres@127.0.0.1:55432/assistant_ui \
|
||||
uv run python main.py
|
||||
```
|
||||
|
||||
Because the FastAPI route streams with `graph.astream`, the backend uses `AsyncPostgresSaver`; the synchronous `PostgresSaver` does not implement the async checkpointer methods used by this route.
|
||||
|
||||
## Integration with Frontend
|
||||
|
||||
This backend is designed to work with the assistant-ui frontend. Update your frontend configuration to point to this server:
|
||||
|
||||
```typescript
|
||||
const runtime = useExternalStoreRuntime({
|
||||
endpoint: "http://localhost:8001/api/chat"
|
||||
});
|
||||
```
|
||||
|
||||
## Customizing the LangGraph
|
||||
|
||||
You can customize the graph in the `create_graph()` function. Currently, it implements a simple chat node using OpenAI's GPT-5.4 Nano model. You can:
|
||||
|
||||
- Add more nodes for different functionalities
|
||||
- Implement tool calling
|
||||
- Add conditional edges
|
||||
- Integrate with different LLMs
|
||||
- Add memory or persistence
|
||||
|
||||
Example of adding a tool node:
|
||||
```python
|
||||
from langgraph.prebuilt import ToolExecutor
|
||||
|
||||
def create_graph():
|
||||
workflow = StateGraph(GraphState)
|
||||
|
||||
# Add nodes
|
||||
workflow.add_node("chat", chat_node)
|
||||
workflow.add_node("tools", tool_node)
|
||||
|
||||
# Add conditional routing
|
||||
workflow.add_conditional_edges(
|
||||
"chat",
|
||||
should_use_tools,
|
||||
{
|
||||
"tools": "tools",
|
||||
"end": END
|
||||
}
|
||||
)
|
||||
|
||||
return workflow.compile()
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test client for the LangGraph backend.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
|
||||
async def test_chat():
|
||||
"""Test the chat endpoint with streaming."""
|
||||
url = "http://localhost:8001/api/chat"
|
||||
|
||||
payload = {
|
||||
"commands": [
|
||||
{
|
||||
"type": "add-message",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Hello! Can you tell me a short joke?"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"system": "You are a helpful assistant.",
|
||||
"state": {}
|
||||
}
|
||||
|
||||
print("📤 Sending request to", url)
|
||||
print(f"📝 Message: {payload['commands'][0]['message']['parts'][0]['text']}")
|
||||
print("-" * 50)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"}
|
||||
) as response:
|
||||
print(f"📥 Response status: {response.status_code}")
|
||||
print(f"📥 Response headers: {dict(response.headers)}")
|
||||
print("-" * 50)
|
||||
print("📥 Streaming response:")
|
||||
|
||||
buffer = ""
|
||||
async for chunk in response.aiter_bytes():
|
||||
chunk_str = chunk.decode('utf-8')
|
||||
buffer += chunk_str
|
||||
|
||||
# Parse SSE events
|
||||
lines = buffer.split('\n')
|
||||
buffer = lines[-1] # Keep incomplete line in buffer
|
||||
|
||||
for line in lines[:-1]:
|
||||
if line.startswith('data: '):
|
||||
data_str = line[6:] # Remove 'data: ' prefix
|
||||
if data_str.strip():
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
if data.get("type") == "state-update":
|
||||
print(f" State update: {json.dumps(data.get('value', {}), indent=2)}")
|
||||
else:
|
||||
print(f" Event: {data.get('type', 'unknown')}")
|
||||
except json.JSONDecodeError:
|
||||
print(f" Raw: {data_str[:100]}...")
|
||||
|
||||
print("-" * 50)
|
||||
print("✅ Test completed")
|
||||
|
||||
async def test_health():
|
||||
"""Test the health endpoint."""
|
||||
url = "http://localhost:8001/health"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
print(f"Health check: {response.status_code}")
|
||||
print(f"Response: {response.json()}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🧪 Testing LangGraph Backend")
|
||||
print("=" * 50)
|
||||
|
||||
# Test health check
|
||||
print("\n1. Testing health endpoint...")
|
||||
asyncio.run(test_health())
|
||||
|
||||
# Test chat
|
||||
print("\n2. Testing chat endpoint...")
|
||||
asyncio.run(test_chat())
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./main.py:graph"
|
||||
},
|
||||
"env": ".env"
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Assistant Transport Backend with LangGraph - FastAPI + assistant-stream + LangGraph server
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated, Any, TypedDict
|
||||
from uuid import uuid4
|
||||
|
||||
import uvicorn
|
||||
from assistant_stream import RunController, create_run
|
||||
from assistant_stream.modules.langgraph import append_langgraph_event, get_tool_call_subgraph_state
|
||||
from assistant_stream.serialization import DataStreamResponse
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage
|
||||
from langchain_core.tools import tool
|
||||
from langchain_openai import ChatOpenAI
|
||||
from langgraph.channels import DeltaChannel
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
from langgraph.graph import END, StateGraph, add_messages
|
||||
from langgraph.graph.state import CompiledStateGraph
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
_postgres_checkpointer_context = None
|
||||
|
||||
|
||||
class MessagePart(BaseModel):
|
||||
"""A part of a user message."""
|
||||
type: str = Field(..., description="The type of message part")
|
||||
text: str | None = Field(None, description="Text content")
|
||||
image: str | None = Field(None, description="Image URL or data")
|
||||
|
||||
|
||||
class UserMessage(BaseModel):
|
||||
"""A user message."""
|
||||
role: str = Field(default="user", description="Message role")
|
||||
parts: list[MessagePart] = Field(..., description="Message parts")
|
||||
|
||||
|
||||
class AddMessageCommand(BaseModel):
|
||||
"""Command to add a new message to the conversation."""
|
||||
type: str = Field(default="add-message", description="Command type")
|
||||
message: UserMessage = Field(..., description="User message")
|
||||
|
||||
|
||||
class AddToolResultCommand(BaseModel):
|
||||
"""Command to add a tool result to the conversation."""
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
type: str = Field(default="add-tool-result", description="Command type")
|
||||
tool_call_id: str = Field(..., alias="toolCallId", description="ID of the tool call")
|
||||
tool_name: str | None = Field(None, alias="toolName", description="Name of the tool")
|
||||
result: Any = Field(..., description="Tool execution result")
|
||||
is_error: bool | None = Field(None, alias="isError", description="Whether the tool failed")
|
||||
artifact: Any | None = Field(None, description="UI-only tool result artifact")
|
||||
model_content: Any | None = Field(
|
||||
None, alias="modelContent", description="Tool result content for the model"
|
||||
)
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
"""Request payload for the chat endpoint."""
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
commands: list[AddMessageCommand | AddToolResultCommand] = Field(
|
||||
..., description="List of commands to execute"
|
||||
)
|
||||
system: str | None = Field(None, description="System prompt")
|
||||
tools: dict[str, Any] | None = Field(None, description="Available tools")
|
||||
run_config: dict[str, Any] | None = Field(
|
||||
None, alias="runConfig", description="Run configuration"
|
||||
)
|
||||
thread_id: str | None = Field(None, alias="threadId", description="Assistant UI thread ID")
|
||||
state: dict[str, Any] | None = Field(None, description="State")
|
||||
|
||||
|
||||
def get_thread_id(request: ChatRequest) -> str:
|
||||
"""Use persistent checkpoints only when the AssistantTransport request identifies a thread."""
|
||||
return request.thread_id or f"anonymous-{uuid4()}"
|
||||
|
||||
|
||||
def add_messages_delta(
|
||||
state: Sequence[BaseMessage],
|
||||
writes: Sequence[BaseMessage | Sequence[BaseMessage]],
|
||||
) -> list[BaseMessage]:
|
||||
result = list(state)
|
||||
for write in writes:
|
||||
if isinstance(write, BaseMessage):
|
||||
result = add_messages(result, [write])
|
||||
else:
|
||||
result = add_messages(result, list(write))
|
||||
return result
|
||||
|
||||
|
||||
def request_tool_schemas(tools: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
if not tools:
|
||||
return []
|
||||
|
||||
schemas = []
|
||||
for name, tool_definition in tools.items():
|
||||
if (
|
||||
name in TOOL_BY_NAME
|
||||
or not isinstance(tool_definition, dict)
|
||||
or tool_definition.get("disabled") is True
|
||||
):
|
||||
continue
|
||||
|
||||
parameters = tool_definition.get("parameters") or {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
}
|
||||
schemas.append(
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": tool_definition.get("description", ""),
|
||||
"parameters": parameters,
|
||||
},
|
||||
}
|
||||
)
|
||||
return schemas
|
||||
|
||||
|
||||
def bindable_tools(tools: dict[str, Any] | None) -> list[Any]:
|
||||
return [*TOOLS, *request_tool_schemas(tools)]
|
||||
|
||||
|
||||
def tool_result_content(command: AddToolResultCommand) -> str:
|
||||
content = command.model_content if command.model_content is not None else command.result
|
||||
return content if isinstance(content, str) else json.dumps(content)
|
||||
|
||||
|
||||
# Define LangGraph state
|
||||
class GraphState(TypedDict, total=False):
|
||||
"""State for the conversation graph."""
|
||||
messages: Annotated[
|
||||
Sequence[BaseMessage],
|
||||
DeltaChannel(reducer=add_messages_delta, snapshot_frequency=50),
|
||||
]
|
||||
tools: dict[str, Any] | None
|
||||
|
||||
|
||||
# Define subagent state
|
||||
class SubagentState(TypedDict):
|
||||
"""State for the subagent."""
|
||||
messages: Annotated[
|
||||
Sequence[BaseMessage],
|
||||
DeltaChannel(reducer=add_messages_delta, snapshot_frequency=50),
|
||||
]
|
||||
task: str
|
||||
result: str
|
||||
|
||||
|
||||
# Create the Task tool
|
||||
@tool
|
||||
def task_tool(task_description: str) -> str:
|
||||
"""
|
||||
Execute a complex task using a subagent.
|
||||
|
||||
Args:
|
||||
task_description: Description of the task to perform
|
||||
|
||||
Returns:
|
||||
The result of the task execution
|
||||
"""
|
||||
# This is a placeholder - the actual execution will be handled by the subgraph
|
||||
return f"Task '{task_description}' will be executed by the subagent."
|
||||
|
||||
|
||||
@tool
|
||||
def calculate_sum(numbers: list[float]) -> dict[str, Any]:
|
||||
"""
|
||||
Add a list of numbers.
|
||||
|
||||
Args:
|
||||
numbers: Numbers to add together.
|
||||
"""
|
||||
return {
|
||||
"numbers": numbers,
|
||||
"sum": sum(numbers),
|
||||
"count": len(numbers),
|
||||
}
|
||||
|
||||
|
||||
@tool
|
||||
def save_note(title: str, body: str) -> dict[str, Any]:
|
||||
"""
|
||||
Save a sample note and return a note ID.
|
||||
|
||||
Args:
|
||||
title: Short note title.
|
||||
body: Note body.
|
||||
"""
|
||||
note_seed = f"{title}\n{body}"
|
||||
note_id = sum((index + 1) * ord(char) for index, char in enumerate(note_seed))
|
||||
return {
|
||||
"id": f"note-{note_id % 100000}",
|
||||
"title": title,
|
||||
"body": body,
|
||||
"saved": True,
|
||||
}
|
||||
|
||||
|
||||
TOOLS = [task_tool, calculate_sum, save_note]
|
||||
TOOL_BY_NAME = {tool.name: tool for tool in TOOLS}
|
||||
|
||||
|
||||
# Subagent node for executing tasks
|
||||
async def subagent_node(state: SubagentState) -> dict[str, Any]:
|
||||
"""Subagent that executes the task."""
|
||||
task = state.get("task", "")
|
||||
|
||||
# Create a prompt for the subagent
|
||||
subagent_messages = [
|
||||
SystemMessage(content=f"You are a helpful subagent. Execute this task: {task}"),
|
||||
HumanMessage(content=f"Please complete the following task: {task}")
|
||||
]
|
||||
|
||||
# Generate response
|
||||
if os.getenv("OPENAI_API_KEY"):
|
||||
# Initialize a simpler LLM for the subagent
|
||||
llm = ChatOpenAI(
|
||||
model="gpt-5.4-nano",
|
||||
temperature=0.7,
|
||||
streaming=True
|
||||
)
|
||||
response = await llm.ainvoke(subagent_messages)
|
||||
result = response.content
|
||||
else:
|
||||
result = f"Mock subagent result for task: {task}"
|
||||
|
||||
return {
|
||||
"messages": [AIMessage(content=result)],
|
||||
"result": result
|
||||
}
|
||||
|
||||
|
||||
def create_subagent_graph() -> CompiledStateGraph:
|
||||
"""Create the subagent graph."""
|
||||
workflow = StateGraph(SubagentState)
|
||||
|
||||
# Add the subagent node
|
||||
workflow.add_node("execute_task", subagent_node)
|
||||
|
||||
# Set entry and exit points
|
||||
workflow.set_entry_point("execute_task")
|
||||
workflow.add_edge("execute_task", END)
|
||||
|
||||
return workflow.compile()
|
||||
|
||||
|
||||
async def agent_node(state: GraphState) -> dict[str, Any]:
|
||||
"""Main agent node that can call tools."""
|
||||
messages = state.get("messages", [])
|
||||
tools = state.get("tools")
|
||||
|
||||
# Check if OpenAI API key is set
|
||||
if os.getenv("OPENAI_API_KEY"):
|
||||
# Initialize the LLM with tool binding
|
||||
llm = ChatOpenAI(
|
||||
model="gpt-5.4-nano",
|
||||
temperature=0.7,
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
# Bind server tools plus request-provided frontend tool declarations.
|
||||
llm_with_tools = llm.bind_tools(bindable_tools(tools))
|
||||
response = await llm_with_tools.ainvoke(messages)
|
||||
elif messages and isinstance(messages[-1], ToolMessage):
|
||||
response = AIMessage(
|
||||
content=f"Task complete: {messages[-1].content}",
|
||||
)
|
||||
else:
|
||||
# Mock response with a tool call for testing
|
||||
print("⚠️ No OpenAI API key found - using mock response with tool call")
|
||||
last_content = messages[-1].content if messages else ""
|
||||
if "weather" in str(last_content).lower() and tools and "get_weather" in tools:
|
||||
tool_call = {
|
||||
"id": "weather_001",
|
||||
"name": "get_weather",
|
||||
"args": {"location": "San Francisco", "unit": "fahrenheit"},
|
||||
}
|
||||
elif "sum" in str(last_content).lower() or "add" in str(last_content).lower():
|
||||
tool_call = {
|
||||
"id": "sum_001",
|
||||
"name": "calculate_sum",
|
||||
"args": {"numbers": [2, 3, 5]},
|
||||
}
|
||||
elif "note" in str(last_content).lower():
|
||||
tool_call = {
|
||||
"id": "note_001",
|
||||
"name": "save_note",
|
||||
"args": {"title": "Smoke test", "body": "Saved from mock mode"},
|
||||
}
|
||||
else:
|
||||
tool_call = {
|
||||
"id": "task_001",
|
||||
"name": "task_tool",
|
||||
"args": {"task_description": "Complete the requested task"},
|
||||
}
|
||||
|
||||
response = AIMessage(content="I'll call a tool for that.", tool_calls=[tool_call])
|
||||
|
||||
return {"messages": [response]}
|
||||
|
||||
|
||||
def should_call_tools(state: GraphState) -> str:
|
||||
"""Run only backend-owned tools. Frontend tools are returned to the client."""
|
||||
messages = state.get("messages", [])
|
||||
if not messages:
|
||||
return "end"
|
||||
|
||||
last_message = messages[-1]
|
||||
if (
|
||||
hasattr(last_message, 'tool_calls')
|
||||
and last_message.tool_calls
|
||||
and any(tool_call["name"] in TOOL_BY_NAME for tool_call in last_message.tool_calls)
|
||||
):
|
||||
return "tools"
|
||||
|
||||
return "end"
|
||||
|
||||
|
||||
async def tool_executor_node(state: GraphState) -> dict[str, Any]:
|
||||
"""Execute tool calls, including Task tool which spawns subagents."""
|
||||
messages = state.get("messages", [])
|
||||
if not messages:
|
||||
return {"messages": []}
|
||||
|
||||
last_message = messages[-1]
|
||||
if not hasattr(last_message, 'tool_calls') or not last_message.tool_calls:
|
||||
return {"messages": []}
|
||||
|
||||
# Process each tool call
|
||||
tool_messages = []
|
||||
for tool_call in last_message.tool_calls:
|
||||
tool_name = tool_call["name"]
|
||||
if tool_name == "task_tool":
|
||||
# Extract task description
|
||||
task_description = tool_call["args"].get("task_description", "")
|
||||
|
||||
# Create and run the subagent graph
|
||||
|
||||
# Initialize subagent state
|
||||
subagent_state = {
|
||||
"messages": [],
|
||||
"task": task_description,
|
||||
"result": ""
|
||||
}
|
||||
|
||||
# Run the subagent
|
||||
final_state = await subagent_graph.ainvoke(subagent_state)
|
||||
|
||||
# Create tool message with the result
|
||||
tool_message = ToolMessage(
|
||||
content=final_state.get("result", "Task completed"),
|
||||
tool_call_id=tool_call["id"],
|
||||
artifact={"subgraph_state": final_state}
|
||||
)
|
||||
tool_messages.append(tool_message)
|
||||
else:
|
||||
tool = TOOL_BY_NAME.get(tool_name)
|
||||
if tool is None:
|
||||
result = {"error": f"Unknown tool: {tool_name}"}
|
||||
else:
|
||||
result = tool.invoke(tool_call.get("args", {}))
|
||||
|
||||
tool_message = ToolMessage(
|
||||
content=json.dumps(result),
|
||||
tool_call_id=tool_call["id"],
|
||||
name=tool_name,
|
||||
artifact=result,
|
||||
)
|
||||
tool_messages.append(tool_message)
|
||||
|
||||
return {"messages": tool_messages}
|
||||
|
||||
|
||||
subagent_graph = create_subagent_graph()
|
||||
|
||||
async def configure_checkpointer_from_env() -> None:
|
||||
"""Switch the graph to the configured persistent checkpointer, when present."""
|
||||
postgres_url = os.getenv("LANGGRAPH_POSTGRES_URL") or os.getenv("DATABASE_URL")
|
||||
if not postgres_url:
|
||||
return
|
||||
|
||||
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||
|
||||
global _postgres_checkpointer_context, graph
|
||||
if _postgres_checkpointer_context is not None:
|
||||
return
|
||||
|
||||
_postgres_checkpointer_context = AsyncPostgresSaver.from_conn_string(postgres_url)
|
||||
checkpointer = await _postgres_checkpointer_context.__aenter__()
|
||||
await checkpointer.setup()
|
||||
graph = create_graph(checkpointer)
|
||||
|
||||
|
||||
def create_graph(checkpointer=None) -> CompiledStateGraph:
|
||||
"""Create and compile the LangGraph with subgraph support."""
|
||||
# Create the main workflow
|
||||
workflow = StateGraph(GraphState)
|
||||
|
||||
# Add nodes
|
||||
workflow.add_node("agent", agent_node)
|
||||
workflow.add_node("tools", tool_executor_node)
|
||||
|
||||
# Set entry point
|
||||
workflow.set_entry_point("agent")
|
||||
|
||||
# Add conditional edges
|
||||
workflow.add_conditional_edges(
|
||||
"agent",
|
||||
should_call_tools,
|
||||
{
|
||||
"tools": "tools",
|
||||
"end": END
|
||||
}
|
||||
)
|
||||
|
||||
# After tools, go back to agent for potential follow-up
|
||||
workflow.add_edge("tools", "agent")
|
||||
|
||||
# Compile with a checkpointer so DeltaChannel is exercised across thread turns.
|
||||
return workflow.compile(checkpointer=checkpointer or InMemorySaver())
|
||||
|
||||
graph = create_graph()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan manager."""
|
||||
print("🚀 Assistant Transport Backend with LangGraph starting up...")
|
||||
await configure_checkpointer_from_env()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if _postgres_checkpointer_context is not None:
|
||||
await _postgres_checkpointer_context.__aexit__(None, None, None)
|
||||
print("🛑 Assistant Transport Backend with LangGraph shutting down...")
|
||||
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="Assistant Transport Backend with LangGraph",
|
||||
description=(
|
||||
"A server implementing the assistant-transport protocol with LangGraph and subgraphs"
|
||||
),
|
||||
version="0.2.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
cors_origins = ["*"] # Allow all origins
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.post("/assistant")
|
||||
async def chat_endpoint(request: ChatRequest):
|
||||
"""Chat endpoint using LangGraph with streaming and subgraph support."""
|
||||
|
||||
async def run_callback(controller: RunController):
|
||||
"""Callback function for the run controller."""
|
||||
# Initialize controller state if needed
|
||||
if controller.state is None:
|
||||
controller.state = {}
|
||||
if "messages" not in controller.state:
|
||||
controller.state["messages"] = []
|
||||
|
||||
input_messages = []
|
||||
|
||||
# Process commands
|
||||
for command in request.commands:
|
||||
if command.type == "add-message":
|
||||
# Extract text from parts
|
||||
text_parts = [
|
||||
part.text for part in command.message.parts
|
||||
if part.type == "text" and part.text
|
||||
]
|
||||
if text_parts:
|
||||
input_messages.append(HumanMessage(content=" ".join(text_parts)))
|
||||
elif command.type == "add-tool-result":
|
||||
# Handle tool results
|
||||
input_messages.append(ToolMessage(
|
||||
content=tool_result_content(command),
|
||||
tool_call_id=command.tool_call_id,
|
||||
name=command.tool_name,
|
||||
artifact=command.artifact if command.artifact is not None else command.result,
|
||||
status="error" if command.is_error else "success",
|
||||
))
|
||||
|
||||
# Add messages to controller state
|
||||
for message in input_messages:
|
||||
controller.state["messages"].append(message.model_dump())
|
||||
|
||||
# Create initial state for LangGraph
|
||||
input_state = {"messages": input_messages, "tools": request.tools}
|
||||
|
||||
# Stream with subgraph support
|
||||
config = {
|
||||
"configurable": {
|
||||
"thread_id": get_thread_id(request),
|
||||
}
|
||||
}
|
||||
|
||||
async for namespace, event_type, chunk in graph.astream(
|
||||
input_state,
|
||||
config=config,
|
||||
stream_mode=["messages", "updates"],
|
||||
subgraphs=True
|
||||
):
|
||||
state = get_tool_call_subgraph_state(
|
||||
controller,
|
||||
subgraph_node="tools",
|
||||
namespace=namespace,
|
||||
artifact_field_name="subgraph_state",
|
||||
default_state={}
|
||||
)
|
||||
# Append the event normally
|
||||
append_langgraph_event(
|
||||
state,
|
||||
namespace,
|
||||
event_type,
|
||||
chunk
|
||||
)
|
||||
|
||||
# Create streaming response using assistant-stream
|
||||
stream = create_run(run_callback, state=request.state)
|
||||
|
||||
return DataStreamResponse(stream)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy", "service": "assistant-transport-backend-langgraph"}
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for running the server."""
|
||||
host = os.getenv("HOST", "0.0.0.0")
|
||||
port = int(os.getenv("PORT", "8010"))
|
||||
debug = os.getenv("DEBUG", "false").lower() == "true"
|
||||
log_level = os.getenv("LOG_LEVEL", "info").lower()
|
||||
|
||||
print(f"🌟 Starting Assistant Transport Backend with LangGraph on {host}:{port}")
|
||||
print(f"🎯 Debug mode: {debug}")
|
||||
print(f"🌍 CORS origins: {cors_origins}")
|
||||
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=host,
|
||||
port=port,
|
||||
reload=debug,
|
||||
log_level=log_level,
|
||||
access_log=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,50 @@
|
||||
[project]
|
||||
name = "assistant-transport-backend-langgraph"
|
||||
version = "0.1.0"
|
||||
description = "Assistant Transport Backend with LangGraph integration"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.100.0",
|
||||
"uvicorn[standard]>=0.20.0",
|
||||
"assistant-stream>=0.0.28",
|
||||
"pydantic>=2.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
"langgraph>=1.2.0",
|
||||
"langchain>=0.2.0",
|
||||
"langchain-core>=0.2.0",
|
||||
"langchain-openai>=0.1.0",
|
||||
"httpx>=0.24.0",
|
||||
"langgraph-checkpoint-postgres>=3.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"black>=23.0.0",
|
||||
"isort>=5.12.0",
|
||||
"mypy>=1.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["."]
|
||||
exclude = ["client_demo.py", "subgraph_demo.py"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py310"
|
||||
line-length = 100
|
||||
select = ["E", "F", "I", "N", "W", "UP"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ["py310"]
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
line_length = 100
|
||||
@@ -0,0 +1,21 @@
|
||||
# Core dependencies
|
||||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.20.0
|
||||
assistant-stream>=0.0.28
|
||||
pydantic>=2.0.0
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# LangGraph dependencies
|
||||
langgraph>=1.2.0
|
||||
langgraph-checkpoint-postgres>=3.0.0
|
||||
langchain>=0.2.0
|
||||
langchain-core>=0.2.0
|
||||
langchain-openai>=0.1.0
|
||||
|
||||
# Development dependencies
|
||||
pytest>=7.0.0
|
||||
pytest-asyncio>=0.21.0
|
||||
black>=23.0.0
|
||||
isort>=5.12.0
|
||||
mypy>=1.0.0
|
||||
ruff>=0.1.0
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test client for the LangGraph backend with subgraph support.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import json
|
||||
|
||||
async def test_subgraph_chat():
|
||||
"""Test the chat endpoint with task tool and subgraph streaming."""
|
||||
url = "http://localhost:8000/assistant"
|
||||
|
||||
payload = {
|
||||
"commands": [
|
||||
{
|
||||
"type": "add-message",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Please help me create a task that writes a haiku about Python programming."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"system": "You are a helpful assistant that can delegate complex tasks to subagents using the Task tool.",
|
||||
"state": {}
|
||||
}
|
||||
|
||||
print("📤 Sending request to", url)
|
||||
print(f"📝 Message: {payload['commands'][0]['message']['parts'][0]['text']}")
|
||||
print("-" * 50)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
url,
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"}
|
||||
) as response:
|
||||
print(f"📥 Response status: {response.status_code}")
|
||||
print("-" * 50)
|
||||
print("📥 Streaming response:")
|
||||
|
||||
buffer = ""
|
||||
message_count = 0
|
||||
tool_call_count = 0
|
||||
|
||||
async for chunk in response.aiter_bytes():
|
||||
chunk_str = chunk.decode('utf-8')
|
||||
buffer += chunk_str
|
||||
|
||||
# Parse SSE events
|
||||
lines = buffer.split('\n')
|
||||
buffer = lines[-1] # Keep incomplete line in buffer
|
||||
|
||||
for line in lines[:-1]:
|
||||
if line.startswith('data: '):
|
||||
data_str = line[6:] # Remove 'data: ' prefix
|
||||
if data_str.strip() and data_str.strip() != '[DONE]':
|
||||
try:
|
||||
data = json.loads(data_str)
|
||||
|
||||
# Log different types of events
|
||||
if data.get("type") == "state-update":
|
||||
state = data.get("value", {})
|
||||
|
||||
# Check for messages
|
||||
if "messages" in state:
|
||||
messages = state["messages"]
|
||||
if len(messages) > message_count:
|
||||
message_count = len(messages)
|
||||
last_msg = messages[-1]
|
||||
|
||||
# Log AI messages
|
||||
if last_msg.get("type") == "ai" or last_msg.get("role") == "assistant":
|
||||
print(f"\n🤖 AI Message:")
|
||||
if "content" in last_msg:
|
||||
print(f" Content: {last_msg['content']}")
|
||||
if "tool_calls" in last_msg:
|
||||
print(f" Tool Calls: {last_msg['tool_calls']}")
|
||||
tool_call_count = len(last_msg['tool_calls'])
|
||||
|
||||
# Log Tool messages (from subgraph)
|
||||
elif last_msg.get("type") == "tool" or last_msg.get("role") == "tool":
|
||||
print(f"\n🔧 Tool Message:")
|
||||
print(f" Content: {last_msg.get('content', '')}")
|
||||
if "artifact" in last_msg:
|
||||
print(f" Artifact: {json.dumps(last_msg['artifact'], indent=6)}")
|
||||
|
||||
# Check for other state updates
|
||||
for key in state:
|
||||
if key not in ["messages", "status"]:
|
||||
print(f"\n📊 State Update - {key}: {state[key]}")
|
||||
|
||||
elif data.get("type") == "message-delta":
|
||||
# Handle streaming message updates
|
||||
delta = data.get("value", {})
|
||||
if delta.get("content"):
|
||||
print(f" Streaming: {delta['content']}", end="")
|
||||
|
||||
else:
|
||||
# Other event types
|
||||
event_type = data.get("type", "unknown")
|
||||
if event_type not in ["ping", "status"]:
|
||||
print(f"\n📡 Event ({event_type}): {data.get('value', '')}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
if data_str.strip():
|
||||
print(f" Parse error: {e}")
|
||||
print(f" Raw data: {data_str[:200]}...")
|
||||
|
||||
print("\n" + "-" * 50)
|
||||
print(f"✅ Test completed - Processed {message_count} messages, {tool_call_count} tool calls")
|
||||
|
||||
|
||||
async def test_direct_tool_result():
|
||||
"""Test sending a tool result back to the assistant."""
|
||||
url = "http://localhost:8000/assistant"
|
||||
|
||||
# First create a state with a tool call
|
||||
initial_state = {
|
||||
"messages": [
|
||||
{
|
||||
"type": "human",
|
||||
"content": "Help me with a task"
|
||||
},
|
||||
{
|
||||
"type": "ai",
|
||||
"content": "I'll help you with that task.",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "test_tool_001",
|
||||
"name": "task_tool",
|
||||
"args": {"task_description": "Write a poem"}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
payload = {
|
||||
"commands": [
|
||||
{
|
||||
"type": "add-tool-result",
|
||||
"toolCallId": "test_tool_001",
|
||||
"result": {"output": "A beautiful poem about coding"}
|
||||
}
|
||||
],
|
||||
"system": "You are a helpful assistant.",
|
||||
"state": initial_state
|
||||
}
|
||||
|
||||
print("📤 Testing tool result handling")
|
||||
print("-" * 50)
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, json=payload)
|
||||
print(f"📥 Response status: {response.status_code}")
|
||||
|
||||
# Read the full response for tool result test
|
||||
content = response.text
|
||||
print(f"📥 Response preview: {content[:500]}...")
|
||||
|
||||
print("✅ Tool result test completed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("🧪 Testing LangGraph Backend with Subgraph Support")
|
||||
print("=" * 50)
|
||||
|
||||
# Test 1: Subgraph chat with task tool
|
||||
print("\n1. Testing subgraph chat with task delegation...")
|
||||
asyncio.run(test_subgraph_chat())
|
||||
|
||||
# Test 2: Direct tool result handling
|
||||
print("\n2. Testing direct tool result handling...")
|
||||
asyncio.run(test_direct_tool_result())
|
||||
+2361
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
# Server Configuration
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DEBUG=true
|
||||
|
||||
# AI Provider Configuration (choose one)
|
||||
# OpenAI Configuration
|
||||
OPENAI_API_KEY=your_openai_api_key_here
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
|
||||
# Anthropic Configuration
|
||||
# ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
# ANTHROPIC_MODEL=claude-3-haiku-20240307
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGINS=http://localhost:3000,https://yourapp.com
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
@@ -0,0 +1,217 @@
|
||||
# Assistant Transport Backend
|
||||
|
||||
A simple Python server that demonstrates the assistant-transport protocol using FastAPI and assistant-stream. This backend returns static responses to show how the streaming protocol works with assistant-ui frontend applications.
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 **FastAPI-based** - High-performance async server
|
||||
- 📡 **Streaming Responses** - Real-time responses using assistant-stream
|
||||
- 🔄 **State Management** - Uses assistant-stream's object-stream state utilities
|
||||
- 🔌 **Assistant-Transport Protocol** - Full compatibility with assistant-ui
|
||||
- 🌐 **CORS Enabled** - Works with any frontend origin
|
||||
- 📦 **Simple Setup** - Minimal dependencies
|
||||
- 🧪 **Static Responses** - No API keys required, perfect for testing
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.9 or higher
|
||||
- pip package manager
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
# Install the package and dependencies
|
||||
pip install -e .
|
||||
|
||||
# Or install dependencies directly
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Configure Environment (Optional)
|
||||
|
||||
```bash
|
||||
# Copy example environment file
|
||||
cp .env.example .env
|
||||
# Edit .env file if needed
|
||||
```
|
||||
|
||||
Default configuration:
|
||||
```env
|
||||
# Server Configuration
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DEBUG=true
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
```
|
||||
|
||||
### 3. Start the Server
|
||||
|
||||
```bash
|
||||
# Using the installed command
|
||||
assistant-transport-backend
|
||||
|
||||
# Or run directly
|
||||
python main.py
|
||||
|
||||
# Or using uvicorn
|
||||
uvicorn main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
The server will be available at:
|
||||
- **API**: http://localhost:8000
|
||||
- **Health Check**: http://localhost:8000/health
|
||||
- **Assistant Endpoint**: http://localhost:8000/assistant
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### `POST /assistant`
|
||||
|
||||
Main endpoint that implements the assistant-transport protocol.
|
||||
|
||||
**Request Format:**
|
||||
```json
|
||||
{
|
||||
"commands": [
|
||||
{
|
||||
"type": "add-message",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"type": "text", "text": "Hello!"}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"system": "You are a helpful assistant",
|
||||
"tools": {},
|
||||
"runConfig": {}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** Streaming response using assistant-stream format with static responses.
|
||||
|
||||
### `GET /health`
|
||||
|
||||
Health check endpoint that returns server status and current conversation state.
|
||||
|
||||
### `POST /cancel`
|
||||
|
||||
Cancel the current request (placeholder for request cancellation).
|
||||
|
||||
## Static Response Patterns
|
||||
|
||||
The backend recognizes these message patterns and returns appropriate static responses:
|
||||
|
||||
- **Greetings** (`hello`, `hi`) → Welcome message
|
||||
- **Weather** (`weather`) → Sunny static response
|
||||
- **What/What is** → Explanation of what the backend does
|
||||
- **Help** → Available command information
|
||||
- **Other messages** → Acknowledgment with echo
|
||||
|
||||
## Integration with Frontend
|
||||
|
||||
This backend works with the `with-assistant-transport` frontend example:
|
||||
|
||||
1. Start backend: `python main.py`
|
||||
2. Start frontend: `cd ../../examples/with-assistant-transport && pnpm dev`
|
||||
3. Frontend connects to `http://localhost:8000/assistant`
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
python/assistant-transport-backend/
|
||||
├── main.py # FastAPI server and main entry point
|
||||
├── pyproject.toml # Project configuration and dependencies
|
||||
├── requirements.txt # Pip requirements file
|
||||
├── setup.py # Automated setup script
|
||||
├── .env.example # Environment variables template
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Assistant-Stream Integration
|
||||
|
||||
The backend uses `assistant_stream.create_run()` to create a streaming controller that:
|
||||
|
||||
1. **Manages State**: Updates conversation state with messages
|
||||
2. **Streams Text**: Uses `controller.append_text()` for character-by-character streaming
|
||||
3. **Updates State**: Uses `controller.state` to update the object-stream state
|
||||
4. **Handles Protocol**: Automatically formats responses for assistant-transport
|
||||
|
||||
### State Management
|
||||
|
||||
```python
|
||||
# Initialize state
|
||||
conversation_state = {
|
||||
"messages": [],
|
||||
"provider": "static"
|
||||
}
|
||||
|
||||
# Create run controller with state
|
||||
controller = create_run(conversation_state)
|
||||
|
||||
# Update state during processing
|
||||
controller.state.provider = "processing"
|
||||
controller.state.messages.append(new_message)
|
||||
|
||||
# Stream text responses
|
||||
for char in response_text:
|
||||
controller.append_text(char)
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Running in Development Mode
|
||||
|
||||
```bash
|
||||
# Enable debug mode and auto-reload
|
||||
DEBUG=true python main.py
|
||||
```
|
||||
|
||||
### Adding Response Patterns
|
||||
|
||||
Edit the static response logic in `main.py`:
|
||||
|
||||
```python
|
||||
# Add new patterns
|
||||
if "goodbye" in user_message:
|
||||
response_text = "Goodbye! Thanks for testing the assistant-transport backend!"
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Test the health endpoint
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# Test the assistant endpoint
|
||||
curl -X POST http://localhost:8000/assistant \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"commands":[{"type":"add-message","message":{"role":"user","parts":[{"type":"text","text":"Hello!"}]}}]}'
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HOST` | Server host | `0.0.0.0` |
|
||||
| `PORT` | Server port | `8000` |
|
||||
| `DEBUG` | Enable debug mode | `false` |
|
||||
| `LOG_LEVEL` | Logging level | `info` |
|
||||
| `CORS_ORIGINS` | Allowed CORS origins | `http://localhost:3000` |
|
||||
|
||||
## License
|
||||
|
||||
This project is part of the assistant-ui monorepo and follows the same MIT licensing terms.
|
||||
|
||||
## Learn More
|
||||
|
||||
- [assistant-ui Documentation](https://docs.assistant-ui.com)
|
||||
- [Assistant Transport Protocol](https://docs.assistant-ui.com/runtimes/assistant-transport)
|
||||
- [assistant-stream Package](https://github.com/assistant-ui/assistant-ui/tree/main/python/assistant-stream)
|
||||
- [FastAPI Documentation](https://fastapi.tiangolo.com)
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Assistant Transport Backend - Simple FastAPI + assistant-stream server
|
||||
|
||||
This server implements the assistant-transport protocol with static responses.
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import random
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
from contextlib import asynccontextmanager
|
||||
import uvicorn
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from assistant_stream.serialization import DataStreamResponse
|
||||
from assistant_stream import RunController, create_run
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class MessagePart(BaseModel):
|
||||
"""A part of a user message."""
|
||||
type: str = Field(..., description="The type of message part")
|
||||
text: Optional[str] = Field(None, description="Text content")
|
||||
image: Optional[str] = Field(None, description="Image URL or data")
|
||||
|
||||
|
||||
class UserMessage(BaseModel):
|
||||
"""A user message."""
|
||||
role: str = Field(default="user", description="Message role")
|
||||
parts: List[MessagePart] = Field(..., description="Message parts")
|
||||
|
||||
|
||||
class AddMessageCommand(BaseModel):
|
||||
"""Command to add a new message to the conversation."""
|
||||
type: str = Field(default="add-message", description="Command type")
|
||||
message: UserMessage = Field(..., description="User message")
|
||||
|
||||
|
||||
class AddToolResultCommand(BaseModel):
|
||||
"""Command to add a tool result to the conversation."""
|
||||
type: str = Field(default="add-tool-result", description="Command type")
|
||||
toolCallId: str = Field(..., description="ID of the tool call")
|
||||
result: Dict[str, Any] = Field(..., description="Tool execution result")
|
||||
|
||||
|
||||
class AssistantRequest(BaseModel):
|
||||
"""Request payload for the assistant endpoint."""
|
||||
commands: List[Union[AddMessageCommand, AddToolResultCommand]] = Field(
|
||||
..., description="List of commands to execute"
|
||||
)
|
||||
system: Optional[str] = Field(None, description="System prompt")
|
||||
tools: Optional[Dict[str, Any]] = Field(None, description="Available tools")
|
||||
# Additional fields that may be sent by the frontend
|
||||
runConfig: Optional[Dict[str, Any]] = Field(None, description="Run configuration")
|
||||
state: Optional[Dict[str, Any]] = Field(None, description="State")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan manager."""
|
||||
print("🚀 Assistant Transport Backend starting up...")
|
||||
yield
|
||||
print("🛑 Assistant Transport Backend shutting down...")
|
||||
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="Assistant Transport Backend",
|
||||
description="A simple server implementing the assistant-transport protocol with static responses",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
cors_origins = os.getenv("CORS_ORIGINS", "http://localhost:3000").split(",")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.post("/assistant")
|
||||
async def assistant_endpoint(request: AssistantRequest):
|
||||
# Create streaming response using assistant-stream
|
||||
async def run_callback(controller: RunController):
|
||||
"""Callback function for the run controller."""
|
||||
try:
|
||||
print("run_callback")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if (request.commands[0].type == "add-message"):
|
||||
controller.state["messages"].append(request.commands[0].message.model_dump())
|
||||
if (request.commands[0].type == "add-tool-result"):
|
||||
controller.state["messages"][-1]["parts"][-1]["result"] = request.commands[0].result
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
controller.state["messages"].append({
|
||||
"role": "assistant",
|
||||
"parts": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Hello22"
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
controller.state["messages"][-1]["parts"].append({
|
||||
"type": "tool-call",
|
||||
"toolCallId": "tool_" + str(random.randint(0, 1000000)),
|
||||
"toolName": "get_weather",
|
||||
"argsText": "",
|
||||
"done": False,
|
||||
})
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
controller.state["messages"][-1]["parts"][-1]["argsText"] = "{\"location\": \"SF\""
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
controller.state["messages"][-1]["parts"][-1]["argsText"] = controller.state["messages"][-1]["parts"][-1]["argsText"] + "}"
|
||||
controller.state["messages"][-1]["parts"][-1]["done"] = True
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
controller.state["provider"] = "completed"
|
||||
print("run_callback3")
|
||||
except Exception as e:
|
||||
print(f"❌ Error in stream generation: {e}")
|
||||
controller.state["provider"] = "error"
|
||||
controller.append_text(f"Error: {str(e)}")
|
||||
|
||||
# Create streaming response using assistant-stream
|
||||
stream = create_run(run_callback, state=request.state)
|
||||
|
||||
return DataStreamResponse(stream)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for running the server."""
|
||||
host = os.getenv("HOST", "0.0.0.0")
|
||||
port = int(os.getenv("PORT", "8000"))
|
||||
debug = os.getenv("DEBUG", "false").lower() == "true"
|
||||
log_level = os.getenv("LOG_LEVEL", "info").lower()
|
||||
|
||||
print(f"🌟 Starting Assistant Transport Backend on {host}:{port}")
|
||||
print(f"🎯 Debug mode: {debug}")
|
||||
print(f"🌍 CORS origins: {cors_origins}")
|
||||
|
||||
uvicorn.run(
|
||||
"main:app",
|
||||
host=host,
|
||||
port=port,
|
||||
reload=debug,
|
||||
log_level=log_level,
|
||||
access_log=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,77 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "assistant-transport-backend"
|
||||
version = "0.1.0"
|
||||
description = "A simple server implementing the assistant-transport protocol with static responses"
|
||||
authors = [
|
||||
{ name = "assistant-ui Team", email = "team@assistant-ui.com" }
|
||||
]
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
requires-python = ">=3.10,<4.0"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
]
|
||||
dependencies = [
|
||||
"fastapi>=0.100.0",
|
||||
"uvicorn[standard]>=0.20.0",
|
||||
"assistant-stream>=0.0.28",
|
||||
"pydantic>=2.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
openai = ["openai>=1.0.0"]
|
||||
anthropic = ["anthropic>=0.18.0"]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"black>=23.0.0",
|
||||
"isort>=5.12.0",
|
||||
"mypy>=1.0.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/assistant-ui/assistant-ui"
|
||||
Issues = "https://github.com/assistant-ui/assistant-ui/issues"
|
||||
Documentation = "https://docs.assistant-ui.com"
|
||||
|
||||
[project.scripts]
|
||||
assistant-transport-backend = "main:main"
|
||||
|
||||
[tool.hatch.metadata]
|
||||
allow-direct-references = true
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["."]
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ['py310']
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
line_length = 100
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.10"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "C", "I"]
|
||||
ignore = ["E501"]
|
||||
@@ -0,0 +1,14 @@
|
||||
# Core dependencies
|
||||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.20.0
|
||||
assistant-stream>=0.0.28
|
||||
pydantic>=2.0.0
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Development dependencies
|
||||
pytest>=7.0.0
|
||||
pytest-asyncio>=0.21.0
|
||||
black>=23.0.0
|
||||
isort>=5.12.0
|
||||
mypy>=1.0.0
|
||||
ruff>=0.1.0
|
||||
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Setup script for Assistant Transport Backend.
|
||||
|
||||
This script helps users get started by:
|
||||
1. Checking Python version
|
||||
2. Creating a virtual environment (optional)
|
||||
3. Installing dependencies
|
||||
4. Setting up environment variables
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def check_python_version():
|
||||
"""Check if Python version is compatible."""
|
||||
version = sys.version_info
|
||||
if version.major < 3 or (version.major == 3 and version.minor < 9):
|
||||
print("❌ Python 3.9+ is required")
|
||||
sys.exit(1)
|
||||
print(f"✅ Python {version.major}.{version.minor}.{version.micro} detected")
|
||||
|
||||
|
||||
def run_command(command, description):
|
||||
"""Run a shell command and handle errors."""
|
||||
print(f"🔄 {description}...")
|
||||
try:
|
||||
result = subprocess.run(command, shell=True, check=True, capture_output=True, text=True)
|
||||
print(f"✅ {description} completed")
|
||||
return result.stdout
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"❌ {description} failed: {e}")
|
||||
print(f"Error output: {e.stderr}")
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
"""Main setup function."""
|
||||
print("🚀 Assistant Transport Backend Setup Script")
|
||||
print("=" * 40)
|
||||
|
||||
# Check Python version
|
||||
check_python_version()
|
||||
|
||||
# Check if we're in the right directory
|
||||
if not Path("pyproject.toml").exists():
|
||||
print("❌ Please run this script from the assistant-transport-backend directory")
|
||||
sys.exit(1)
|
||||
|
||||
# Ask user about virtual environment
|
||||
create_venv = input("\n💡 Create a virtual environment? (y/N): ").lower().strip()
|
||||
if create_venv in ['y', 'yes']:
|
||||
venv_name = input("Virtual environment name (default: venv): ").strip() or "venv"
|
||||
|
||||
if not Path(venv_name).exists():
|
||||
run_command(f"python -m venv {venv_name}", f"Creating virtual environment '{venv_name}'")
|
||||
|
||||
# Provide activation instructions
|
||||
if os.name == 'nt': # Windows
|
||||
activate_cmd = f"{venv_name}\\Scripts\\activate"
|
||||
else: # Unix/Linux/macOS
|
||||
activate_cmd = f"source {venv_name}/bin/activate"
|
||||
|
||||
print(f"\n💡 To activate the virtual environment, run:")
|
||||
print(f" {activate_cmd}")
|
||||
print("💡 Then re-run this setup script.")
|
||||
return
|
||||
|
||||
# Install dependencies
|
||||
print("\n📦 Installing dependencies...")
|
||||
if Path("pyproject.toml").exists():
|
||||
# Try pip install with pyproject.toml (editable install)
|
||||
result = run_command("pip install -e .", "Installing package in editable mode")
|
||||
if not result:
|
||||
# Fallback to requirements.txt
|
||||
run_command("pip install -r requirements.txt", "Installing from requirements.txt")
|
||||
else:
|
||||
run_command("pip install -r requirements.txt", "Installing from requirements.txt")
|
||||
|
||||
# Set up environment variables
|
||||
print("\n🔧 Setting up environment variables...")
|
||||
env_file = Path(".env")
|
||||
example_env = Path(".env.example")
|
||||
|
||||
if not env_file.exists() and example_env.exists():
|
||||
# Copy example env file
|
||||
with open(example_env, 'r') as f:
|
||||
example_content = f.read()
|
||||
|
||||
with open(env_file, 'w') as f:
|
||||
f.write(example_content)
|
||||
|
||||
print("✅ Created .env file from .env.example")
|
||||
print("💡 Please edit .env file to add your API keys:")
|
||||
print(" - OPENAI_API_KEY (for OpenAI GPT models)")
|
||||
print(" - ANTHROPIC_API_KEY (for Anthropic Claude models)")
|
||||
elif env_file.exists():
|
||||
print("✅ .env file already exists")
|
||||
|
||||
# Final instructions
|
||||
print("\n🎉 Setup complete!")
|
||||
print("\n🚀 To start the server:")
|
||||
print(" python main.py")
|
||||
print("\n Or:")
|
||||
print(" uvicorn main:app --reload --host 0.0.0.0 --port 8000")
|
||||
print("\n📖 The server will be available at http://localhost:8000")
|
||||
print("🌐 API endpoint will be at http://localhost:8000/assistant")
|
||||
|
||||
# Test server start
|
||||
test_server = input("\n🧪 Test server startup? (y/N): ").lower().strip()
|
||||
if test_server in ['y', 'yes']:
|
||||
print("\n🧪 Testing server startup (will stop after 5 seconds)...")
|
||||
try:
|
||||
import signal
|
||||
import time
|
||||
from multiprocessing import Process
|
||||
|
||||
# Import and start server
|
||||
from main import app
|
||||
import uvicorn
|
||||
|
||||
def run_test_server():
|
||||
uvicorn.run(app, host="127.0.0.1", port=8000, log_level="warning")
|
||||
|
||||
process = Process(target=run_test_server)
|
||||
process.start()
|
||||
time.sleep(2)
|
||||
|
||||
# Test if server responds
|
||||
try:
|
||||
import httpx
|
||||
response = httpx.get("http://127.0.0.1:8000/health", timeout=3.0)
|
||||
if response.status_code == 200:
|
||||
print("✅ Server test successful!")
|
||||
else:
|
||||
print(f"⚠️ Server responded with status {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not test server: {e}")
|
||||
|
||||
process.terminate()
|
||||
process.join()
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Server test failed: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+1429
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
|
||||
# uv
|
||||
.venv/
|
||||
uv.lock
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
coverage.xml
|
||||
*.cover
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
@@ -0,0 +1 @@
|
||||
3.12.6
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 AgentbaseAI Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,224 @@
|
||||
# assistant-ui Sync Server API
|
||||
|
||||
A Python client library for interacting with assistant-ui sync server backends, providing the same API structure as the JavaScript/TypeScript `useChatRuntime`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install assistant-ui-sync-server-api
|
||||
```
|
||||
|
||||
Or using uv:
|
||||
|
||||
```bash
|
||||
uv add assistant-ui-sync-server-api
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Example
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from assistant_ui import AssistantClient
|
||||
|
||||
# Create a client
|
||||
client = AssistantClient(base_url="https://api.example.com")
|
||||
|
||||
# Create messages
|
||||
messages = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Hello, how are you?"}]
|
||||
}
|
||||
]
|
||||
|
||||
# Send a chat request to a specific thread
|
||||
async def main():
|
||||
thread = client.threads("thread-123")
|
||||
response = await thread.chat(
|
||||
messages=messages,
|
||||
system="You are a helpful assistant."
|
||||
)
|
||||
print(response.json())
|
||||
await client.close()
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
### Using Context Managers
|
||||
|
||||
```python
|
||||
# Async context manager
|
||||
async with AssistantClient(base_url="https://api.example.com") as client:
|
||||
thread = client.threads("thread-123")
|
||||
response = await thread.chat(messages=messages)
|
||||
|
||||
# Sync context manager
|
||||
with AssistantClient(base_url="https://api.example.com") as client:
|
||||
thread = client.threads("thread-123")
|
||||
response = thread.chat_sync(messages=messages)
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
```python
|
||||
# Static headers
|
||||
client = AssistantClient(
|
||||
base_url="https://api.example.com",
|
||||
headers={"Authorization": "Bearer your-token"}
|
||||
)
|
||||
|
||||
# Dynamic headers (async)
|
||||
async def get_auth_headers():
|
||||
token = await fetch_token()
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
client = AssistantClient(
|
||||
base_url="https://api.example.com",
|
||||
headers=get_auth_headers
|
||||
)
|
||||
```
|
||||
|
||||
### Using Tools
|
||||
|
||||
```python
|
||||
tools = {
|
||||
"get_weather": {
|
||||
"description": "Get the current weather for a location",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {"type": "string"},
|
||||
"unit": {"type": "string", "enum": ["celsius", "fahrenheit"]}
|
||||
},
|
||||
"required": ["location"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread = client.threads("thread-123")
|
||||
response = await thread.chat(
|
||||
messages=messages,
|
||||
tools=tools
|
||||
)
|
||||
```
|
||||
|
||||
### Canceling Operations
|
||||
|
||||
```python
|
||||
thread = client.threads("thread-123")
|
||||
|
||||
# Start a long-running chat
|
||||
chat_task = asyncio.create_task(thread.chat(messages=messages))
|
||||
|
||||
# Cancel it
|
||||
await thread.cancel()
|
||||
chat_task.cancel()
|
||||
```
|
||||
|
||||
### Message Types
|
||||
|
||||
The package supports various message types matching the assistant-ui format:
|
||||
|
||||
```python
|
||||
from assistant_ui.types import Message
|
||||
|
||||
# Text message
|
||||
text_message: Message = {
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Hello!"}]
|
||||
}
|
||||
|
||||
# Image message
|
||||
image_message: Message = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "What's in this image?"},
|
||||
{"type": "image", "image": "https://example.com/image.jpg"}
|
||||
]
|
||||
}
|
||||
|
||||
# File message
|
||||
file_message: Message = {
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "file", "data": "https://example.com/file.pdf", "mimeType": "application/pdf"}
|
||||
]
|
||||
}
|
||||
|
||||
# Assistant message with tool calls
|
||||
assistant_message: Message = {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "I'll help you with that."},
|
||||
{
|
||||
"type": "tool-call",
|
||||
"toolCallId": "call-123",
|
||||
"toolName": "get_weather",
|
||||
"args": {"location": "San Francisco"}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Tool result message
|
||||
tool_message: Message = {
|
||||
"role": "tool",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool-result",
|
||||
"toolCallId": "call-123",
|
||||
"toolName": "get_weather",
|
||||
"result": {"temperature": 72, "condition": "sunny"},
|
||||
"isError": False
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AssistantClient`
|
||||
|
||||
Main client for interacting with assistant-ui backends.
|
||||
|
||||
**Constructor:**
|
||||
|
||||
```python
|
||||
AssistantClient(
|
||||
base_url: str,
|
||||
headers: Optional[Dict[str, str] | Callable] = None,
|
||||
timeout: Optional[float] = None,
|
||||
**kwargs
|
||||
)
|
||||
```
|
||||
|
||||
**Methods:**
|
||||
|
||||
- `threads(thread_id: str) -> ThreadClient`: Get a ThreadClient for a specific thread
|
||||
- `close()`: Close the async client
|
||||
- `close_sync()`: Close the sync client
|
||||
|
||||
### `ThreadClient`
|
||||
|
||||
Client for interacting with a specific thread.
|
||||
|
||||
**Methods:**
|
||||
|
||||
- `chat(messages, system=None, tools=None, **kwargs)`: Send an async chat request
|
||||
- `chat_sync(messages, system=None, tools=None, **kwargs)`: Send a sync chat request
|
||||
- `cancel()`: Cancel the current async operation
|
||||
- `cancel_sync()`: Cancel the current sync operation
|
||||
|
||||
## Type Definitions
|
||||
|
||||
The package includes TypedDict definitions for all message types and configuration options, providing full type hints for better IDE support and type checking.
|
||||
|
||||
## Development
|
||||
|
||||
This package is part of the assistant-ui monorepo. To contribute:
|
||||
|
||||
1. Clone the main repository
|
||||
2. Navigate to `python/assistant-ui`
|
||||
3. Install dependencies with `uv sync`
|
||||
4. Run tests with `uv run pytest`
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic example of using the assistant-ui Sync Server API client.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from assistant_ui import AssistantClient
|
||||
from assistant_ui.types import Message, Tool
|
||||
|
||||
|
||||
async def basic_chat_example():
|
||||
"""Simple async chat example."""
|
||||
print("=== Basic Async Chat Example ===")
|
||||
|
||||
# Create client
|
||||
client = AssistantClient(
|
||||
base_url="http://localhost:3000",
|
||||
headers={"Authorization": "Bearer your-api-key"}
|
||||
)
|
||||
|
||||
messages: list[Message] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "What is the capital of France?"}],
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
# Get thread client and send chat
|
||||
thread = client.threads("thread-123")
|
||||
response = await thread.chat(
|
||||
messages=messages,
|
||||
system="You are a helpful assistant that answers questions concisely."
|
||||
)
|
||||
print(f"Response status: {response.status_code}")
|
||||
print(f"Response body: {response.json()}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
def sync_chat_example():
|
||||
"""Simple synchronous chat example."""
|
||||
print("\n=== Sync Chat Example ===")
|
||||
|
||||
# Create client using context manager
|
||||
with AssistantClient(
|
||||
base_url="http://localhost:3000",
|
||||
headers={"Authorization": "Bearer your-api-key"}
|
||||
) as client:
|
||||
messages: list[Message] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Tell me a short joke."}],
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
# Get thread client and send chat
|
||||
thread = client.threads("thread-456")
|
||||
response = thread.chat_sync(
|
||||
messages=messages,
|
||||
system="You are a helpful and funny assistant."
|
||||
)
|
||||
print(f"Response status: {response.status_code}")
|
||||
print(f"Response body: {response.json()}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
|
||||
async def chat_with_tools_example():
|
||||
"""Example using tools."""
|
||||
print("\n=== Chat with Tools Example ===")
|
||||
|
||||
# Using async context manager
|
||||
async with AssistantClient(base_url="http://localhost:3000") as client:
|
||||
tools: dict[str, Tool] = {
|
||||
"get_weather": {
|
||||
"description": "Get the current weather for a location",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "The city and state, e.g., San Francisco, CA"
|
||||
},
|
||||
"unit": {
|
||||
"type": "string",
|
||||
"enum": ["celsius", "fahrenheit"],
|
||||
"description": "The temperature unit"
|
||||
}
|
||||
},
|
||||
"required": ["location"],
|
||||
}
|
||||
},
|
||||
"calculate": {
|
||||
"description": "Perform mathematical calculations",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"description": "The mathematical expression to evaluate"
|
||||
}
|
||||
},
|
||||
"required": ["expression"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages: list[Message] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "What's the weather in New York?"}],
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
thread = client.threads("thread-789")
|
||||
response = await thread.chat(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
system="You are a helpful weather assistant."
|
||||
)
|
||||
print(f"Response status: {response.status_code}")
|
||||
print(f"Response body: {response.json()}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
|
||||
async def cancel_example():
|
||||
"""Example of canceling a thread operation."""
|
||||
print("\n=== Cancel Example ===")
|
||||
|
||||
async with AssistantClient(base_url="http://localhost:3000") as client:
|
||||
thread = client.threads("thread-cancel-demo")
|
||||
|
||||
try:
|
||||
# Start a chat that might take a long time
|
||||
messages: list[Message] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Tell me a very long story..."}],
|
||||
}
|
||||
]
|
||||
|
||||
# Start chat in background
|
||||
chat_task = asyncio.create_task(thread.chat(messages=messages))
|
||||
|
||||
# Wait a bit then cancel
|
||||
await asyncio.sleep(0.5)
|
||||
print("Canceling operation...")
|
||||
cancel_response = await thread.cancel()
|
||||
print(f"Cancel response: {cancel_response.status_code}")
|
||||
|
||||
# Cancel the chat task
|
||||
chat_task.cancel()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
print("Chat was cancelled")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
|
||||
async def multimodal_example():
|
||||
"""Example with images and files."""
|
||||
print("\n=== Multimodal Example ===")
|
||||
|
||||
# Example with async header function
|
||||
async def get_auth_headers():
|
||||
# Simulate fetching auth token
|
||||
await asyncio.sleep(0.1)
|
||||
return {"Authorization": "Bearer dynamic-token"}
|
||||
|
||||
client = AssistantClient(
|
||||
base_url="http://localhost:3000",
|
||||
headers=get_auth_headers
|
||||
)
|
||||
|
||||
messages: list[Message] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Can you analyze this image and document?"},
|
||||
{"type": "image", "image": "https://example.com/chart.png"},
|
||||
{"type": "file", "data": "https://example.com/report.pdf", "mimeType": "application/pdf"}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
thread = client.threads("thread-multimodal")
|
||||
response = await thread.chat(
|
||||
messages=messages,
|
||||
system="You are an expert at analyzing visual and document content."
|
||||
)
|
||||
print(f"Response: {response.json()}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run all examples."""
|
||||
await basic_chat_example()
|
||||
sync_chat_example()
|
||||
await chat_with_tools_example()
|
||||
await cancel_example()
|
||||
await multimodal_example()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,51 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "assistant-ui-sync-server-api"
|
||||
version = "0.1.1"
|
||||
description = "Python client for assistant-ui sync server API"
|
||||
authors = [
|
||||
{ name="assistant-ui Team", email="team@assistant-ui.com" },
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
]
|
||||
dependencies = [
|
||||
"httpx>=0.24.0",
|
||||
"typing-extensions>=4.0.0 ; python_full_version < '3.11'",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"black>=23.0.0",
|
||||
"isort>=5.0.0",
|
||||
"mypy>=1.0.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/assistant-ui/assistant-ui"
|
||||
Issues = "https://github.com/assistant-ui/assistant-ui/issues"
|
||||
Documentation = "https://docs.assistant-ui.com"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/assistant_ui"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"black>=24.8.0",
|
||||
"isort>=5.13.2",
|
||||
"mypy>=1.14.1",
|
||||
"pytest>=8.3.5",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
]
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Deploying assistant-ui-sync-server-api to PyPI"
|
||||
|
||||
# Clean previous builds
|
||||
echo "🧹 Cleaning previous builds..."
|
||||
rm -rf dist/ build/ *.egg-info src/*.egg-info
|
||||
|
||||
# Build the package
|
||||
echo "📦 Building package..."
|
||||
python -m pip install --upgrade build twine
|
||||
python -m build
|
||||
|
||||
# Check the package
|
||||
echo "🔍 Checking package..."
|
||||
twine check dist/*
|
||||
|
||||
# Upload to PyPI
|
||||
echo "📤 Uploading to PyPI..."
|
||||
echo "Note: You'll need to authenticate with your PyPI credentials"
|
||||
twine upload dist/*
|
||||
|
||||
echo "✅ Deployment complete!"
|
||||
@@ -0,0 +1,32 @@
|
||||
from .client import AssistantClient, ThreadClient
|
||||
from .types import (
|
||||
Message,
|
||||
TextPart,
|
||||
ImagePart,
|
||||
FilePart,
|
||||
ToolCallPart,
|
||||
ToolResultPart,
|
||||
Tool,
|
||||
ToolParameters,
|
||||
SystemMessage,
|
||||
UserMessage,
|
||||
AssistantMessage,
|
||||
ToolMessage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AssistantClient",
|
||||
"ThreadClient",
|
||||
"Message",
|
||||
"SystemMessage",
|
||||
"UserMessage",
|
||||
"AssistantMessage",
|
||||
"ToolMessage",
|
||||
"TextPart",
|
||||
"ImagePart",
|
||||
"FilePart",
|
||||
"ToolCallPart",
|
||||
"ToolResultPart",
|
||||
"Tool",
|
||||
"ToolParameters",
|
||||
]
|
||||
@@ -0,0 +1,451 @@
|
||||
from typing import Dict, List, Any, Optional, Union, Callable, Awaitable
|
||||
import warnings
|
||||
import httpx
|
||||
from .types import (
|
||||
Message,
|
||||
Tool,
|
||||
AssistantTransportCommand,
|
||||
AddMessageCommand,
|
||||
AddToolResultCommand
|
||||
)
|
||||
|
||||
|
||||
def _convert_messages_to_commands(messages: List[Message]) -> List[AssistantTransportCommand]:
|
||||
"""
|
||||
Convert legacy messages format to commands format.
|
||||
|
||||
Args:
|
||||
messages: List of messages in the legacy format
|
||||
|
||||
Returns:
|
||||
List of commands in the new format
|
||||
"""
|
||||
commands: List[AssistantTransportCommand] = []
|
||||
|
||||
for message in messages:
|
||||
role = message.get("role")
|
||||
|
||||
if role == "system":
|
||||
# System messages can't be directly converted to commands
|
||||
# They should be passed via the system parameter instead
|
||||
continue
|
||||
|
||||
elif role == "user":
|
||||
content = message.get("content", [])
|
||||
parts = []
|
||||
|
||||
for part in content:
|
||||
part_type = part.get("type")
|
||||
if part_type == "text":
|
||||
parts.append({"type": "text", "text": part.get("text", "")})
|
||||
elif part_type == "image":
|
||||
parts.append({"type": "image", "image": part.get("image", "")})
|
||||
# FilePart cannot be converted to command format
|
||||
|
||||
if parts:
|
||||
command: AddMessageCommand = {
|
||||
"type": "add-message",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": parts
|
||||
}
|
||||
}
|
||||
commands.append(command)
|
||||
|
||||
elif role == "assistant":
|
||||
content = message.get("content", [])
|
||||
text_parts = []
|
||||
|
||||
for part in content:
|
||||
part_type = part.get("type")
|
||||
if part_type == "text":
|
||||
text_parts.append({"type": "text", "text": part.get("text", "")})
|
||||
elif part_type == "tool-call":
|
||||
# Tool calls are handled separately, not in add-message
|
||||
continue
|
||||
|
||||
if text_parts:
|
||||
command: AddMessageCommand = {
|
||||
"type": "add-message",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"parts": text_parts
|
||||
}
|
||||
}
|
||||
commands.append(command)
|
||||
|
||||
elif role == "tool":
|
||||
# Convert tool results to commands
|
||||
content = message.get("content", [])
|
||||
for part in content:
|
||||
if part.get("type") == "tool-result":
|
||||
tool_command: AddToolResultCommand = {
|
||||
"type": "add-tool-result",
|
||||
"toolCallId": part.get("toolCallId", ""),
|
||||
"toolName": part.get("toolName", ""),
|
||||
"result": part.get("result"),
|
||||
"isError": part.get("isError", False),
|
||||
"artifact": None # artifact not available in legacy format
|
||||
}
|
||||
commands.append(tool_command)
|
||||
|
||||
return commands
|
||||
|
||||
|
||||
class ThreadClient:
|
||||
"""Client for interacting with a specific thread."""
|
||||
|
||||
def __init__(self, client: "AssistantClient", thread_id: str):
|
||||
self._client = client
|
||||
self._thread_id = thread_id
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: Optional[List[Message]] = None,
|
||||
commands: Optional[List[AssistantTransportCommand]] = None,
|
||||
system: Optional[str] = None,
|
||||
tools: Optional[Dict[str, Tool]] = None,
|
||||
unstable_assistantMessageId: Optional[str] = None,
|
||||
runConfig: Optional[Dict[str, Any]] = None,
|
||||
state: Optional[Any] = None,
|
||||
**kwargs: Any
|
||||
) -> httpx.Response:
|
||||
"""
|
||||
Send a chat request for this thread.
|
||||
|
||||
Args:
|
||||
messages: (Deprecated) List of messages in the conversation
|
||||
commands: List of commands to execute
|
||||
system: System prompt
|
||||
tools: Dictionary of available tools
|
||||
unstable_assistantMessageId: Optional assistant message ID
|
||||
runConfig: Optional run configuration
|
||||
state: Optional state data
|
||||
**kwargs: Additional parameters to include in the request body
|
||||
|
||||
Returns:
|
||||
httpx.Response object containing the backend response
|
||||
"""
|
||||
# Build request payload
|
||||
payload = {
|
||||
"threadId": self._thread_id,
|
||||
}
|
||||
|
||||
# Handle commands and messages
|
||||
if messages is not None:
|
||||
warnings.warn(
|
||||
"The 'messages' parameter is deprecated and will be removed in a future version. "
|
||||
"Use 'commands' parameter instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
|
||||
# Convert messages to commands
|
||||
converted_commands = _convert_messages_to_commands(messages)
|
||||
|
||||
# If both messages and commands provided, merge them
|
||||
if commands is not None:
|
||||
commands = commands + converted_commands
|
||||
else:
|
||||
commands = converted_commands
|
||||
|
||||
# Still send messages for backward compatibility
|
||||
payload["messages"] = messages
|
||||
|
||||
if commands is not None:
|
||||
payload["commands"] = commands
|
||||
|
||||
if system is not None:
|
||||
payload["system"] = system
|
||||
|
||||
if tools is not None:
|
||||
payload["tools"] = tools
|
||||
|
||||
if unstable_assistantMessageId is not None:
|
||||
payload["unstable_assistantMessageId"] = unstable_assistantMessageId
|
||||
|
||||
if runConfig is not None:
|
||||
payload["runConfig"] = runConfig
|
||||
|
||||
if state is not None:
|
||||
payload["state"] = state
|
||||
|
||||
# Add any additional kwargs
|
||||
payload.update(kwargs)
|
||||
|
||||
# Make the request
|
||||
return await self._client._make_request("POST", "/api/chat", json=payload)
|
||||
|
||||
def chat_sync(
|
||||
self,
|
||||
messages: Optional[List[Message]] = None,
|
||||
commands: Optional[List[AssistantTransportCommand]] = None,
|
||||
system: Optional[str] = None,
|
||||
tools: Optional[Dict[str, Tool]] = None,
|
||||
unstable_assistantMessageId: Optional[str] = None,
|
||||
runConfig: Optional[Dict[str, Any]] = None,
|
||||
state: Optional[Any] = None,
|
||||
**kwargs: Any
|
||||
) -> httpx.Response:
|
||||
"""
|
||||
Synchronous version of chat method.
|
||||
|
||||
Args:
|
||||
messages: (Deprecated) List of messages in the conversation
|
||||
commands: List of commands to execute
|
||||
system: System prompt
|
||||
tools: Dictionary of available tools
|
||||
unstable_assistantMessageId: Optional assistant message ID
|
||||
runConfig: Optional run configuration
|
||||
state: Optional state data
|
||||
**kwargs: Additional parameters to include in the request body
|
||||
|
||||
Returns:
|
||||
httpx.Response object containing the backend response
|
||||
"""
|
||||
# Build request payload
|
||||
payload = {
|
||||
"threadId": self._thread_id,
|
||||
}
|
||||
|
||||
# Handle commands and messages
|
||||
if messages is not None:
|
||||
warnings.warn(
|
||||
"The 'messages' parameter is deprecated and will be removed in a future version. "
|
||||
"Use 'commands' parameter instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
|
||||
# Convert messages to commands
|
||||
converted_commands = _convert_messages_to_commands(messages)
|
||||
|
||||
# If both messages and commands provided, merge them
|
||||
if commands is not None:
|
||||
commands = commands + converted_commands
|
||||
else:
|
||||
commands = converted_commands
|
||||
|
||||
# Still send messages for backward compatibility
|
||||
payload["messages"] = messages
|
||||
|
||||
if commands is not None:
|
||||
payload["commands"] = commands
|
||||
|
||||
if system is not None:
|
||||
payload["system"] = system
|
||||
|
||||
if tools is not None:
|
||||
payload["tools"] = tools
|
||||
|
||||
if unstable_assistantMessageId is not None:
|
||||
payload["unstable_assistantMessageId"] = unstable_assistantMessageId
|
||||
|
||||
if runConfig is not None:
|
||||
payload["runConfig"] = runConfig
|
||||
|
||||
if state is not None:
|
||||
payload["state"] = state
|
||||
|
||||
# Add any additional kwargs
|
||||
payload.update(kwargs)
|
||||
|
||||
# Make the request
|
||||
return self._client._make_request_sync("POST", "/api/chat", json=payload)
|
||||
|
||||
async def cancel(self) -> httpx.Response:
|
||||
"""
|
||||
Cancel the current operation for this thread.
|
||||
|
||||
Returns:
|
||||
httpx.Response object containing the backend response
|
||||
"""
|
||||
return await self._client._make_request(
|
||||
"POST",
|
||||
"/api/cancel",
|
||||
json={"threadId": self._thread_id}
|
||||
)
|
||||
|
||||
def cancel_sync(self) -> httpx.Response:
|
||||
"""
|
||||
Synchronous version of cancel method.
|
||||
|
||||
Returns:
|
||||
httpx.Response object containing the backend response
|
||||
"""
|
||||
return self._client._make_request_sync(
|
||||
"POST",
|
||||
"/api/cancel",
|
||||
json={"threadId": self._thread_id}
|
||||
)
|
||||
|
||||
|
||||
class AssistantClient:
|
||||
"""Main client for interacting with assistant-ui backends."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
headers: Optional[Union[Dict[str, str], Callable[[], Union[Dict[str, str], Awaitable[Dict[str, str]]]]]] = None,
|
||||
timeout: Optional[float] = None,
|
||||
**kwargs: Any
|
||||
):
|
||||
"""
|
||||
Initialize the AssistantClient.
|
||||
|
||||
Args:
|
||||
base_url: Base URL for the API (e.g., "https://api.example.com")
|
||||
headers: Optional headers to include with requests
|
||||
timeout: Optional timeout for requests
|
||||
**kwargs: Additional arguments passed to httpx client
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self._headers = headers
|
||||
self._timeout = timeout
|
||||
self._client_kwargs = kwargs
|
||||
self._async_client: Optional[httpx.AsyncClient] = None
|
||||
self._sync_client: Optional[httpx.Client] = None
|
||||
|
||||
def threads(self, thread_id: str) -> ThreadClient:
|
||||
"""
|
||||
Get a ThreadClient for a specific thread.
|
||||
|
||||
Args:
|
||||
thread_id: The ID of the thread
|
||||
|
||||
Returns:
|
||||
ThreadClient instance for the specified thread
|
||||
"""
|
||||
return ThreadClient(self, thread_id)
|
||||
|
||||
async def _get_headers(self) -> Dict[str, str]:
|
||||
"""Get headers, resolving async functions if needed."""
|
||||
if self._headers is None:
|
||||
return {"Content-Type": "application/json"}
|
||||
|
||||
if callable(self._headers):
|
||||
headers = self._headers()
|
||||
if hasattr(headers, "__await__"):
|
||||
headers = await headers
|
||||
else:
|
||||
headers = self._headers
|
||||
|
||||
if isinstance(headers, dict):
|
||||
headers = dict(headers) # Make a copy
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
else:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
return headers
|
||||
|
||||
def _get_headers_sync(self) -> Dict[str, str]:
|
||||
"""Get headers synchronously."""
|
||||
if self._headers is None:
|
||||
return {"Content-Type": "application/json"}
|
||||
|
||||
if callable(self._headers):
|
||||
headers = self._headers()
|
||||
if hasattr(headers, "__await__"):
|
||||
raise ValueError("Synchronous methods do not support async header functions")
|
||||
else:
|
||||
headers = self._headers
|
||||
|
||||
if isinstance(headers, dict):
|
||||
headers = dict(headers) # Make a copy
|
||||
headers.setdefault("Content-Type", "application/json")
|
||||
else:
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
return headers
|
||||
|
||||
async def _ensure_async_client(self) -> httpx.AsyncClient:
|
||||
"""Ensure async client is initialized."""
|
||||
if self._async_client is None:
|
||||
self._async_client = httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
timeout=self._timeout,
|
||||
**self._client_kwargs
|
||||
)
|
||||
return self._async_client
|
||||
|
||||
def _ensure_sync_client(self) -> httpx.Client:
|
||||
"""Ensure sync client is initialized."""
|
||||
if self._sync_client is None:
|
||||
self._sync_client = httpx.Client(
|
||||
base_url=self.base_url,
|
||||
timeout=self._timeout,
|
||||
**self._client_kwargs
|
||||
)
|
||||
return self._sync_client
|
||||
|
||||
async def _make_request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
**kwargs: Any
|
||||
) -> httpx.Response:
|
||||
"""Make an async HTTP request."""
|
||||
client = await self._ensure_async_client()
|
||||
headers = await self._get_headers()
|
||||
|
||||
# Merge headers
|
||||
if "headers" in kwargs:
|
||||
headers.update(kwargs["headers"])
|
||||
kwargs["headers"] = headers
|
||||
|
||||
response = await client.request(method, path, **kwargs)
|
||||
|
||||
if not response.is_success:
|
||||
raise Exception(f"Request failed with status {response.status_code}: {response.text}")
|
||||
|
||||
return response
|
||||
|
||||
def _make_request_sync(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
**kwargs: Any
|
||||
) -> httpx.Response:
|
||||
"""Make a synchronous HTTP request."""
|
||||
client = self._ensure_sync_client()
|
||||
headers = self._get_headers_sync()
|
||||
|
||||
# Merge headers
|
||||
if "headers" in kwargs:
|
||||
headers.update(kwargs["headers"])
|
||||
kwargs["headers"] = headers
|
||||
|
||||
response = client.request(method, path, **kwargs)
|
||||
|
||||
if not response.is_success:
|
||||
raise Exception(f"Request failed with status {response.status_code}: {response.text}")
|
||||
|
||||
return response
|
||||
|
||||
async def close(self):
|
||||
"""Close the async client if it's open."""
|
||||
if self._async_client:
|
||||
await self._async_client.aclose()
|
||||
self._async_client = None
|
||||
|
||||
def close_sync(self):
|
||||
"""Close the sync client if it's open."""
|
||||
if self._sync_client:
|
||||
self._sync_client.close()
|
||||
self._sync_client = None
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit."""
|
||||
await self.close()
|
||||
|
||||
def __enter__(self):
|
||||
"""Sync context manager entry."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Sync context manager exit."""
|
||||
self.close_sync()
|
||||
@@ -0,0 +1,101 @@
|
||||
from typing import TypedDict, Literal, Union, Dict, Any, List, Optional
|
||||
|
||||
|
||||
class TextPart(TypedDict):
|
||||
type: Literal["text"]
|
||||
text: str
|
||||
|
||||
|
||||
class ImagePart(TypedDict):
|
||||
type: Literal["image"]
|
||||
image: str
|
||||
|
||||
|
||||
class FilePart(TypedDict):
|
||||
type: Literal["file"]
|
||||
data: str
|
||||
mimeType: str
|
||||
|
||||
|
||||
class ToolCallPart(TypedDict):
|
||||
type: Literal["tool-call"]
|
||||
toolCallId: str
|
||||
toolName: str
|
||||
args: Dict[str, Any]
|
||||
|
||||
|
||||
class ToolResultPart(TypedDict):
|
||||
type: Literal["tool-result"]
|
||||
toolCallId: str
|
||||
toolName: str
|
||||
result: Any
|
||||
isError: Optional[bool]
|
||||
|
||||
|
||||
ContentPart = Union[TextPart, ImagePart, FilePart, ToolCallPart, ToolResultPart]
|
||||
|
||||
|
||||
class SystemMessage(TypedDict):
|
||||
role: Literal["system"]
|
||||
content: str
|
||||
unstable_id: Optional[str]
|
||||
|
||||
|
||||
class UserMessage(TypedDict):
|
||||
role: Literal["user"]
|
||||
content: List[Union[TextPart, ImagePart, FilePart]]
|
||||
unstable_id: Optional[str]
|
||||
|
||||
|
||||
class AssistantMessage(TypedDict):
|
||||
role: Literal["assistant"]
|
||||
content: List[Union[TextPart, ToolCallPart]]
|
||||
unstable_id: Optional[str]
|
||||
|
||||
|
||||
class ToolMessage(TypedDict):
|
||||
role: Literal["tool"]
|
||||
content: List[ToolResultPart]
|
||||
|
||||
|
||||
Message = Union[SystemMessage, UserMessage, AssistantMessage, ToolMessage]
|
||||
|
||||
|
||||
# Command types for AssistantTransportCommand
|
||||
class UserMessageCommand(TypedDict):
|
||||
role: Literal["user"]
|
||||
parts: List[Union[TextPart, ImagePart]]
|
||||
|
||||
|
||||
class AssistantMessageCommand(TypedDict):
|
||||
role: Literal["assistant"]
|
||||
parts: List[TextPart]
|
||||
|
||||
|
||||
class AddMessageCommand(TypedDict):
|
||||
type: Literal["add-message"]
|
||||
message: Union[UserMessageCommand, AssistantMessageCommand]
|
||||
|
||||
|
||||
class AddToolResultCommand(TypedDict):
|
||||
type: Literal["add-tool-result"]
|
||||
toolCallId: str
|
||||
toolName: str
|
||||
result: Any
|
||||
isError: bool
|
||||
artifact: Optional[Any]
|
||||
|
||||
|
||||
AssistantTransportCommand = Union[AddMessageCommand, AddToolResultCommand]
|
||||
|
||||
|
||||
class ToolParameters(TypedDict):
|
||||
type: Literal["object"]
|
||||
properties: Dict[str, Any]
|
||||
required: Optional[List[str]]
|
||||
additionalProperties: Optional[bool]
|
||||
|
||||
|
||||
class Tool(TypedDict, total=False):
|
||||
description: str
|
||||
parameters: ToolParameters
|
||||
@@ -0,0 +1 @@
|
||||
# Tests for assistant-ui package
|
||||
@@ -0,0 +1,510 @@
|
||||
"""Tests for the assistant-ui-sync-server-api client."""
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
import warnings
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from assistant_ui import AssistantClient
|
||||
from assistant_ui.types import Message, AssistantTransportCommand
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_chat():
|
||||
"""Test basic async chat functionality."""
|
||||
client = AssistantClient(
|
||||
base_url="https://api.example.com",
|
||||
headers={"Authorization": "Bearer test"}
|
||||
)
|
||||
|
||||
messages: list[Message] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Hello"}],
|
||||
}
|
||||
]
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"response": "Hi there!"}
|
||||
mock_response.text = "Success"
|
||||
|
||||
with patch.object(client, "_ensure_async_client") as mock_ensure:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request = AsyncMock(return_value=mock_response)
|
||||
mock_ensure.return_value = mock_client
|
||||
|
||||
thread = client.threads("thread-123")
|
||||
|
||||
# Suppress deprecation warning for this test
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
response = await thread.chat(
|
||||
messages=messages,
|
||||
system="You are helpful"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check the request was made correctly
|
||||
call_args = mock_client.request.call_args
|
||||
payload = call_args[1]["json"]
|
||||
|
||||
assert payload["threadId"] == "thread-123"
|
||||
assert payload["messages"] == messages
|
||||
assert payload["system"] == "You are helpful"
|
||||
# Commands should also be present due to conversion
|
||||
assert "commands" in payload
|
||||
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_tools():
|
||||
"""Test chat with tools."""
|
||||
async with AssistantClient(base_url="https://api.example.com") as client:
|
||||
messages: list[Message] = []
|
||||
tools = {"search": {"description": "Search the web"}}
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(client, "_ensure_async_client") as mock_ensure:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request = AsyncMock(return_value=mock_response)
|
||||
mock_ensure.return_value = mock_client
|
||||
|
||||
thread = client.threads("thread-456")
|
||||
await thread.chat(messages=messages, tools=tools)
|
||||
|
||||
# Check tools were included
|
||||
call_args = mock_client.request.call_args
|
||||
assert call_args[1]["json"]["tools"] == tools
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel():
|
||||
"""Test cancel functionality."""
|
||||
client = AssistantClient(base_url="https://api.example.com")
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(client, "_ensure_async_client") as mock_ensure:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request = AsyncMock(return_value=mock_response)
|
||||
mock_ensure.return_value = mock_client
|
||||
|
||||
thread = client.threads("thread-789")
|
||||
await thread.cancel()
|
||||
|
||||
# Check cancel request was made
|
||||
mock_client.request.assert_called_once_with(
|
||||
"POST",
|
||||
"/api/cancel",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={"threadId": "thread-789"}
|
||||
)
|
||||
|
||||
await client.close()
|
||||
|
||||
|
||||
def test_sync_chat():
|
||||
"""Test synchronous chat."""
|
||||
with AssistantClient(base_url="https://api.example.com") as client:
|
||||
messages: list[Message] = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "I can help."},
|
||||
{
|
||||
"type": "tool-call",
|
||||
"toolCallId": "call-123",
|
||||
"toolName": "search",
|
||||
"args": {"query": "weather"}
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
mock_response.text = "Success"
|
||||
|
||||
with patch.object(client, "_ensure_sync_client") as mock_ensure:
|
||||
mock_client = Mock()
|
||||
mock_client.request = Mock(return_value=mock_response)
|
||||
mock_ensure.return_value = mock_client
|
||||
|
||||
thread = client.threads("thread-sync")
|
||||
response = thread.chat_sync(messages=messages)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_headers():
|
||||
"""Test with async header function."""
|
||||
async def get_headers():
|
||||
return {"Authorization": "Bearer dynamic-token"}
|
||||
|
||||
client = AssistantClient(
|
||||
base_url="https://api.example.com",
|
||||
headers=get_headers
|
||||
)
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(client, "_ensure_async_client") as mock_ensure:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request = AsyncMock(return_value=mock_response)
|
||||
mock_ensure.return_value = mock_client
|
||||
|
||||
thread = client.threads("thread-async-headers")
|
||||
await thread.chat(messages=[])
|
||||
|
||||
# Check dynamic headers were used
|
||||
call_args = mock_client.request.call_args
|
||||
assert call_args[1]["headers"]["Authorization"] == "Bearer dynamic-token"
|
||||
|
||||
await client.close()
|
||||
|
||||
|
||||
def test_sync_with_async_headers_raises():
|
||||
"""Test that sync methods raise error with async headers."""
|
||||
async def get_headers():
|
||||
return {"Authorization": "Bearer token"}
|
||||
|
||||
client = AssistantClient(
|
||||
base_url="https://api.example.com",
|
||||
headers=get_headers
|
||||
)
|
||||
|
||||
thread = client.threads("thread-123")
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
thread.chat_sync(messages=[])
|
||||
|
||||
assert "async header functions" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_handling():
|
||||
"""Test error handling."""
|
||||
client = AssistantClient(base_url="https://api.example.com")
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.is_success = False
|
||||
mock_response.status_code = 500
|
||||
mock_response.text = "Internal Server Error"
|
||||
|
||||
with patch.object(client, "_ensure_async_client") as mock_ensure:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request = AsyncMock(return_value=mock_response)
|
||||
mock_ensure.return_value = mock_client
|
||||
|
||||
thread = client.threads("thread-error")
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await thread.chat(messages=[])
|
||||
|
||||
assert "Request failed with status 500" in str(exc_info.value)
|
||||
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_manager():
|
||||
"""Test async context manager."""
|
||||
async with AssistantClient(base_url="https://api.example.com") as client:
|
||||
assert client._async_client is None # Not created until first use
|
||||
|
||||
# Force client creation
|
||||
mock_response = AsyncMock()
|
||||
mock_response.is_success = True
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_async_client_class:
|
||||
mock_instance = AsyncMock()
|
||||
mock_instance.request = AsyncMock(return_value=mock_response)
|
||||
mock_async_client_class.return_value = mock_instance
|
||||
|
||||
thread = client.threads("test")
|
||||
await thread.chat(messages=[])
|
||||
|
||||
assert client._async_client is not None
|
||||
|
||||
# After context exit, client should be closed
|
||||
assert client._async_client is None
|
||||
|
||||
|
||||
def test_sync_context_manager():
|
||||
"""Test sync context manager."""
|
||||
with AssistantClient(base_url="https://api.example.com") as client:
|
||||
assert client._sync_client is None # Not created until first use
|
||||
|
||||
# Force client creation
|
||||
mock_response = Mock()
|
||||
mock_response.is_success = True
|
||||
|
||||
with patch("httpx.Client") as mock_client_class:
|
||||
mock_instance = Mock()
|
||||
mock_instance.request = Mock(return_value=mock_response)
|
||||
mock_client_class.return_value = mock_instance
|
||||
|
||||
thread = client.threads("test")
|
||||
thread.chat_sync(messages=[])
|
||||
|
||||
assert client._sync_client is not None
|
||||
|
||||
# After context exit, client should be closed
|
||||
assert client._sync_client is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_parameters():
|
||||
"""Test chat with all possible parameters."""
|
||||
client = AssistantClient(
|
||||
base_url="https://api.example.com",
|
||||
headers={"X-API-Key": "test-key"},
|
||||
timeout=30.0
|
||||
)
|
||||
|
||||
messages: list[Message] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Analyze this:"},
|
||||
{"type": "image", "image": "https://example.com/img.jpg"},
|
||||
{"type": "file", "data": "https://example.com/doc.pdf", "mimeType": "application/pdf"}
|
||||
],
|
||||
"unstable_id": "msg-123",
|
||||
}
|
||||
]
|
||||
|
||||
tools = {
|
||||
"analyze": {
|
||||
"description": "Analyze content",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"content": {"type": "string"}},
|
||||
"required": ["content"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(client, "_ensure_async_client") as mock_ensure:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request = AsyncMock(return_value=mock_response)
|
||||
mock_ensure.return_value = mock_client
|
||||
|
||||
thread = client.threads("thread-all-params")
|
||||
await thread.chat(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
system="You are an analyzer",
|
||||
unstable_assistantMessageId="asst-456",
|
||||
runConfig={"model": "gpt-5.4-nano"},
|
||||
state={"session": "abc"},
|
||||
custom_field="custom_value"
|
||||
)
|
||||
|
||||
# Check all parameters were included
|
||||
call_args = mock_client.request.call_args
|
||||
payload = call_args[1]["json"]
|
||||
|
||||
assert payload["threadId"] == "thread-all-params"
|
||||
assert payload["system"] == "You are an analyzer"
|
||||
assert payload["messages"] == messages
|
||||
assert payload["tools"] == tools
|
||||
assert payload["unstable_assistantMessageId"] == "asst-456"
|
||||
assert payload["runConfig"] == {"model": "gpt-5.4-nano"}
|
||||
assert payload["state"] == {"session": "abc"}
|
||||
assert payload["custom_field"] == "custom_value"
|
||||
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_commands():
|
||||
"""Test chat with commands instead of messages."""
|
||||
client = AssistantClient(
|
||||
base_url="https://api.example.com",
|
||||
headers={"Authorization": "Bearer test"}
|
||||
)
|
||||
|
||||
commands: list[AssistantTransportCommand] = [
|
||||
{
|
||||
"type": "add-message",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [{"type": "text", "text": "Hello with commands"}]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "add-tool-result",
|
||||
"toolCallId": "call-789",
|
||||
"toolName": "search",
|
||||
"result": {"data": "search results"},
|
||||
"isError": False,
|
||||
"artifact": None
|
||||
}
|
||||
]
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(client, "_ensure_async_client") as mock_ensure:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request = AsyncMock(return_value=mock_response)
|
||||
mock_ensure.return_value = mock_client
|
||||
|
||||
thread = client.threads("thread-cmd")
|
||||
response = await thread.chat(commands=commands)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check the request was made with commands
|
||||
call_args = mock_client.request.call_args
|
||||
payload = call_args[1]["json"]
|
||||
|
||||
assert payload["threadId"] == "thread-cmd"
|
||||
assert payload["commands"] == commands
|
||||
assert "messages" not in payload
|
||||
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_with_deprecated_messages():
|
||||
"""Test that messages parameter shows deprecation warning."""
|
||||
client = AssistantClient(base_url="https://api.example.com")
|
||||
|
||||
messages: list[Message] = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": "Using deprecated API"}],
|
||||
}
|
||||
]
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(client, "_ensure_async_client") as mock_ensure:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request = AsyncMock(return_value=mock_response)
|
||||
mock_ensure.return_value = mock_client
|
||||
|
||||
thread = client.threads("thread-deprecated")
|
||||
|
||||
# Check deprecation warning
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
warnings.simplefilter("always")
|
||||
await thread.chat(messages=messages)
|
||||
|
||||
assert len(w) == 1
|
||||
assert issubclass(w[0].category, DeprecationWarning)
|
||||
assert "messages' parameter is deprecated" in str(w[0].message)
|
||||
|
||||
# Check both messages and commands were sent
|
||||
call_args = mock_client.request.call_args
|
||||
payload = call_args[1]["json"]
|
||||
|
||||
assert payload["messages"] == messages
|
||||
assert "commands" in payload
|
||||
assert len(payload["commands"]) > 0
|
||||
|
||||
await client.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_messages_and_commands_together():
|
||||
"""Test using both messages and commands together."""
|
||||
client = AssistantClient(base_url="https://api.example.com")
|
||||
|
||||
messages: list[Message] = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "Previous message"}],
|
||||
}
|
||||
]
|
||||
|
||||
commands: list[AssistantTransportCommand] = [
|
||||
{
|
||||
"type": "add-message",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [{"type": "text", "text": "New command"}]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(client, "_ensure_async_client") as mock_ensure:
|
||||
mock_client = AsyncMock()
|
||||
mock_client.request = AsyncMock(return_value=mock_response)
|
||||
mock_ensure.return_value = mock_client
|
||||
|
||||
thread = client.threads("thread-both")
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
await thread.chat(messages=messages, commands=commands)
|
||||
|
||||
# Check both were merged
|
||||
call_args = mock_client.request.call_args
|
||||
payload = call_args[1]["json"]
|
||||
|
||||
assert payload["messages"] == messages
|
||||
assert "commands" in payload
|
||||
# Should have original command plus converted message
|
||||
assert len(payload["commands"]) >= 2
|
||||
|
||||
await client.close()
|
||||
|
||||
|
||||
def test_sync_chat_with_commands():
|
||||
"""Test synchronous chat with commands."""
|
||||
with AssistantClient(base_url="https://api.example.com") as client:
|
||||
commands: list[AssistantTransportCommand] = [
|
||||
{
|
||||
"type": "add-message",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"parts": [{"type": "text", "text": "Sync command test"}]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch.object(client, "_ensure_sync_client") as mock_ensure:
|
||||
mock_client = Mock()
|
||||
mock_client.request = Mock(return_value=mock_response)
|
||||
mock_ensure.return_value = mock_client
|
||||
|
||||
thread = client.threads("thread-sync-cmd")
|
||||
response = thread.chat_sync(commands=commands)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Check commands were sent
|
||||
call_args = mock_client.request.call_args
|
||||
payload = call_args[1]["json"]
|
||||
|
||||
assert payload["commands"] == commands
|
||||
@@ -0,0 +1,41 @@
|
||||
# State Management Test
|
||||
|
||||
This is a test project for the `assistant-stream` state management functionality. It demonstrates various state operations and updates over time.
|
||||
|
||||
## Features
|
||||
|
||||
- **Simple State Test**: Basic state updates with primitive values
|
||||
- **Complex Test**: Nested state updates with objects and arrays
|
||||
- **String Operations**: String concatenation and method testing
|
||||
- **List Operations**: List manipulation with append, extend, and other operations
|
||||
- **Dictionary Operations**: Dictionary manipulation with various methods
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install the requirements:
|
||||
|
||||
```
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. Run the server:
|
||||
|
||||
```
|
||||
python server.py
|
||||
```
|
||||
|
||||
3. Open your browser to [http://localhost:8000](http://localhost:8000)
|
||||
|
||||
4. Click on the different test buttons to see state updates in action
|
||||
|
||||
## Implementation Details
|
||||
|
||||
This test server demonstrates the following state management features:
|
||||
|
||||
- Primitive values (strings, numbers, booleans)
|
||||
- Nested state objects
|
||||
- String operations (concatenation, methods)
|
||||
- List operations (append, extend, indexing)
|
||||
- Dictionary operations (get, setdefault, keys/values)
|
||||
|
||||
Each test endpoint updates state over time with various operations to showcase the functionality of the `StateProxy` and `StateManager` classes.
|
||||
@@ -0,0 +1,14 @@
|
||||
[project]
|
||||
name = "state-test"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = [
|
||||
{name = "Simon Farshid",email = "simon.farshid@outlook.com"}
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = []
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.68.0
|
||||
uvicorn>=0.15.0
|
||||
../assistant-stream
|
||||
@@ -0,0 +1,205 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from assistant_stream import create_run, RunController
|
||||
from assistant_stream.serialization import DataStreamResponse
|
||||
|
||||
import asyncio
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.post("/simple-test")
|
||||
async def simple_test():
|
||||
async def run(controller: RunController):
|
||||
# Check that state is initially None
|
||||
assert controller.state is None, "Initial state should be None"
|
||||
|
||||
# Initialize state with a direct assignment
|
||||
controller.state = {
|
||||
"counter": 0,
|
||||
"message": "Starting simple test"
|
||||
}
|
||||
|
||||
# Update state over time
|
||||
for i in range(1, 6):
|
||||
controller.state["counter"] = i
|
||||
controller.state["message"] = f"Counter updated to {i}"
|
||||
|
||||
# Add a boolean and number
|
||||
controller.state["completed"] = True
|
||||
controller.state["total"] = 5.5
|
||||
|
||||
# Demonstrate setting root state again
|
||||
controller.state = {
|
||||
"final": True,
|
||||
"summary": "Test completed successfully"
|
||||
}
|
||||
|
||||
controller.append_text("hi")
|
||||
|
||||
return DataStreamResponse(create_run(run))
|
||||
|
||||
|
||||
@app.post("/complex-test")
|
||||
async def complex_test():
|
||||
async def run(controller: RunController):
|
||||
# Demonstrate that state starts as None
|
||||
print(f"Initial state: {controller.state}")
|
||||
|
||||
# Initialize nested state directly
|
||||
controller.state = {
|
||||
"user": {
|
||||
"name": "John",
|
||||
"preferences": {
|
||||
"theme": "dark",
|
||||
"notifications": True
|
||||
}
|
||||
},
|
||||
"messages": []
|
||||
}
|
||||
|
||||
# Add messages
|
||||
controller.state["messages"].append("Hello")
|
||||
controller.state["messages"].append("World")
|
||||
|
||||
# Set state back to None to demonstrate nullifying
|
||||
controller.state = {}
|
||||
controller.state["user"] = {
|
||||
"name": "Test User",
|
||||
"settings": {"theme": "light", "notifications": True},
|
||||
}
|
||||
controller.state["stats"] = {"visits": 0, "actions": []}
|
||||
|
||||
# Update nested state
|
||||
for i in range(1, 6):
|
||||
await asyncio.sleep(0.01)
|
||||
controller.state["stats"]["visits"] = i
|
||||
controller.state["stats"]["actions"].append(f"action_{i}")
|
||||
|
||||
# Toggle theme every other iteration
|
||||
if i % 2 == 0:
|
||||
controller.state["user"]["settings"]["theme"] = "dark"
|
||||
else:
|
||||
controller.state["user"]["settings"]["theme"] = "light"
|
||||
|
||||
# Final update
|
||||
await asyncio.sleep(0.01)
|
||||
controller.state["completed"] = True
|
||||
|
||||
return DataStreamResponse(create_run(run))
|
||||
|
||||
|
||||
@app.post("/string-test")
|
||||
async def string_test():
|
||||
async def run(controller: RunController):
|
||||
# Initialize string
|
||||
controller.state["message"] = "Hello"
|
||||
|
||||
# Append to string using +=
|
||||
await asyncio.sleep(1)
|
||||
controller.state["message"] += " world"
|
||||
|
||||
# Append more text
|
||||
await asyncio.sleep(1)
|
||||
controller.state["message"] += "!"
|
||||
|
||||
# String methods (these should work through the proxy)
|
||||
await asyncio.sleep(1)
|
||||
controller.state["uppercase"] = controller.state["message"].upper()
|
||||
|
||||
await asyncio.sleep(1)
|
||||
controller.state["contains_world"] = "world" in controller.state["message"]
|
||||
|
||||
await asyncio.sleep(1)
|
||||
controller.state["length"] = len(controller.state["message"])
|
||||
|
||||
return DataStreamResponse(create_run(run))
|
||||
|
||||
|
||||
@app.post("/list-test")
|
||||
async def list_test():
|
||||
async def run(controller: RunController):
|
||||
# Initialize list
|
||||
controller.state["items"] = []
|
||||
|
||||
# Append items one by one
|
||||
for i in range(5):
|
||||
await asyncio.sleep(1)
|
||||
controller.state["items"].append(f"item_{i}")
|
||||
|
||||
# Extend list with multiple items
|
||||
await asyncio.sleep(1)
|
||||
controller.state["items"] += ["batch_1", "batch_2"]
|
||||
|
||||
# Access by index
|
||||
await asyncio.sleep(1)
|
||||
controller.state["first_item"] = controller.state["items"][0]
|
||||
|
||||
# Get length
|
||||
await asyncio.sleep(1)
|
||||
controller.state["count"] = len(controller.state["items"])
|
||||
|
||||
# Check membership
|
||||
await asyncio.sleep(1)
|
||||
controller.state["contains_batch"] = "batch_1" in controller.state["items"]
|
||||
|
||||
# Clear the list
|
||||
await asyncio.sleep(1)
|
||||
controller.state["items"].clear()
|
||||
|
||||
return DataStreamResponse(create_run(run))
|
||||
|
||||
|
||||
@app.post("/dict-test")
|
||||
async def dict_test():
|
||||
async def run(controller: RunController):
|
||||
# Initialize dictionary
|
||||
controller.state["config"] = {}
|
||||
|
||||
# Add items one by one
|
||||
await asyncio.sleep(1)
|
||||
controller.state["config"]["theme"] = "light"
|
||||
|
||||
await asyncio.sleep(1)
|
||||
controller.state["config"]["language"] = "en"
|
||||
|
||||
# Set default value
|
||||
await asyncio.sleep(1)
|
||||
controller.state["config"].setdefault("notifications", True)
|
||||
|
||||
# Try to set default for existing key (should not change)
|
||||
await asyncio.sleep(1)
|
||||
controller.state["config"].setdefault("theme", "dark")
|
||||
controller.state["theme_unchanged"] = (
|
||||
controller.state["config"]["theme"] == "light"
|
||||
)
|
||||
|
||||
# Get with default
|
||||
await asyncio.sleep(1)
|
||||
controller.state["missing_with_default"] = controller.state["config"].get(
|
||||
"missing", "default_value"
|
||||
)
|
||||
|
||||
# Keys, values, items
|
||||
await asyncio.sleep(1)
|
||||
controller.state["all_keys"] = list(controller.state["config"].keys())
|
||||
controller.state["all_values"] = list(controller.state["config"].values())
|
||||
|
||||
# Clear the dictionary
|
||||
await asyncio.sleep(1)
|
||||
controller.state["config"].clear()
|
||||
|
||||
return DataStreamResponse(create_run(run))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.12"
|
||||
|
||||
[[package]]
|
||||
name = "state-test"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
Reference in New Issue
Block a user