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,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
|
||||
Reference in New Issue
Block a user