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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:40:13 +08:00
commit e30e75b5d4
3893 changed files with 533074 additions and 0 deletions
@@ -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
View File
@@ -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