Bulk Message Creation Endpoint (#82)

* fix: increase db pool limit and optimize crud requests

* fix: Sentry tracing and fly concurrency

* feat: Add alembic and indexes

* feat: Batch insert method

* fix: Pydantic Validation

* fix: Added Pydantic based API validation and Associated Test Cases

* chore: Update Changelog

* fix(docs): Update Docs with new API Method and OpenAPI Spec

* chore(ci): Add Environment Variable for Anthropic
This commit is contained in:
Vineeth Voruganti 2024-12-16 01:27:15 -05:00 committed by GitHub
parent 6995f878a9
commit d6194df824
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 4494 additions and 4083 deletions

View File

@ -51,6 +51,7 @@ jobs:
SENTRY_ENABLED: false
OPENTELEMETRY_ENABLED: false
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

View File

@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Alembic for handling database migrations
- Additional indexes for reading Messages and Metamessages
### Changed
- API validation using Pydantic
### Fixed
- Dialectic Streaming Endpoint properly sends text in `StreamingResponse`

View File

@ -0,0 +1,3 @@
---
openapi: post /v1/apps/{app_id}/users/{user_id}/sessions/{session_id}/messages/batch
---

View File

@ -118,6 +118,7 @@
"pages": [
"api-reference/endpoint/messages/get-messages",
"api-reference/endpoint/messages/create-message-for-session",
"api-reference/endpoint/messages/create-batch-messages-for-session",
"api-reference/endpoint/messages/get-message",
"api-reference/endpoint/messages/update-message"
]
@ -160,7 +161,7 @@
"github": "https://github.com/plastic-labs",
"linkedin": "https://www.linkedin.com/company/plasticlabs"
},
"openapi": ["/openapi.yml"],
"openapi": ["/openapi.json"],
"analytics": {
"posthog": {
"apiKey": "phc_1yrzzcgywqXGcerkkI4g7C0YfyPMcAKNOOvGcjTCiUk"

3434
docs/openapi.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
"main": ".pnp.js",
"scripts": {
"dev": "mintlify dev",
"openapi": "npx @mintlify/scraping openapi-file openapi.yml -o api-reference/endpoint",
"openapi": "npx @mintlify/scraping openapi-file openapi.json -o api-reference/endpoint",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",

View File

@ -421,6 +421,40 @@ async def create_message(
return honcho_message
async def create_messages(
db: AsyncSession,
messages: list[schemas.MessageCreate],
app_id: str,
user_id: str,
session_id: str,
) -> list[models.Message]:
"""Bulk create messages for a session while maintaining order"""
# Verify session exists and belongs to user
honcho_session = await get_session(
db, app_id=app_id, session_id=session_id, user_id=user_id
)
if honcho_session is None:
raise ValueError("Session not found or does not belong to user")
# Create list of message records
message_records = [
{
"session_id": session_id,
"is_user": message.is_user,
"content": message.content,
"h_metadata": message.metadata,
}
for message in messages
]
# Bulk insert messages and return them in order
stmt = insert(models.Message).returning(models.Message)
result = await db.execute(stmt, message_records)
await db.commit()
return list(result.scalars().all())
async def get_messages(
db: AsyncSession,
app_id: str,

View File

@ -69,7 +69,7 @@ class User(Base):
)
def __repr__(self) -> str:
return f"User(id={self.id}, app_id={self.app_id}, created_at={self.created_at}, h_metadata={self.h_metadata})"
return f"User(id={self.id}, app_id={self.app_id}, public_id={self.public_id} created_at={self.created_at}, h_metadata={self.h_metadata})"
class Session(Base):

View File

@ -76,11 +76,11 @@ async def create_collection(
db=db,
):
"""Create a new Collection"""
if collection.name == "honcho":
raise HTTPException(
status_code=406,
detail="error invalid collection configuration - honcho is a reserved name",
)
# if collection.name == "honcho":
# raise HTTPException(
# status_code=406,
# detail="error invalid collection configuration - honcho is a reserved name",
# )
try:
return await crud.create_collection(
db, collection=collection, app_id=app_id, user_id=user_id
@ -106,11 +106,6 @@ async def update_collection(
status_code=406,
detail="error invalid collection configuration - atleast 1 field must be provided",
)
if collection.name is not None and collection.name == "honcho":
raise HTTPException(
status_code=406,
detail="error invalid collection configuration - honcho is a reserved name",
)
try:
honcho_collection = await crud.update_collection(
db,

View File

@ -85,8 +85,6 @@ async def query_documents(
try:
top_k = options.top_k
if top_k is not None and top_k > 50:
top_k = 50 # TODO see if we need to paginate this
filter = options.filter
if options.filter == {}:
filter = None

View File

@ -1,8 +1,9 @@
from typing import Optional
from typing import Optional, List
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import paginate
from sqlalchemy.sql import insert
from src import crud, schemas
from src.db import SessionLocal
@ -17,35 +18,83 @@ router = APIRouter(
)
async def enqueue(payload: dict):
async def enqueue(payload: dict | list[dict]):
async with SessionLocal() as db:
# Get Session and Check metadata
session = await crud.get_session(
db,
app_id=payload["app_id"],
user_id=payload["user_id"],
session_id=payload["session_id"],
)
# Check if metadata has a "deriver" key
if session is not None:
deriver_disabled = session.h_metadata.get("deriver_disabled")
if deriver_disabled is not None and deriver_disabled is not False:
print("=====================")
print(f"Deriver is not enabled on session {payload['session_id']}")
print("=====================")
# If deriver is not enabled, do not enqueue
return
else:
# Session doesn't exist return
return
try:
processed_payload = {
k: str(v) if isinstance(v, str) else v for k, v in payload.items()
}
item = QueueItem(payload=processed_payload, session_id=session.id)
db.add(item)
await db.commit()
return
if isinstance(payload, list):
if not payload: # Empty list check
return
print("Payload:\n", payload)
# Check session once since all messages are for same session
session = await crud.get_session(
db,
app_id=payload[0]["app_id"],
user_id=payload[0]["user_id"],
session_id=payload[0]["session_id"],
)
print("Session found:", session is not None)
if session:
print("Session metadata:", session.h_metadata)
if session is None or (
session.h_metadata.get("deriver_disabled") is not None
and session.h_metadata.get("deriver_disabled") is not False
):
print("Skipping enqueue due to session check")
return
# Process all payloads
queue_records = [
{
"payload": {
k: str(v) if isinstance(v, str) else v for k, v in p.items()
},
"session_id": session.id,
}
for p in payload
]
print("Number of queue records to insert:", len(queue_records))
# Use insert to maintain order
stmt = insert(QueueItem).returning(QueueItem)
result = await db.execute(stmt, queue_records)
await db.commit()
print("Queue items inserted successfully")
return
else:
# Original single insert logic
session = await crud.get_session(
db,
app_id=payload["app_id"],
user_id=payload["user_id"],
session_id=payload["session_id"],
)
if session is not None:
deriver_disabled = session.h_metadata.get("deriver_disabled")
if deriver_disabled is not None and deriver_disabled is not False:
print("=====================")
print(
f"Deriver is not enabled on session {payload['session_id']}"
)
print("=====================")
return
else:
return
processed_payload = {
k: str(v) if isinstance(v, str) else v for k, v in payload.items()
}
# Use insert for consistency
stmt = (
insert(QueueItem)
.values(payload=processed_payload, session_id=session.id)
.returning(QueueItem)
)
await db.execute(stmt)
await db.commit()
return
except Exception as e:
print("=====================")
print("FAILURE: in enqueue")
@ -87,6 +136,43 @@ async def create_message_for_session(
raise HTTPException(status_code=404, detail="Session not found") from None
@router.post("/batch", response_model=List[schemas.Message])
async def create_batch_messages_for_session(
app_id: str,
user_id: str,
session_id: str,
batch: schemas.MessageBatchCreate,
background_tasks: BackgroundTasks,
db=db,
):
"""Bulk create messages for a session while maintaining order. Maximum 100 messages per batch."""
try:
created_messages = await crud.create_messages(
db, messages=batch.messages, app_id=app_id, user_id=user_id, session_id=session_id
)
# Create payloads for all messages
payloads = [
{
"app_id": app_id,
"user_id": user_id,
"session_id": session_id,
"message_id": message.public_id,
"is_user": message.is_user,
"content": message.content,
"metadata": message.h_metadata,
}
for message in created_messages
]
# Enqueue all messages in one call
background_tasks.add_task(enqueue, payloads) # type: ignore
return created_messages
except ValueError:
raise HTTPException(status_code=404, detail="Session not found") from None
@router.post("/list", response_model=Page[schemas.Message])
async def get_messages(
app_id: str,
@ -143,8 +229,6 @@ async def update_message(
db=db,
):
"""Update the metadata of a Message"""
if message.metadata is None:
raise HTTPException(status_code=400, detail="Message metadata cannot be empty")
try:
return await crud.update_message(
db,

View File

@ -68,8 +68,6 @@ async def update_session(
db=db,
):
"""Update the metadata of a Session"""
if session.metadata is None:
raise HTTPException(status_code=400, detail="Session metadata cannot be empty")
try:
return await crud.update_session(
db, app_id=app_id, user_id=user_id, session_id=session_id, session=session

View File

@ -23,7 +23,6 @@ async def create_user(
db=db,
):
"""Create a new User"""
print("running create_user")
try:
return await crud.create_user(db, app_id=app_id, user=user)
except IntegrityError as e:

View File

@ -1,4 +1,5 @@
import datetime
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field, field_validator
@ -8,7 +9,7 @@ class AppBase(BaseModel):
class AppCreate(AppBase):
name: str
name: Annotated[str, Field(min_length=1, max_length=100)]
metadata: dict = {}
@ -44,7 +45,7 @@ class UserBase(BaseModel):
class UserCreate(UserBase):
name: str
name: Annotated[str, Field(min_length=1, max_length=100)]
metadata: dict = {}
@ -54,7 +55,7 @@ class UserGet(UserBase):
class UserUpdate(UserBase):
name: str | None = None
metadata: dict | None = None
metadata: dict | None = None # Allow user to explicitly set metadata to empty
class User(UserBase):
@ -85,7 +86,7 @@ class MessageBase(BaseModel):
class MessageCreate(MessageBase):
content: str
content: Annotated[str, Field(min_length=0, max_length=50000)]
is_user: bool
metadata: dict = {}
@ -95,7 +96,7 @@ class MessageGet(MessageBase):
class MessageUpdate(MessageBase):
metadata: dict | None = None
metadata: dict
class Message(MessageBase):
@ -136,7 +137,7 @@ class SessionGet(SessionBase):
class SessionUpdate(SessionBase):
metadata: dict | None = None
metadata: dict
class Session(SessionBase):
@ -169,10 +170,10 @@ class MetamessageBase(BaseModel):
class MetamessageCreate(MetamessageBase):
metamessage_type: str
content: str
metamessage_type: Annotated[str, Field(min_length=1, max_length=50)]
content: Annotated[str, Field(min_length=0, max_length=50000)]
message_id: str
metadata: dict | None = {}
metadata: dict = {}
class MetamessageGet(MetamessageBase):
@ -221,8 +222,14 @@ class CollectionBase(BaseModel):
class CollectionCreate(CollectionBase):
name: str
metadata: dict | None = {}
name: Annotated[str, Field(min_length=1, max_length=100)]
metadata: dict = {}
@field_validator("name")
def validate_name(cls, v):
if v.lower() == "honcho":
raise ValueError("Collection name cannot be 'honcho'")
return v
class CollectionGet(CollectionBase):
@ -233,6 +240,12 @@ class CollectionUpdate(CollectionBase):
name: str | None = None
metadata: dict | None = None
@field_validator("name")
def validate_name(cls, v):
if v is not None and v.lower() == "honcho":
raise ValueError("Collection name cannot be 'honcho'")
return v
class Collection(CollectionBase):
public_id: str = Field(exclude=True)
@ -262,8 +275,8 @@ class DocumentBase(BaseModel):
class DocumentCreate(DocumentBase):
content: str
metadata: dict | None = {}
content: Annotated[str, Field(min_length=1, max_length=100000)]
metadata: dict = {}
class DocumentGet(DocumentBase):
@ -271,14 +284,14 @@ class DocumentGet(DocumentBase):
class DocumentQuery(DocumentBase):
query: str
query: Annotated[str, Field(min_length=1, max_length=1000)]
filter: dict | None = None
top_k: int = 5
top_k: int = Field(default=5, ge=1, le=50)
class DocumentUpdate(DocumentBase):
metadata: dict | None = None
content: str | None = None
metadata: dict | None = Field(None, max_length=10000)
content: Annotated[str | None, Field(min_length=1, max_length=100000)] = None
class Document(DocumentBase):
@ -306,8 +319,25 @@ class Document(DocumentBase):
class AgentQuery(BaseModel):
queries: str | list[str]
# collections: str | list[str] = "honcho"
@field_validator('queries')
def validate_queries(cls, v):
MAX_STRING_LENGTH = 10000
MAX_LIST_LENGTH = 25
if isinstance(v, str):
if len(v) > MAX_STRING_LENGTH:
raise ValueError('Query too long')
elif isinstance(v, list):
if len(v) > MAX_LIST_LENGTH:
raise ValueError('Too many queries')
if any(len(q) > MAX_STRING_LENGTH for q in v):
raise ValueError('One or more queries too long')
return v
class AgentChat(BaseModel):
content: str
class MessageBatchCreate(BaseModel):
"""Schema for batch message creation with a max of 100 messages"""
messages: list[MessageCreate] = Field(..., max_length=100)

View File

@ -17,6 +17,20 @@ def test_create_app(client):
assert "id" in data
def test_create_app_no_metadata(client):
name = str(generate_nanoid())
response = client.post("/v1/apps", json={"name": name})
print(response)
assert response.status_code == 200
data = response.json()
print("===================")
print(data)
print("===================")
assert data["name"] == name
assert data["metadata"] == {}
assert "id" in data
def test_get_or_create_app(client):
name = str(generate_nanoid())
response = client.get(f"/v1/apps/name/{name}")

View File

@ -110,3 +110,101 @@ async def test_update_message(client, db_session, sample_data):
assert response.status_code == 200
data = response.json()
assert data["metadata"] == {"new_key": "new_value"}
@pytest.mark.asyncio
async def test_update_message_empty_metadata(client, db_session, sample_data):
test_app, test_user = sample_data
# Create a test session and message
test_session = models.Session(user_id=test_user.public_id)
db_session.add(test_session)
await db_session.commit()
test_message = models.Message(
session_id=test_session.public_id, content="Test message", is_user=True
)
db_session.add(test_message)
await db_session.commit()
response = client.put(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{test_session.public_id}/messages/{test_message.public_id}",
json={"metadata": None},
)
assert response.status_code == 422
data = response.json()
print(data)
# assert data["detail"] == "Message metadata cannot be empty"
@pytest.mark.asyncio
async def test_create_batch_messages(client, db_session, sample_data):
test_app, test_user = sample_data
# Create a test session
test_session = models.Session(user_id=test_user.public_id)
db_session.add(test_session)
await db_session.commit()
# Create batch of test messages
test_messages = {
"messages": [
{
"content": f"Test message {i}",
"is_user": i % 2 == 0, # Alternating user/non-user messages
"metadata": {"batch_index": i}
} for i in range(3)
]
}
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{test_session.public_id}/messages/batch",
json=test_messages,
)
assert response.status_code == 200
data = response.json()
# Verify the response contains all messages
assert len(data) == 3
# Verify messages are in the correct order and have correct content
for i, message in enumerate(data):
assert message["content"] == f"Test message {i}"
assert message["is_user"] == (i % 2 == 0)
assert message["metadata"] == {"batch_index": i}
assert "id" in message
assert message["session_id"] == test_session.public_id
# Verify messages were actually saved to the database
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{test_session.public_id}/messages/list",
json={},
)
assert response.status_code == 200
saved_messages = response.json()["items"]
assert len(saved_messages) == 3
@pytest.mark.asyncio
async def test_create_batch_messages_limit(client, db_session, sample_data):
test_app, test_user = sample_data
test_session = models.Session(user_id=test_user.public_id)
db_session.add(test_session)
await db_session.commit()
# Create batch with more than 100 messages
test_messages = {
"messages": [
{
"content": f"Test message {i}",
"is_user": i % 2 == 0,
"metadata": {"batch_index": i}
} for i in range(101) # 101 messages
]
}
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{test_session.public_id}/messages/batch",
json=test_messages,
)
assert response.status_code == 422 # Validation error
data = response.json()
assert "messages" in data["detail"][0]["loc"] # Error should mention messages field

View File

@ -67,7 +67,7 @@ async def test_empty_update_session(client, db_session, sample_data):
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{test_session.public_id}",
json={},
)
assert response.status_code == 400
assert response.status_code == 422
@pytest.mark.asyncio

View File

@ -0,0 +1,516 @@
import pytest
from nanoid import generate as generate_nanoid
def test_app_validations_api(client):
# Test name too short
response = client.post("/v1/apps", json={"name": "", "metadata": {}})
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "name"]
assert error["msg"] == "String should have at least 1 character"
assert error["type"] == "string_too_short"
# Test name too long
response = client.post("/v1/apps", json={"name": "a" * 101, "metadata": {}})
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "name"]
assert error["msg"] == "String should have at most 100 characters"
assert error["type"] == "string_too_long"
# Test invalid metadata type
response = client.post("/v1/apps", json={"name": "test", "metadata": "not a dict"})
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "metadata"]
assert error["type"] == "dict_type"
def test_user_validations_api(client, sample_data):
test_app, _ = sample_data
# Test name too short
response = client.post(
f"/v1/apps/{test_app.public_id}/users",
json={"name": "", "metadata": {}}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "name"]
assert error["msg"] == "String should have at least 1 character"
assert error["type"] == "string_too_short"
# Test name too long
response = client.post(
f"/v1/apps/{test_app.public_id}/users",
json={"name": "a" * 101, "metadata": {}}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "name"]
assert error["msg"] == "String should have at most 100 characters"
assert error["type"] == "string_too_long"
def test_message_validations_api(client, sample_data):
test_app, test_user = sample_data
# Create a test session first
session_response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions",
json={"metadata": {}}
)
session_id = session_response.json()["id"]
# Test content too long
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}/messages",
json={
"content": "a" * 50001,
"is_user": True,
"metadata": {}
}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "content"]
assert error["msg"] == "String should have at most 50000 characters"
assert error["type"] == "string_too_long"
# Test invalid is_user type
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}/messages",
json={
"content": "test",
"is_user": "not a bool",
"metadata": {}
}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "is_user"]
assert error["type"] == "bool_parsing"
def test_collection_validations_api(client, sample_data):
test_app, test_user = sample_data
# Test name too short
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections",
json={"name": "", "metadata": {}}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "name"]
assert error["msg"] == "String should have at least 1 character"
assert error["type"] == "string_too_short"
# Test name too long
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections",
json={"name": "a" * 101, "metadata": {}}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "name"]
assert error["msg"] == "String should have at most 100 characters"
assert error["type"] == "string_too_long"
# Test 'honcho' name restriction
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections",
json={"name": "honcho", "metadata": {}}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "name"]
assert error["msg"] == "Value error, Collection name cannot be 'honcho'"
assert error["type"] == "value_error"
def test_document_validations_api(client, sample_data):
test_app, test_user = sample_data
# Create a collection first
collection_response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections",
json={"name": str(generate_nanoid()), "metadata": {}}
)
collection_id = collection_response.json()["id"]
# Test content too short
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections/{collection_id}/documents",
json={"content": "", "metadata": {}}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "content"]
assert error["msg"] == "String should have at least 1 character"
assert error["type"] == "string_too_short"
# Test content too long
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections/{collection_id}/documents",
json={"content": "a" * 100001, "metadata": {}}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "content"]
assert error["msg"] == "String should have at most 100000 characters"
assert error["type"] == "string_too_long"
def test_document_query_validations_api(client, sample_data):
test_app, test_user = sample_data
# Create a collection first
collection_response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections",
json={"name": str(generate_nanoid()), "metadata": {}}
)
collection_id = collection_response.json()["id"]
# Test query too short
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections/{collection_id}/documents/query",
json={"query": "", "top_k": 5}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "query"]
assert error["msg"] == "String should have at least 1 character"
assert error["type"] == "string_too_short"
# Test query too long
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections/{collection_id}/documents/query",
json={"query": "a" * 1001, "top_k": 5}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "query"]
assert error["msg"] == "String should have at most 1000 characters"
assert error["type"] == "string_too_long"
# Test top_k too small
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections/{collection_id}/documents/query",
json={"query": "test", "top_k": 0}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "top_k"]
assert error["msg"] == "Input should be greater than or equal to 1"
assert error["type"] == "greater_than_equal"
# Test top_k too large
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections/{collection_id}/documents/query",
json={"query": "test", "top_k": 51}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "top_k"]
assert error["msg"] == "Input should be less than or equal to 50"
assert error["type"] == "less_than_equal"
def test_message_batch_validations_api(client, sample_data):
test_app, test_user = sample_data
# Create a test session first
session_response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions",
json={"metadata": {}}
)
session_id = session_response.json()["id"]
# Test batch too large
messages = [
{
"content": f"test message {i}",
"is_user": True,
"metadata": {}
}
for i in range(101) # Create 101 messages
]
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}/messages/batch",
json={"messages": messages}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "messages"]
assert "List should have at most 100 items after validation" in error["msg"]
assert error["type"] == "too_long"
def test_metamessage_validations_api(client, sample_data):
test_app, test_user = sample_data
# Create session and message first
session_response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions",
json={"metadata": {}}
)
session_id = session_response.json()["id"]
message_response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}/messages",
json={"content": "test message", "is_user": True, "metadata": {}}
)
message_id = message_response.json()["id"]
# Test metamessage_type too short
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}/metamessages",
json={
"metamessage_type": "",
"content": "test content",
"message_id": message_id,
"metadata": {}
}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "metamessage_type"]
assert error["msg"] == "String should have at least 1 character"
assert error["type"] == "string_too_short"
# Test metamessage_type too long
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}/metamessages",
json={
"metamessage_type": "a" * 51,
"content": "test content",
"message_id": message_id,
"metadata": {}
}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "metamessage_type"]
assert error["msg"] == "String should have at most 50 characters"
assert error["type"] == "string_too_long"
# Test content too long
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}/metamessages",
json={
"metamessage_type": "test_type",
"content": "a" * 50001,
"message_id": message_id,
"metadata": {}
}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "content"]
assert error["msg"] == "String should have at most 50000 characters"
assert error["type"] == "string_too_long"
def test_collection_update_validations_api(client, sample_data):
test_app, test_user = sample_data
# Create a collection first
collection_response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections",
json={"name": str(generate_nanoid()), "metadata": {}}
)
collection_id = collection_response.json()["id"]
# Test honcho name in update
response = client.put(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections/{collection_id}",
json={"name": "honcho", "metadata": {}}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "name"]
assert error["msg"] == "Value error, Collection name cannot be 'honcho'"
assert error["type"] == "value_error"
def test_document_update_validations_api(client, sample_data):
test_app, test_user = sample_data
# Create collection and document first
collection_response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections",
json={"name": str(generate_nanoid()), "metadata": {}}
)
collection_id = collection_response.json()["id"]
document_response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections/{collection_id}/documents",
json={"content": "test content", "metadata": {}}
)
document_id = document_response.json()["id"]
# Test content too long in update
response = client.put(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections/{collection_id}/documents/{document_id}",
json={"content": "a" * 100001, "metadata": {}}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "content"]
assert error["msg"] == "String should have at most 100000 characters"
assert error["type"] == "string_too_long"
def test_session_validations_api(client, sample_data):
test_app, test_user = sample_data
# Create a test session first
session_response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions",
json={"metadata": {}}
)
session_id = session_response.json()["id"]
# Test invalid metadata type
response = client.put(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}",
json={"metadata": "not a dict"}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "metadata"]
assert error["type"] == "dict_type"
# Test empty update
response = client.put(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}",
json={}
)
assert response.status_code == 422
def test_agent_query_validations_api(client, sample_data):
test_app, test_user = sample_data
# Create a session first since agent queries are likely session-based
session_response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions",
json={"metadata": {}}
)
session_id = session_response.json()["id"]
# Test valid string query (under 10000 chars)
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}/chat",
json={"queries": "a" * 9999}
)
assert response.status_code == 200
# Test string query too long (over 10000 chars)
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}/chat",
json={"queries": "a" * 10001}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "queries"]
assert error["msg"] == "Value error, Query too long"
assert error["type"] == "value_error"
# Test valid list query (under 25 items, each under 10000 chars)
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}/chat",
json={"queries": ["a" * 9999 for _ in range(25)]}
)
assert response.status_code == 200
# Test list too long (over 25 items)
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}/chat",
json={"queries": ["test" for _ in range(26)]}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "queries"]
assert error["type"] == "value_error"
# Test list item too long (item over 10000 chars)
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}/chat",
json={"queries": ["a" * 10001]}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "queries"]
assert error["msg"] == "Value error, One or more queries too long"
assert error["type"] == "value_error"
# Test that strings over 20 chars are allowed
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}/chat",
json={"queries": "a" * 100} # 100 chars should be fine
)
assert response.status_code == 200
def test_required_field_validations_api(client, sample_data):
test_app, test_user = sample_data
session_response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions",
json={"metadata": {}}
)
session_id = session_response.json()["id"]
# Test missing required content in message
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}/messages",
json={"is_user": True, "metadata": {}}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "content"]
assert error["type"] == "missing"
# Test missing required is_user in message
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}/messages",
json={"content": "test", "metadata": {}}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "is_user"]
assert error["type"] == "missing"
# Test missing required name in collection
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections",
json={"metadata": {}}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "name"]
assert error["type"] == "missing"
def test_filter_validations_api(client, sample_data):
test_app, test_user = sample_data
# Create a session first
session_response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions",
json={"metadata": {}}
)
session_id = session_response.json()["id"]
# Test invalid filter type in message list (at session level)
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/sessions/{session_id}/messages/list",
json={"filter": "not a dict"}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "filter"]
assert error["type"] == "dict_type"
# Test invalid filter type in collection list (at user level)
response = client.post(
f"/v1/apps/{test_app.public_id}/users/{test_user.public_id}/collections/list",
json={"filter": "not a dict"}
)
assert response.status_code == 422
error = response.json()["detail"][0]
assert error["loc"] == ["body", "filter"]
assert error["type"] == "dict_type"

View File

@ -0,0 +1,218 @@
import pytest
from pydantic import ValidationError
from src.schemas import (
AppCreate,
UserCreate,
MessageCreate,
MetamessageCreate,
CollectionCreate,
DocumentCreate,
DocumentQuery,
MessageBatchCreate,
)
class TestAppValidations:
def test_valid_app_create(self):
app = AppCreate(name="test", metadata={})
assert app.name == "test"
assert app.metadata == {}
def test_app_name_too_short(self):
with pytest.raises(ValidationError) as exc_info:
AppCreate(name="", metadata={})
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "string_too_short"
def test_app_name_too_long(self):
with pytest.raises(ValidationError) as exc_info:
AppCreate(name="a" * 101, metadata={})
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "string_too_long"
def test_app_invalid_metadata_type(self):
with pytest.raises(ValidationError) as exc_info:
AppCreate(name="test", metadata="not a dict")
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "dict_type"
class TestUserValidations:
def test_valid_user_create(self):
user = UserCreate(name="test", metadata={})
assert user.name == "test"
assert user.metadata == {}
def test_user_name_too_short(self):
with pytest.raises(ValidationError) as exc_info:
UserCreate(name="", metadata={})
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "string_too_short"
def test_user_name_too_long(self):
with pytest.raises(ValidationError) as exc_info:
UserCreate(name="a" * 101, metadata={})
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "string_too_long"
class TestMessageValidations:
def test_valid_message_create(self):
msg = MessageCreate(content="test", is_user=True, metadata={})
assert msg.content == "test"
assert msg.is_user is True
assert msg.metadata == {}
def test_message_content_too_long(self):
with pytest.raises(ValidationError) as exc_info:
MessageCreate(content="a" * 50001, is_user=True, metadata={})
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "string_too_long"
def test_message_invalid_is_user_type(self):
with pytest.raises(ValidationError) as exc_info:
MessageCreate(content="test", is_user="not a bool", metadata={})
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "bool_parsing"
class TestMetamessageValidations:
def test_valid_metamessage_create(self):
meta = MetamessageCreate(
metamessage_type="test",
content="test content",
message_id="123",
metadata={},
)
assert meta.metamessage_type == "test"
assert meta.content == "test content"
assert meta.message_id == "123"
def test_metamessage_type_too_short(self):
with pytest.raises(ValidationError) as exc_info:
MetamessageCreate(
metamessage_type="",
content="test",
message_id="123",
metadata={},
)
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "string_too_short"
def test_metamessage_type_too_long(self):
with pytest.raises(ValidationError) as exc_info:
MetamessageCreate(
metamessage_type="a" * 51,
content="test",
message_id="123",
metadata={},
)
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "string_too_long"
def test_metamessage_content_too_long(self):
with pytest.raises(ValidationError) as exc_info:
MetamessageCreate(
metamessage_type="test",
content="a" * 50001,
message_id="123",
metadata={},
)
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "string_too_long"
class TestCollectionValidations:
def test_valid_collection_create(self):
collection = CollectionCreate(name="test", metadata={})
assert collection.name == "test"
assert collection.metadata == {}
def test_collection_name_too_short(self):
with pytest.raises(ValidationError) as exc_info:
CollectionCreate(name="", metadata={})
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "string_too_short"
def test_collection_name_too_long(self):
with pytest.raises(ValidationError) as exc_info:
CollectionCreate(name="a" * 101, metadata={})
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "string_too_long"
def test_collection_name_honcho(self):
with pytest.raises(ValidationError) as exc_info:
CollectionCreate(name="honcho", metadata={})
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "value_error"
class TestDocumentValidations:
def test_valid_document_create(self):
doc = DocumentCreate(content="test content", metadata={})
assert doc.content == "test content"
assert doc.metadata == {}
def test_document_content_too_short(self):
with pytest.raises(ValidationError) as exc_info:
DocumentCreate(content="", metadata={})
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "string_too_short"
def test_document_content_too_long(self):
with pytest.raises(ValidationError) as exc_info:
DocumentCreate(content="a" * 100001, metadata={})
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "string_too_long"
class TestDocumentQueryValidations:
def test_valid_document_query(self):
query = DocumentQuery(query="test query", top_k=5)
assert query.query == "test query"
assert query.top_k == 5
def test_query_too_short(self):
with pytest.raises(ValidationError) as exc_info:
DocumentQuery(query="", top_k=5)
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "string_too_short"
def test_query_too_long(self):
with pytest.raises(ValidationError) as exc_info:
DocumentQuery(query="a" * 1001, top_k=5)
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "string_too_long"
def test_top_k_too_small(self):
with pytest.raises(ValidationError) as exc_info:
DocumentQuery(query="test", top_k=0)
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "greater_than_equal"
def test_top_k_too_large(self):
with pytest.raises(ValidationError) as exc_info:
DocumentQuery(query="test", top_k=51)
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "less_than_equal"
class TestMessageBatchValidations:
def test_valid_message_batch(self):
batch = MessageBatchCreate(
messages=[
MessageCreate(content="test", is_user=True, metadata={})
]
)
assert len(batch.messages) == 1
def test_message_batch_too_large(self):
with pytest.raises(ValidationError) as exc_info:
MessageBatchCreate(
messages=[
MessageCreate(content="test", is_user=True, metadata={})
for _ in range(101)
]
)
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "too_long"