* type stuff

* add action

* bump python

* Refactor type annotations and update tracking decorators in agent and dependencies modules. Replace ai_track with track from src.utils.types, and enhance type hints for better clarity. Update pyproject.toml to allow untyped libraries.

* type everything basically

* fix migration typing

* type like crazy

* remove usless tests

* Update mocks in tests to use AsyncMock for dialectic_call and dialectic_stream, ensuring proper async behavior in test cases. Adjust mock return values for consistency and clarity.

* Update src/deriver/tom/single_prompt.py

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update src/deriver/tom/long_term.py

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Enhance CLAUDE.md documentation with additional details on core concepts, API structure, and development commands. Update command syntax for running server and tests to use 'uv run' for consistency. Improve clarity in configuration and architectural decisions sections.

* Refactor type annotations in CRUD functions to accept more flexible filter types, changing from dict[str, str] to dict[str, Any]. Clean up logging in agent.py by removing unnecessary timing logs for user representation generation and query execution.

* Remove unused import of ai_track from long_term.py and single_prompt.py to clean up the codebase.

* pass tests

* update some stuff

* fix unused

* ruff

* make stuff work again

* Add LLM_GROQ_API_KEY to GitHub Actions and format tom_inference parameters

* test

* test

* Refactor LLM settings to use 'gemini' provider and update related model parameters; remove unused API keys from GitHub Actions workflow.

* Update LLM settings to use 'anthropic' provider and change model to 'claude-3-5-haiku-20241022'; maintain existing summarization provider.

* test

* llm provider stuff

* update

* revert

* Integrate client management for LLM providers across various modules; remove deprecated environment variable setup for API keys.

* only if key avaialble

* Refactor type hints and improve schema definitions for queue processing; remove unused imports and enhance function signatures for clarity.

* fix test

* model

* test

* Update LLM provider type annotations and enhance client management; replace Provider with Providers for better type handling in config and clients modules.

* Refactor LLM provider handling to default to "openai" for custom providers across multiple modules; update type annotations and improve client management for consistency.

---------

Co-authored-by: Dani Balcells <18307962+danibalcells@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Ayush Paul 2025-06-24 18:29:13 -04:00 committed by GitHub
parent 80652d4395
commit 24bf8eeeb4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 2151 additions and 2509 deletions

View File

@ -74,7 +74,7 @@ LLM_ANTHROPIC_API_KEY=your-anthropic-api-key-here
# LLM_QUERY_GENERATION_MODEL=llama-3.1-8b-instant
# Summarization LLM settings
# LLM_SUMMARY_PROVIDER=gemini
# LLM_SUMMARY_PROVIDER=google
# LLM_SUMMARY_MODEL=gemini-2.0-flash-lite
# LLM_SUMMARY_MAX_TOKENS_SHORT=1000
# LLM_SUMMARY_MAX_TOKENS_LONG=2000

24
.github/workflows/staticanalysis.yml vendored Normal file
View File

@ -0,0 +1,24 @@
name: Static Analysis
on: [push]
permissions:
contents: read
jobs:
basedpyright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
- name: Install uv
uses: astral-sh/setup-uv@v2
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Install the project
run: uv sync --all-extras --dev
- name: run basedpyright
run: uv run basedpyright

View File

@ -51,3 +51,11 @@ jobs:
SENTRY_ENABLED: false
LLM_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
LLM_ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
LLM_DIALECTIC_PROVIDER: openai
LLM_DIALECTIC_MODEL: test
LLM_QUERY_GENERATION_PROVIDER: openai
LLM_QUERY_GENERATION_MODEL: test
LLM_TOM_INFERENCE_PROVIDER: openai
LLM_TOM_INFERENCE_MODEL: test
LLM_SUMMARY_PROVIDER: openai
LLM_SUMMARY_MODEL: test

4
.vscode/tasks.json vendored
View File

@ -2,9 +2,9 @@
"version": "2.0.0",
"tasks": [
{
"label": "start",
"label": "sync",
"type": "shell",
"command": "uv sync && uv run fastapi dev src/main.py",
"command": "uv sync",
"group": "none",
"presentation": {
"reveal": "always",

View File

@ -80,11 +80,12 @@ All API routes follow the pattern: `/v1/{resource}/{id}/{action}`
### Commands
- Setup: `uv sync`
- Run server: `fastapi dev src/main.py`
- Run tests: `pytest tests/`
- Run single test: `pytest tests/path/to/test_file.py::test_function`
- Linting: `ruff check src/`
- Format code: `ruff format src/`
- Run server: `uv run fastapi dev src/main.py`
- Run tests: `uv run pytest tests/`
- Run single test: `uv run pytest tests/path/to/test_file.py::test_function`
- Linting: `uv run ruff check src/`
- Typechecking: `uv run basedpyright`
- Format code: `uv run ruff format src/`
### Code Style

View File

@ -49,7 +49,7 @@ QUERY_GENERATION_PROVIDER = "groq"
QUERY_GENERATION_MODEL = "llama-3.1-8b-instant"
# Summarization specific
SUMMARY_PROVIDER = "gemini"
SUMMARY_PROVIDER = "google"
SUMMARY_MODEL = "gemini-2.0-flash-lite"
SUMMARY_MAX_TOKENS_SHORT = 1000
SUMMARY_MAX_TOKENS_LONG = 2000

View File

@ -79,7 +79,7 @@ FastAPI routes should be simple, letting global exception handlers do most of th
```python
@router.get("/{user_id}")
async def get_user(app_id: str, user_id: str, db=db):
async def get_user(app_id: str, user_id: str, db: AsyncSession = db):
# ResourceNotFoundException will be caught by global handler
return await crud.get_user(db, app_id, user_id)
```
@ -88,7 +88,7 @@ For specific handling, use try/except only when necessary:
```python
@router.post("")
async def create_user(app_id: str, user: schemas.UserCreate, db=db):
async def create_user(app_id: str, user: schemas.UserCreate, db: AsyncSession = db):
try:
return await crud.create_user(db, app_id, user)
except ConflictException:
@ -197,7 +197,7 @@ async def get_document(
user_id: str,
collection_id: str,
document_id: str,
db=db
db: AsyncSession = db
):
"""Get a document by ID"""
# ResourceNotFoundException will be caught by global handler

View File

@ -1,5 +1,3 @@
from os import getenv
from typing import Optional
import sqlalchemy as sa
from alembic import op
@ -11,7 +9,7 @@ def get_schema() -> str:
return settings.DB.SCHEMA
def table_exists(table_name: str, inspector: Optional[sa.Inspector] = None) -> bool:
def table_exists(table_name: str, inspector: sa.Inspector | None = None) -> bool:
"""Check if a table exists in the database."""
if inspector is None:
inspector = sa.inspect(op.get_bind())
@ -20,7 +18,7 @@ def table_exists(table_name: str, inspector: Optional[sa.Inspector] = None) -> b
def column_exists(
table_name: str, column_name: str, inspector: Optional[sa.Inspector] = None
table_name: str, column_name: str, inspector: sa.Inspector | None = None
) -> bool:
"""Check if a column exists in a table."""
if inspector is None:
@ -33,7 +31,7 @@ def column_exists(
def fk_exists(
table_name: str, fk_name: str, inspector: Optional[sa.Inspector] = None
table_name: str, fk_name: str, inspector: sa.Inspector | None = None
) -> bool:
"""Check if a foreign key exists in a table."""
if inspector is None:
@ -44,7 +42,7 @@ def fk_exists(
def index_exists(
table_name: str, index_name: str, inspector: Optional[sa.Inspector] = None
table_name: str, index_name: str, inspector: sa.Inspector | None = None
) -> bool:
"""Check if an index exists in a table."""
if inspector is None:
@ -58,7 +56,7 @@ def constraint_exists(
table_name: str,
constraint_name: str,
type: str,
inspector: Optional[sa.Inspector] = None,
inspector: sa.Inspector | None = None,
) -> bool:
"""Check if a constraint exists in a table."""
if inspector is None:

View File

@ -7,16 +7,15 @@ Create Date: 2025-05-13 16:11:54.859842
"""
from collections.abc import Sequence
from typing import Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "20f89a421aff"
down_revision: Union[str, None] = "556a16564f50"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "556a16564f50"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:

View File

@ -7,7 +7,6 @@ Create Date: 2025-05-13 17:10:33.805495
"""
from collections.abc import Sequence
from typing import Union
import sqlalchemy as sa
from alembic import op
@ -22,9 +21,9 @@ from migrations.utils import (
# revision identifiers, used by Alembic.
revision: str = "556a16564f50"
down_revision: Union[str, None] = "b765d82110bd"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "b765d82110bd"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:

View File

@ -7,19 +7,17 @@ Create Date: 2025-05-19 17:00:18.151735
"""
from collections.abc import Sequence
from typing import Union
from os import getenv
from alembic import op
from sqlalchemy import text
from src.config import settings
# revision identifiers, used by Alembic.
revision: str = "66e63cf2cf77"
down_revision: Union[str, None] = "20f89a421aff"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
schema = settings.DB.SCHEMA
down_revision: str | None = "20f89a421aff"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
schema = getenv("DATABASE_SCHEMA", "public")
def upgrade() -> None:

View File

@ -7,7 +7,6 @@ Create Date: 2024-01-01 00:00:00.000000
"""
from collections.abc import Sequence
from typing import Union
import sqlalchemy as sa
from alembic import op
@ -18,9 +17,9 @@ from src.config import settings
# revision identifiers, used by Alembic.
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
@ -360,7 +359,7 @@ def upgrade() -> None:
server_default="{}",
),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("embedding", Vector(1536), nullable=True),
sa.Column("embedding", Vector(1536), nullable=True), # pyright: ignore
sa.Column(
"created_at",
sa.DateTime(timezone=True),
@ -464,4 +463,3 @@ def downgrade() -> None:
op.drop_table("sessions", schema=schema)
op.drop_table("users", schema=schema)
op.drop_table("apps", schema=schema)

View File

@ -7,7 +7,6 @@ Create Date: 2025-04-03 15:32:16.733312
"""
from collections.abc import Sequence
from typing import Union
import sqlalchemy as sa
from alembic import op
@ -18,9 +17,9 @@ from src.config import settings
# revision identifiers, used by Alembic.
revision: str = "b765d82110bd"
down_revision: Union[str, None] = "c3828084f472"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "c3828084f472"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
@ -99,13 +98,15 @@ def upgrade() -> None:
# 4. Data migration: Fill session_id from messages
try:
op.execute("""
op.execute(
"""
UPDATE metamessages m
SET session_id = msg.session_id
FROM messages msg
WHERE m.message_id = msg.public_id
AND m.session_id IS NULL
""")
"""
)
print("Updated session_id values from messages")
except Exception as e:
print(f"Error updating session_id values: {e}")
@ -122,23 +123,27 @@ def upgrade() -> None:
print(
f"Found {orphaned_count} orphaned records. Setting message_id to NULL."
)
op.execute("""
op.execute(
"""
UPDATE metamessages
SET message_id = NULL
WHERE message_id IS NOT NULL AND session_id IS NULL
""")
"""
)
except Exception as e:
print(f"Error handling orphaned records: {e}")
# 6. Data migration: Fill user_id from session's user
try:
op.execute("""
op.execute(
"""
UPDATE metamessages m
SET user_id = s.user_id
FROM sessions s
WHERE m.session_id = s.public_id
AND m.user_id IS NULL
""")
"""
)
print("Updated user_id values from sessions")
except Exception as e:
print(f"Error updating user_id values from sessions: {e}")
@ -152,10 +157,15 @@ def upgrade() -> None:
}
# Helper function to create index if not exists
def create_index_if_not_exists(index_name, table_name, columns, **kwargs):
def create_index_if_not_exists(
index_name: str,
table_name: str,
columns: list[str | sa.TextClause],
schema: str,
):
if index_name not in existing_indices:
try:
op.create_index(index_name, table_name, columns, **kwargs)
op.create_index(index_name, table_name, columns, schema=schema)
print(f"Created index {index_name}")
except Exception as e:
print(f"Error creating index {index_name}: {e}")
@ -191,11 +201,13 @@ def upgrade() -> None:
if constraint_violations and constraint_violations > 0:
print(f"WARNING: {constraint_violations} records would violate the constraint!")
print("Setting these message_ids to NULL to prevent constraint violation")
op.execute("""
op.execute(
"""
UPDATE metamessages
SET message_id = NULL
WHERE message_id IS NOT NULL AND session_id IS NULL
""")
"""
)
# 9. Add the check constraint for message_id and session_id relationship
try:
@ -230,7 +242,8 @@ def upgrade() -> None:
if null_user_count and null_user_count > 0:
print(f"WARNING: {null_user_count} records have NULL user_id!")
# Try to derive user_id from message for these orphaned records
op.execute("""
op.execute(
"""
UPDATE metamessages m
SET user_id = u.public_id
FROM messages msg
@ -238,7 +251,8 @@ def upgrade() -> None:
JOIN users u ON s.user_id = u.public_id
WHERE m.message_id = msg.public_id
AND m.user_id IS NULL
""")
"""
)
# Check again after fixes
null_user_count = conn.execute(
@ -321,9 +335,11 @@ def downgrade() -> None:
# 4. Make message_id required again and clean up data if needed
try:
op.execute("""
op.execute(
"""
DELETE FROM metamessages WHERE message_id IS NULL
""")
"""
)
op.alter_column(
"metamessages",
"message_id",

View File

@ -7,8 +7,6 @@ Create Date: 2024-12-12 13:41:40.156095
"""
from collections.abc import Sequence
from os import getenv
from typing import Union
import sqlalchemy as sa
from alembic import op
@ -17,9 +15,9 @@ from src.config import settings
# revision identifiers, used by Alembic.
revision: str = "c3828084f472"
down_revision: Union[str, None] = "a1b2c3d4e5f6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "a1b2c3d4e5f6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:

View File

@ -9,7 +9,6 @@ Create Date: 2025-06-09 15:16:38.164067
from collections.abc import Sequence
from contextlib import suppress
from os import getenv
from typing import Union
import sqlalchemy as sa
import tiktoken
@ -28,9 +27,9 @@ from migrations.utils import (
# revision identifiers, used by Alembic.
revision: str = "d429de0e5338"
down_revision: Union[str, None] = "66e63cf2cf77"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
down_revision: str | None = "66e63cf2cf77"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:

View File

@ -6,7 +6,7 @@ authors = [
{name = "Plastic Labs", email = "hello@plasticlabs.ai"},
]
readme = "README.md"
requires-python = ">=3.9"
requires-python = ">=3.10"
dependencies = [
"fastapi[standard]>=0.111.0",
"python-dotenv>=1.0.0",
@ -18,16 +18,14 @@ dependencies = [
"psycopg[binary]>=3.1.19",
"httpx>=0.27.0",
"rich>=13.7.1",
"openai>=1.43.0",
"anthropic>=0.36.0",
"nanoid>=2.0.0",
"alembic>=1.14.0",
"langfuse>=2.57.1,<3.0.0",
"pyjwt>=2.10.0",
"google-genai>=1.10.0",
"pydantic-settings>=2.9.1",
"tomli>=2.0.0; python_version < '3.11'",
"tiktoken>=0.9.0",
"mirascope[anthropic,google,groq,langfuse]>=1.25.1",
"openai>=1.91.0",
"pydantic>=2.11.7",
"pydantic-settings>=2.10.1",
]
[tool.uv]
dev-dependencies = [
@ -38,6 +36,7 @@ dev-dependencies = [
"interrogate>=1.7.0",
"py-spy>=0.3.14",
"ruff>=0.11.2",
"basedpyright>=1.29.4",
]
[tool.ruff.lint]
@ -64,3 +63,37 @@ extend-immutable-calls = ["fastapi.Depends"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
[tool.basedpyright]
# BasedPyright currently seems like the best type checker option, much faster
# than mypy and with a good extension for VSCode/Cursor.
# https://marketplace.visualstudio.com/items?itemName=detachhead.basedpyright
# https://docs.basedpyright.com/latest/configuration/config-files/#sample-pyprojecttoml-file
include = ["src", "tests"]
exclude = ["tests/**/disabled*.py"]
# By default BasedPyright is very strict, so you almost certainly want to disable
# some of the rules.
# First, these turn off warnings about (yes) how you ignore warnings:
reportIgnoreCommentWithoutRule = false
# reportUnnecessaryTypeIgnoreComment = false
# A few typically noisy warnings are next.
# How many you enable is up to you. The first few are off by default, but you can
# comment/uncomment these as desired:
reportMissingTypeStubs = false
reportUnusedCallResult = false
reportCallInDefaultInitializer = false
reportAny = false
reportExplicitAny = false
allowedUntypedLibraries = ["langfuse", "langfuse.decorators", "mirascope"]
reportImplicitOverride = false
# reportImplicitStringConcatenation = false
# reportUnreachable = false
# reportPrivateImportUsage = false
# reportPrivateLocalImportUsage = false
# reportMissingImports = false
# reportUnnecessaryIsInstance = false
# reportUnknownVariableType = false
# reportUnknownArgumentType = false
# reportUnknownParameterType = false
# reportUnknownMemberType = false

View File

@ -1,127 +1,103 @@
import asyncio
import json
import logging
import os
from typing import Any, Optional
import sentry_sdk
from anthropic import MessageStreamManager
from langfuse.decorators import langfuse_context, observe
from sentry_sdk.ai.monitoring import ai_track
from langfuse.decorators import langfuse_context, observe # pyright: ignore
from mirascope import llm
from mirascope.integrations.langfuse import with_langfuse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models, schemas
from src import crud, models
from src.config import settings
from src.dependencies import tracked_db
from src.deriver.tom import get_tom_inference
from src.deriver.tom.embeddings import CollectionEmbeddingStore
from src.deriver.tom.long_term import get_user_representation_long_term
from src.deriver.tom.single_prompt import UserRepresentationOutput
from src.utils import history, parse_xml_content
from src.utils.model_client import ModelClient, ModelProvider
from src.utils.clients import clients
from src.utils.types import track
# Configure logging
logger = logging.getLogger(__name__)
QUERY_GENERATION_SYSTEM = """Given this query about a user, generate 3 focused search queries that would help retrieve relevant facts about the user.
Each query should focus on a specific aspect related to the original query, rephrased to maximize semantic search effectiveness.
For example, if the original query asks "what does the user like to eat?", generated queries might include "user's food preferences", "user's favorite cuisine", etc.
Format your response as a JSON array of strings, with each string being a search query.
Respond only in valid JSON, without markdown formatting or quotes, and nothing else.
Example:
["query about interests", "query about personality", "query about experiences"]"""
@track("Dialectic Call")
@with_langfuse()
@llm.call(
provider=(
settings.LLM.DIALECTIC_PROVIDER
if settings.LLM.DIALECTIC_PROVIDER != "custom"
else "openai"
),
model=settings.LLM.DIALECTIC_MODEL,
client=clients[settings.LLM.DIALECTIC_PROVIDER],
)
async def dialectic_call(
query: str, working_representation: str, additional_context: str
):
return f"""
You are operating as a context service that helps maintain psychological understanding of users across applications. Alongside a query, you'll receive: 1) previously collected psychological context about the user that I've maintained, 2) a series of long-term facts about the user, and 3) their current conversation/interaction from the requesting application. Your goal is to analyze this information and provide theory-of-mind insights that help applications personalize their responses. Please respond in a brief, matter-of-fact, and appropriate manner to convey as much relevant information to the application based on its query and the user's most recent message. You are encouraged to provide any context from the provided resources that helps provide a more complete or nuanced understanding of the user, as long as it is somewhat relevant to the query. If the context provided doesn't help address the query, write absolutely NOTHING but "None".
<query>{query}</query>
<context>{working_representation}</context>
<conversation_history>{additional_context}</conversation_history>
"""
class Dialectic:
def __init__(self, agent_input: str, user_representation: str, chat_history: str):
self.agent_input = agent_input
self.user_representation = user_representation
self.chat_history = chat_history
self.client = ModelClient(
provider=ModelProvider(settings.LLM.DIALECTIC_PROVIDER),
model=settings.LLM.DIALECTIC_MODEL,
)
self.system_prompt = """You are operating as a context service that helps maintain psychological understanding of users across applications. Alongside a query, you'll receive: 1) previously collected psychological context about the user that I've maintained, 2) a series of long-term facts about the user, and 3) their current conversation/interaction from the requesting application. Your goal is to analyze this information and provide theory-of-mind insights that help applications personalize their responses. Please respond in a brief, matter-of-fact, and appropriate manner to convey as much relevant information to the application based on its query and the user's most recent message. You are encouraged to provide any context from the provided resources that helps provide a more complete or nuanced understanding of the user, as long as it is somewhat relevant to the query. If the context provided doesn't help address the query, write absolutely NOTHING but "None"."""
@track("Dialectic Stream")
@with_langfuse()
@llm.call(
provider=(
settings.LLM.DIALECTIC_PROVIDER
if settings.LLM.DIALECTIC_PROVIDER != "custom"
else "openai"
),
model=settings.LLM.DIALECTIC_MODEL,
stream=True,
client=clients[settings.LLM.DIALECTIC_PROVIDER],
)
async def dialectic_stream(
query: str, working_representation: str, additional_context: str
):
return f"""
You are operating as a context service that helps maintain psychological understanding of users across applications. Alongside a query, you'll receive: 1) previously collected psychological context about the user that I've maintained, 2) a series of long-term facts about the user, and 3) their current conversation/interaction from the requesting application. Your goal is to analyze this information and provide theory-of-mind insights that help applications personalize their responses. Please respond in a brief, matter-of-fact, and appropriate manner to convey as much relevant information to the application based on its query and the user's most recent message. You are encouraged to provide any context from the provided resources that helps provide a more complete or nuanced understanding of the user, as long as it is somewhat relevant to the query. If the context provided doesn't help address the query, write absolutely NOTHING but "None".
@ai_track("Dialectic Call")
@observe()
async def call(self):
with sentry_sdk.start_transaction(
op="dialectic-inference", name="Dialectic API Response"
):
logger.debug(
f"Starting call() method with query length: {len(self.agent_input)}"
)
call_start = asyncio.get_event_loop().time()
<query>{query}</query>
<context>{working_representation}</context>
<conversation_history>{additional_context}</conversation_history>
"""
prompt = f"""
<query>{self.agent_input}</query>
<context>{self.user_representation}</context>
<conversation_history>{self.chat_history}</conversation_history>
"""
logger.debug(
f"Prompt constructed with context length: {len(self.user_representation)} chars"
)
# Create a properly formatted message
message: dict[str, Any] = {"role": "user", "content": prompt}
class SemanticQueries(BaseModel):
queries: list[str]
# Generate the response
logger.debug("Calling model for generation")
model_start = asyncio.get_event_loop().time()
response = await self.client.generate(
messages=[message],
system=self.system_prompt,
max_tokens=settings.LLM.DEFAULT_MAX_TOKENS,
)
model_time = asyncio.get_event_loop().time() - model_start
logger.debug(
f"Model response received in {model_time:.2f}s: {len(response)} chars"
)
total_time = asyncio.get_event_loop().time() - call_start
logger.debug(f"call() completed in {total_time:.2f}s")
return [{"text": response}]
@with_langfuse()
@llm.call(
provider=(
settings.LLM.QUERY_GENERATION_PROVIDER
if settings.LLM.QUERY_GENERATION_PROVIDER != "custom"
else "openai"
),
model=settings.LLM.QUERY_GENERATION_MODEL,
response_model=SemanticQueries,
client=clients[settings.LLM.QUERY_GENERATION_PROVIDER],
)
async def generate_semantic_queries_llm(query: str):
return f"""
Given this query about a user, generate 3 focused search queries that would help retrieve relevant facts about the user. Each query should focus on a specific aspect related to the original query, rephrased to maximize semantic search effectiveness.
For example, if the original query asks "what does the user like to eat?", generated queries might include "user's food preferences", "user's favorite cuisine", etc.
@ai_track("Dialectic Call")
@observe()
async def stream(self):
with sentry_sdk.start_transaction(
op="dialectic-inference", name="Dialectic API Response"
):
logger.debug(
f"Starting stream() method with query length: {len(self.agent_input)}"
)
stream_start = asyncio.get_event_loop().time()
Format your response as a JSON array of strings, with each string being a search query.
Respond only in valid JSON, without markdown formatting or quotes, and nothing else.
Example:
["query about interests", "query about personality", "query about experiences"]
prompt = f"""
<query>{self.agent_input}</query>
<context>{self.user_representation}</context>
<conversation_history>{self.chat_history}</conversation_history>
"""
logger.debug(
f"Prompt constructed with context length: {len(self.user_representation)} chars"
)
# Create a properly formatted message
message: dict[str, Any] = {"role": "user", "content": prompt}
# Stream the response
logger.debug("Calling model for streaming")
model_start = asyncio.get_event_loop().time()
stream = await self.client.stream(
messages=[message],
system=self.system_prompt,
max_tokens=settings.LLM.DEFAULT_MAX_TOKENS,
)
stream_setup_time = asyncio.get_event_loop().time() - model_start
logger.debug(f"Stream started in {stream_setup_time:.2f}s")
total_time = asyncio.get_event_loop().time() - stream_start
logger.debug(f"stream() setup completed in {total_time:.2f}s")
return stream
<query>{query}</query>
"""
@observe()
@ -131,7 +107,7 @@ async def chat(
session_name: str | None,
queries: str | list[str],
stream: bool = False,
) -> schemas.DialecticResponse | MessageStreamManager:
) -> llm.Stream | llm.CallResponse:
"""
Chat with the Dialectic API using on-demand user representation generation.
@ -161,8 +137,6 @@ async def chat(
logger.debug(f"Received query: {final_query} for session {session_name}")
logger.debug("Starting on-demand user representation generation")
start_time = asyncio.get_event_loop().time()
# Setup phase - create resources we'll need for all operations
# 1. Fetch latest peer message & chat history
@ -237,15 +211,7 @@ async def chat(
f"User representation generated: {len(user_representation)} characters"
)
# Create a Dialectic chain with the fresh user representation
chain = Dialectic(
agent_input=final_query,
user_representation=user_representation,
chat_history=chat_history,
)
generation_time = asyncio.get_event_loop().time() - start_time
logger.debug(f"User representation generation completed in {generation_time:.2f}s")
# Call dialectic with enhanced context
langfuse_context.update_current_trace(
session_id=session_name,
@ -256,21 +222,16 @@ async def chat(
# Use streaming or non-streaming response based on the request
logger.debug(f"Calling Dialectic with streaming={stream}")
query_start_time = asyncio.get_event_loop().time()
if stream:
response_stream = await chain.stream()
logger.debug(
f"Dialectic stream started after {asyncio.get_event_loop().time() - query_start_time:.2f}s"
logger.debug("Calling Dialectic with streaming")
response = await dialectic_stream(
final_query, user_representation, chat_history
)
return response_stream
response = await chain.call()
query_time = asyncio.get_event_loop().time() - query_start_time
total_time = asyncio.get_event_loop().time() - start_time
logger.debug(
f"Dialectic response received in {query_time:.2f}s (total: {total_time:.2f}s)"
)
return schemas.DialecticResponse(content=response[0]["text"])
return response
else:
logger.debug("Calling Dialectic with non-streaming")
response = await dialectic_call(final_query, user_representation, chat_history)
return response
async def get_long_term_facts(
@ -324,7 +285,7 @@ async def get_long_term_facts(
all_facts_lists = await asyncio.gather(*query_tasks)
# Combine all facts into a single set to remove duplicates
retrieved_facts = set()
retrieved_facts: set[str] = set()
for facts in all_facts_lists:
retrieved_facts.update(facts)
@ -360,7 +321,14 @@ async def run_tom_inference(chat_history: str) -> str:
tom_time = asyncio.get_event_loop().time() - tom_start_time
logger.debug(f"ToM inference completed in {tom_time:.2f}s")
prediction = parse_xml_content(tom_inference_response, "prediction")
# Create a prediction summary from the structured Pydantic object
prediction = (
f"Current context: {tom_inference_response.current_state.immediate_context}"
)
if tom_inference_response.tentative_inferences:
prediction += f"\nKey inferences: {', '.join([inf.interpretation for inf in tom_inference_response.tentative_inferences[:3]])}"
logger.debug(f"Prediction length: {len(prediction)} characters")
return prediction
@ -381,47 +349,9 @@ async def generate_semantic_queries(query: str) -> list[str]:
query_start = asyncio.get_event_loop().time()
logger.debug("Calling LLM for query generation")
llm_start = asyncio.get_event_loop().time()
try:
provider = ModelProvider(settings.LLM.QUERY_GENERATION_PROVIDER)
except ValueError as e:
logger.error(
f"Invalid query-generation provider '{settings.LLM.QUERY_GENERATION_PROVIDER}': {e}"
)
raise
# Create a new model client
client = ModelClient(
provider=provider,
model=settings.LLM.QUERY_GENERATION_MODEL,
)
# Prepare the messages for Anthropic
messages: list[dict[str, Any]] = [{"role": "user", "content": query}]
# Generate the response
try:
result = await client.generate(
messages=messages,
system=QUERY_GENERATION_SYSTEM,
max_tokens=settings.LLM.DEFAULT_MAX_TOKENS,
use_caching=True, # Likely not caching because the system prompt is under 1000 tokens
)
llm_time = asyncio.get_event_loop().time() - llm_start
logger.debug(f"LLM response received in {llm_time:.2f}s: {result[:100]}...")
# Parse the JSON response to get a list of queries
try:
queries = json.loads(result)
if not isinstance(queries, list):
# Fallback if response is not a valid list
logger.debug("LLM response not a list, using as single query")
queries = [result]
except json.JSONDecodeError:
# Fallback if response is not valid JSON
logger.debug("Failed to parse JSON response, using raw response as query")
queries = [query] # Fall back to the original query
queries_result = await generate_semantic_queries_llm(query)
queries = queries_result.queries
# Ensure we always include the original query
if query not in queries:
@ -433,8 +363,8 @@ async def generate_semantic_queries(query: str) -> list[str]:
return queries
except Exception as e:
logger.error(f"Error during API call: {str(e)}")
raise
logger.error(f"Error during query generation: {str(e)}")
return [query] # Fallback to original query
async def generate_user_representation(
@ -445,7 +375,7 @@ async def generate_user_representation(
tom_inference: str,
facts: list[str],
db: AsyncSession,
message_id: Optional[str] = None,
message_id: str | None = None,
with_inference: bool = False,
) -> str:
"""
@ -473,19 +403,37 @@ async def generate_user_representation(
# Generate the new user representation
logger.debug("Calling get_user_representation")
gen_start_time = asyncio.get_event_loop().time()
user_representation_response = await get_user_representation_long_term(
chat_history=chat_history,
facts=facts,
user_representation=latest_representation,
tom_inference=tom_inference,
user_representation_response: UserRepresentationOutput = (
await get_user_representation_long_term(
chat_history=chat_history,
facts=facts,
user_representation=latest_representation,
tom_inference=tom_inference,
)
)
gen_time = asyncio.get_event_loop().time() - gen_start_time
logger.debug(f"get_user_representation completed in {gen_time:.2f}s")
# Extract the representation from the response
representation = parse_xml_content(
user_representation_response, "representation"
)
if hasattr(user_representation_response, "current_state"):
# New Mirascope response model
representation = f"""
CURRENT STATE: {user_representation_response.current_state}
TENTATIVE PATTERNS:
{chr(10).join([pattern.pattern for pattern in user_representation_response.tentative_patterns])}
KNOWLEDGE GAPS:
{chr(10).join([gap.missing_info for gap in user_representation_response.knowledge_gaps])}
RECENT UPDATES:
{chr(10).join([update.detail for update in user_representation_response.updates.new_information])}
"""
else:
# Fallback to XML parsing for backwards compatibility
representation = parse_xml_content(
str(user_representation_response), "representation"
)
logger.debug(f"Extracted representation: {len(representation)} characters")
else:
representation = f"""

View File

@ -1,6 +1,6 @@
import logging
from pathlib import Path
from typing import Annotated, Any, ClassVar, Optional
from typing import Annotated, Any, ClassVar
import tomllib
from dotenv import load_dotenv
@ -14,6 +14,8 @@ from pydantic_settings import (
SettingsConfigDict,
)
from src.utils.types import Providers
# Load .env file for local development.
# Make sure this is called before AppSettings is instantiated if you rely on .env for AppSettings construction.
load_dotenv(override=True)
@ -96,7 +98,7 @@ class HonchoSettings(BaseSettings):
"""
@classmethod
def settings_customise_sources(
def settings_customise_sources( # pyright: ignore
cls,
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
@ -115,7 +117,7 @@ class HonchoSettings(BaseSettings):
class DBSettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="DB_")
model_config = SettingsConfigDict(env_prefix="DB_") # pyright: ignore
CONNECTION_URI: str = (
"postgresql+psycopg://postgres:postgres@localhost:5432/postgres"
@ -137,12 +139,12 @@ class DBSettings(HonchoSettings):
class AuthSettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="AUTH_")
model_config = SettingsConfigDict(env_prefix="AUTH_") # pyright: ignore
USE_AUTH: bool = False
JWT_SECRET: Optional[str] = None # Must be set if USE_AUTH is true
JWT_SECRET: str | None = None # Must be set if USE_AUTH is true
@model_validator(mode="after")
@model_validator(mode="after") # type: ignore
def _require_jwt_secret(self) -> "AuthSettings":
if self.USE_AUTH and not self.JWT_SECRET:
raise ValueError("JWT_SECRET must be set if USE_AUTH is true")
@ -150,41 +152,47 @@ class AuthSettings(HonchoSettings):
class SentrySettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="SENTRY_")
model_config = SettingsConfigDict(env_prefix="SENTRY_") # pyright: ignore
ENABLED: bool = False
DSN: Optional[str] = None
DSN: str | None = None
TRACES_SAMPLE_RATE: Annotated[float, Field(default=0.1, ge=0.0, le=1.0)] = 0.1
PROFILES_SAMPLE_RATE: Annotated[float, Field(default=0.1, ge=0.0, le=1.0)] = 0.1
class LLMSettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="LLM_")
model_config = SettingsConfigDict(env_prefix="LLM_") # pyright: ignore
# API Keys for LLM providers
ANTHROPIC_API_KEY: Optional[str] = None
OPENAI_API_KEY: Optional[str] = None
OPENAI_COMPATIBLE_API_KEY: Optional[str] = None
GEMINI_API_KEY: Optional[str] = None
GROQ_API_KEY: Optional[str] = None # Added missing GROQ API key
OPENAI_COMPATIBLE_BASE_URL: Optional[str] = None
ANTHROPIC_API_KEY: str | None = None
OPENAI_API_KEY: str | None = None
OPENAI_COMPATIBLE_API_KEY: str | None = None
GEMINI_API_KEY: str | None = None
GROQ_API_KEY: str | None = None
OPENAI_COMPATIBLE_BASE_URL: str | None = None
# General LLM settings
DEFAULT_MAX_TOKENS: Annotated[int, Field(default=1000, gt=0, le=100000)] = 1000
DEFAULT_TEMPERATURE: Annotated[float, Field(default=0.0, ge=0.0, le=2.0)] = 0.0
# Dialectic specific
DIALECTIC_PROVIDER: str = "anthropic"
DIALECTIC_MODEL: str = "claude-3-haiku-20240307"
DIALECTIC_PROVIDER: Providers = "anthropic"
DIALECTIC_MODEL: str = "claude-3-5-haiku-20241022"
# DIALECTIC_SYSTEM_PROMPT_FILE: Optional[str] = "prompts/dialectic_system.txt" # Example for file-based
# Query Generation specific
QUERY_GENERATION_PROVIDER: str = "groq"
QUERY_GENERATION_MODEL: str = "llama3-8b-8192"
QUERY_GENERATION_PROVIDER: Providers = "google"
QUERY_GENERATION_MODEL: str = "gemini-2.0-flash-lite"
# QUERY_GENERATION_SYSTEM_PROMPT_FILE: Optional[str] = "prompts/query_generation_system.txt"
# Tom Inference specific
# TOM_INFERENCE_PROVIDER: Providers = "groq"
# TOM_INFERENCE_MODEL: str = "llama-3.3-70b-versatile"
TOM_INFERENCE_PROVIDER: Providers = "anthropic"
TOM_INFERENCE_MODEL: str = "claude-3-5-haiku-20241022"
# Summarization specific
SUMMARY_PROVIDER: str = "gemini"
SUMMARY_PROVIDER: Providers = "google"
SUMMARY_MODEL: str = (
"gemini-1.5-flash-latest" # Consider specific model version if needed
)
@ -195,7 +203,7 @@ class LLMSettings(HonchoSettings):
class AgentSettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="AGENT_")
model_config = SettingsConfigDict(env_prefix="AGENT_") # pyright: ignore
SEMANTIC_SEARCH_TOP_K: Annotated[int, Field(default=10, gt=0, le=100)] = 10
SEMANTIC_SEARCH_MAX_DISTANCE: Annotated[
@ -205,7 +213,7 @@ class AgentSettings(HonchoSettings):
class DeriverSettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="DERIVER_")
model_config = SettingsConfigDict(env_prefix="DERIVER_") # pyright: ignore
WORKERS: Annotated[int, Field(default=1, gt=0, le=100)] = 1
STALE_SESSION_TIMEOUT_MINUTES: Annotated[int, Field(default=5, gt=0, le=1440)] = (
@ -219,7 +227,7 @@ class DeriverSettings(HonchoSettings):
class HistorySettings(HonchoSettings):
model_config = SettingsConfigDict(env_prefix="HISTORY_")
model_config = SettingsConfigDict(env_prefix="HISTORY_") # pyright: ignore
MESSAGES_PER_SHORT_SUMMARY: Annotated[int, Field(default=20, gt=0, le=100)] = 20
MESSAGES_PER_LONG_SUMMARY: Annotated[int, Field(default=60, gt=0, le=500)] = 60
@ -227,7 +235,9 @@ class HistorySettings(HonchoSettings):
class AppSettings(HonchoSettings):
# No env_prefix for app-level settings
model_config = SettingsConfigDict(env_prefix="", env_nested_delimiter="__")
model_config = SettingsConfigDict( # pyright: ignore
env_prefix="", env_nested_delimiter="__"
)
# Application-wide settings
LOG_LEVEL: str = "INFO"

View File

@ -1,13 +1,14 @@
import os
from collections.abc import Sequence
from logging import getLogger
from typing import Optional
from typing import Any, final
from dotenv import load_dotenv
from nanoid import generate as generate_nanoid
from openai import AsyncOpenAI
from sqlalchemy import Select, cast, func, insert, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.engine import Row
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.types import BigInteger
@ -17,11 +18,25 @@ from . import models, schemas
from .exceptions import (
ResourceNotFoundException,
)
from .utils.model_client import ModelClient, ModelProvider
load_dotenv(override=True)
openai_client = AsyncOpenAI(api_key=settings.LLM.OPENAI_API_KEY)
@final
class EmbeddingClient:
def __init__(self, api_key: str | None):
if api_key is None:
raise ValueError("API key is required")
self.client = AsyncOpenAI(api_key=api_key)
async def embed(self, query: str) -> list[float]:
response = await self.client.embeddings.create(
input=query, model="text-embedding-3-small"
)
return response.data[0].embedding
embedding_client = EmbeddingClient(settings.LLM.OPENAI_API_KEY)
logger = getLogger(__name__)
@ -29,10 +44,6 @@ USER_REPRESENTATION_METADATA_KEY = "user_representation"
SESSION_PEERS_LIMIT = int(os.getenv("SESSION_PEERS_LIMIT", 10))
# Create a ModelClient instance for embeddings
# Using OpenAI provider for embeddings as it's the most common
embedding_client = ModelClient(provider=ModelProvider.OPENAI)
########################################################
# workspace methods
########################################################
@ -77,8 +88,8 @@ async def get_or_create_workspace(
async def get_all_workspaces(
filter: Optional[dict] = None,
) -> Select:
filter: dict[str, Any] | None = None,
) -> Select[tuple[models.Workspace]]:
"""
Get all workspaces.
@ -89,7 +100,7 @@ async def get_all_workspaces(
stmt = select(models.Workspace)
if filter is not None:
stmt = stmt.where(models.Workspace.h_metadata.contains(filter))
stmt = stmt.order_by(models.Workspace.created_at)
stmt: Select[tuple[models.Workspace]] = stmt.order_by(models.Workspace.created_at)
return stmt
@ -231,8 +242,8 @@ async def get_peer(
async def get_peers(
workspace_name: str,
filter: Optional[dict] = None,
) -> Select:
filter: dict[str, str] | None = None,
) -> Select[tuple[models.Peer]]:
stmt = select(models.Peer).where(models.Peer.workspace_name == workspace_name)
if filter is not None:
@ -283,9 +294,9 @@ async def update_peer(
async def get_sessions_for_peer(
workspace_name: str,
peer_name: str,
is_active: Optional[bool] = None,
filter: Optional[dict] = None,
) -> Select:
is_active: bool | None = None,
filter: dict[str, Any] | None = None,
) -> Select[tuple[models.Session]]:
"""
Get all sessions for a peer through the session_peers relationship.
@ -315,7 +326,7 @@ async def get_sessions_for_peer(
if filter is not None:
stmt = stmt.where(models.Session.h_metadata.contains(filter))
stmt = stmt.order_by(models.Session.created_at)
stmt: Select[tuple[models.Session]] = stmt.order_by(models.Session.created_at)
return stmt
@ -327,9 +338,9 @@ async def get_sessions_for_peer(
async def get_sessions(
workspace_name: str,
is_active: Optional[bool] = False,
filter: Optional[dict] = None,
) -> Select:
is_active: bool | None = None,
filter: dict[str, Any] | None = None,
) -> Select[tuple[models.Session]]:
"""
Get all sessions in a workspace.
"""
@ -540,7 +551,7 @@ async def clone_session(
db: AsyncSession,
workspace_name: str,
original_session_name: str,
cutoff_message_id: Optional[str] = None,
cutoff_message_id: str | None = None,
) -> models.Session:
"""
Clone a session and its messages. If cutoff_message_id is provided,
@ -692,7 +703,7 @@ async def remove_peers_from_session(
async def get_peers_from_session(
workspace_name: str,
session_name: str,
) -> Select:
) -> Select[tuple[models.Peer]]:
"""
Get all peers from a session.
@ -719,7 +730,7 @@ async def get_peers_from_session(
async def get_session_peer_configuration(
workspace_name: str,
session_name: str,
) -> Select:
) -> Select[tuple[str, dict[str, Any], dict[str, Any]]]:
"""
Get configuration from both SessionPeer and Peer tables for active peers in a session.
@ -730,7 +741,7 @@ async def get_session_peer_configuration(
Returns:
Select statement returning peer_name, peer_configuration, and session_peer_configuration
"""
stmt = (
stmt: Select[tuple[str, dict[str, Any], dict[str, Any]]] = (
select(
models.Peer.name.label("peer_name"),
models.Peer.configuration.label("peer_configuration"),
@ -988,9 +999,9 @@ async def search(
query: str,
*,
workspace_name: str,
session_name: Optional[str] = None,
peer_name: Optional[str] = None,
) -> Select:
session_name: str | None = None,
peer_name: str | None = None,
) -> Select[tuple[models.Message]]:
"""
Search across message content using a hybrid approach:
- Uses PostgreSQL full text search for natural language queries
@ -1249,11 +1260,11 @@ async def create_messages_for_peer(
async def get_messages(
workspace_name: str,
session_name: str,
reverse: Optional[bool] = False,
filter: Optional[dict] = None,
token_limit: Optional[int] = None,
message_count_limit: Optional[int] = None,
) -> Select:
reverse: bool | None = False,
filter: dict[str, Any] | None = None,
token_limit: int | None = None,
message_count_limit: int | None = None,
) -> Select[tuple[models.Message]]:
"""
Get messages from a session. If token_limit is provided, the n most recent messages
with token count adding up to the limit will be returned. If message_count_limit is provided,
@ -1338,7 +1349,7 @@ async def get_messages_id_range(
session_name: str | None,
peer_name: str | None,
start_id: int = 0,
end_id: Optional[int] = None,
end_id: int | None = None,
) -> list[models.Message]:
"""
Get messages from a session or peer by primary key ID range.
@ -1386,9 +1397,9 @@ async def get_messages_id_range(
async def get_messages_for_peer(
workspace_name: str,
peer_name: str,
reverse: Optional[bool] = False,
filter: Optional[dict] = None,
) -> Select:
reverse: bool | None = False,
filter: dict[str, Any] | None = None,
) -> Select[tuple[models.Message]]:
stmt = (
select(models.Message)
.where(models.Message.workspace_name == workspace_name)
@ -1412,7 +1423,7 @@ async def get_message(
workspace_name: str,
session_name: str,
message_id: str,
) -> Optional[models.Message]:
) -> models.Message | None:
stmt = (
select(models.Message)
.where(models.Message.workspace_name == workspace_name)
@ -1520,8 +1531,8 @@ async def query_documents(
peer_name: str,
collection_name: str,
query: str,
filter: Optional[dict] = None,
max_distance: Optional[float] = None,
filter: dict[str, Any] | None = None,
max_distance: float | None = None,
top_k: int = 5,
) -> Sequence[models.Document]:
# Using ModelClient for embeddings
@ -1552,7 +1563,7 @@ async def create_document(
workspace_name: str,
peer_name: str,
collection_name: str,
duplicate_threshold: Optional[float] = None,
duplicate_threshold: float | None = None,
) -> models.Document:
"""
Embed text as a vector and create a document.
@ -1665,8 +1676,8 @@ async def get_duplicate_documents(
async def get_deriver_status(
db: AsyncSession,
workspace_name: str,
peer_name: Optional[str] = None,
session_name: Optional[str] = None,
peer_name: str | None = None,
session_name: str | None = None,
include_sender: bool = False,
) -> schemas.DeriverStatus:
"""
@ -1706,10 +1717,10 @@ async def get_deriver_status(
def _build_queue_status_query(
workspace_name: str,
peer_name: Optional[str],
session_name: Optional[str],
peer_name: str | None,
session_name: str | None,
include_sender: bool,
):
) -> Select[Any]:
"""Build SQL query for queue status with validation and aggregation."""
from sqlalchemy import case, func
@ -1788,72 +1799,77 @@ def _build_queue_status_query(
return stmt
def _process_queue_rows(rows):
def _process_queue_rows(rows: Sequence[Row[Any]]) -> schemas.QueueCounts:
"""Process query results that already contain aggregated counts."""
if not rows:
return {
"total": 0,
"completed": 0,
"in_progress": 0,
"pending": 0,
"sessions": {},
}
return schemas.QueueCounts(
total=0,
completed=0,
in_progress=0,
pending=0,
sessions={},
)
# Since we're using window functions, all rows have the same overall totals
# We just need the first row for overall counts
first_row = rows[0]
# Build sessions dictionary from unique session_ids
sessions = {}
seen_sessions = set()
sessions: dict[str, schemas.SessionCounts] = {}
seen_sessions: set[str] = set()
for row in rows:
if row.session_id and row.session_id not in seen_sessions:
sessions[row.session_id] = {
"completed": row.session_completed,
"in_progress": row.session_in_progress,
"pending": row.session_pending,
}
sessions[row.session_id] = schemas.SessionCounts(
completed=row.session_completed,
in_progress=row.session_in_progress,
pending=row.session_pending,
)
seen_sessions.add(row.session_id)
return {
"total": first_row.total,
"completed": first_row.completed,
"in_progress": first_row.in_progress,
"pending": first_row.pending,
"sessions": sessions,
}
return schemas.QueueCounts(
total=first_row.total,
completed=first_row.completed,
in_progress=first_row.in_progress,
pending=first_row.pending,
sessions=sessions,
)
def _build_status_response(
peer_name: Optional[str], session_name: Optional[str], counts: dict
):
peer_name: str | None, session_name: str | None, counts: schemas.QueueCounts
) -> schemas.DeriverStatus:
"""Build the final response object."""
base_response = {
"peer_id": peer_name,
"total_work_units": counts["total"],
"completed_work_units": counts["completed"],
"in_progress_work_units": counts["in_progress"],
"pending_work_units": counts["pending"],
}
if session_name:
return schemas.DeriverStatus(session_id=session_name, **base_response)
return schemas.DeriverStatus(
session_id=session_name,
peer_id=peer_name,
total_work_units=counts.total,
completed_work_units=counts.completed,
in_progress_work_units=counts.in_progress,
pending_work_units=counts.pending,
)
sessions = {}
for session_id, data in counts["sessions"].items():
total = data["completed"] + data["in_progress"] + data["pending"]
sessions: dict[str, schemas.DeriverStatus] = {}
for session_id, data in counts.sessions.items():
total = data.completed + data.in_progress + data.pending
sessions[session_id] = schemas.DeriverStatus(
peer_id=peer_name,
session_id=session_id,
total_work_units=total,
completed_work_units=data["completed"],
in_progress_work_units=data["in_progress"],
pending_work_units=data["pending"],
completed_work_units=data.completed,
in_progress_work_units=data.in_progress,
pending_work_units=data.pending,
)
return schemas.DeriverStatus(
sessions=sessions if sessions else None, **base_response
sessions=sessions if sessions else None,
peer_id=peer_name,
total_work_units=counts.total,
completed_work_units=counts.completed,
in_progress_work_units=counts.in_progress,
pending_work_units=counts.pending,
)

View File

@ -1,5 +1,4 @@
import contextvars
from typing import Optional
from sqlalchemy import MetaData, create_engine, text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
@ -11,7 +10,7 @@ from src.config import settings
connect_args = {"prepare_threshold": None}
# Context variable to store request context
request_context: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
request_context: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"request_context", default=None
)
@ -21,7 +20,7 @@ if settings.DB.POOL_CLASS == "null":
engine_kwargs["poolclass"] = NullPool
else:
# Only add pool-related kwargs for pooled connections
engine_kwargs.update(
engine_kwargs.update( # pyright: ignore
{
"pool_pre_ping": settings.DB.POOL_PRE_PING,
"pool_size": settings.DB.POOL_SIZE,

View File

@ -29,7 +29,7 @@ async def get_db():
@asynccontextmanager
async def tracked_db(operation_name=None):
async def tracked_db(operation_name: str | None = None):
"""Context manager for tracked database sessions"""
# Get request ID if available, or create operation-specific one
context = request_context.get()

View File

@ -1,9 +1,9 @@
import logging
import os
from typing import Literal
from typing import Any, Literal
import sentry_sdk
from langfuse.decorators import observe
from langfuse.decorators import observe # pyright: ignore
from pydantic import BaseModel, ValidationError
from rich.console import Console
from sqlalchemy.ext.asyncio import AsyncSession
@ -40,10 +40,10 @@ class PayloadSchema(BaseModel):
class Config:
# Forbid extra fields to prevent injection of unexpected data
extra = "forbid"
extra = "forbid" # pyright: ignore
async def process_item(db: AsyncSession, payload: dict):
async def process_item(db: AsyncSession, payload: dict[str, Any]):
# Validate payload structure and types before processing
try:
validated_payload = PayloadSchema(**payload)
@ -57,22 +57,21 @@ async def process_item(db: AsyncSession, payload: dict):
validated_payload.session_name,
)
processing_args = [
validated_payload.content,
validated_payload.workspace_name,
validated_payload.sender_name,
validated_payload.target_name,
validated_payload.session_name,
validated_payload.message_id,
db,
]
if validated_payload.task_type == "representation":
logger.debug(
"Processing message %s in %s",
validated_payload.message_id,
validated_payload.session_name,
)
await process_message(*processing_args)
await process_message(
validated_payload.content,
validated_payload.workspace_name,
validated_payload.sender_name,
validated_payload.target_name,
validated_payload.session_name,
validated_payload.message_id,
db,
)
logger.debug(
"Finished processing message %s in %s %s",
validated_payload.message_id,
@ -140,7 +139,8 @@ async def process_message(
# Extract facts from chat history
logger.debug("Extracting facts from chat history")
extract_start = os.times()[4]
facts = await extract_facts_long_term(chat_history_str)
fact_extraction = await extract_facts_long_term(chat_history_str)
facts: list[str] = fact_extraction.facts or []
extract_time = os.times()[4] - extract_start
console.print(f"Extracted Facts: {facts}", style="bright_blue")
logger.debug(f"Extracted {len(facts)} facts in {extract_time:.2f}s")

View File

@ -1,5 +1,6 @@
import asyncio
import signal
from _asyncio import Task
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
@ -45,14 +46,14 @@ class WorkUnit:
class QueueManager:
def __init__(self):
self.shutdown_event = asyncio.Event()
self.active_tasks: set[asyncio.Task] = set()
self.shutdown_event: asyncio.Event = asyncio.Event()
self.active_tasks: set[asyncio.Task[None]] = set()
self.owned_work_units: set[WorkUnit] = set()
self.queue_empty_flag = asyncio.Event()
self.queue_empty_flag: asyncio.Event = asyncio.Event()
# Initialize from settings
self.workers = settings.DERIVER.WORKERS
self.semaphore = asyncio.Semaphore(self.workers)
self.workers: int = settings.DERIVER.WORKERS
self.semaphore: asyncio.Semaphore = asyncio.Semaphore(self.workers)
# Initialize Sentry if enabled, using settings
if settings.SENTRY.ENABLED:
@ -64,7 +65,7 @@ class QueueManager:
integrations=[AsyncioIntegration()],
)
def add_task(self, task: asyncio.Task):
def add_task(self, task: asyncio.Task[None]):
"""Track a new task"""
self.active_tasks.add(task)
task.add_done_callback(self.active_tasks.discard)
@ -241,7 +242,7 @@ class QueueManager:
# Create a new task for processing this work unit
if not self.shutdown_event.is_set():
task = asyncio.create_task(
task: Task[None] = asyncio.create_task(
self.process_work_unit(work_unit)
)
self.add_task(task)

View File

@ -1,11 +1,17 @@
from .conversational import (
get_tom_inference_conversational,
get_user_representation_conversational,
tom_inference_conversational,
user_representation_conversational,
)
from .long_term import get_user_representation_long_term
from .single_prompt import (
get_tom_inference_single_prompt,
get_user_representation_single_prompt,
TomInferenceOutput,
UserRepresentationOutput,
)
from .single_prompt import (
tom_inference as tom_inference_single_prompt,
)
from .single_prompt import (
user_representation as user_representation_single_prompt,
)
@ -13,16 +19,12 @@ async def get_tom_inference(
chat_history: str,
user_representation: str = "None",
method: str = "conversational",
**kwargs,
) -> str:
) -> TomInferenceOutput:
if method == "conversational":
return await get_tom_inference_conversational(
chat_history, user_representation, **kwargs
)
return await tom_inference_conversational(chat_history, user_representation)
elif method == "single_prompt":
return await get_tom_inference_single_prompt(
chat_history, user_representation, **kwargs
)
return await tom_inference_single_prompt(chat_history, user_representation)
else:
raise ValueError(f"Invalid method: {method}")
@ -32,19 +34,18 @@ async def get_user_representation(
user_representation: str = "None",
tom_inference: str = "None",
method: str = "conversational",
**kwargs,
) -> str:
) -> UserRepresentationOutput:
if method == "conversational":
return await get_user_representation_conversational(
chat_history, user_representation, tom_inference, **kwargs
return await user_representation_conversational(
chat_history, user_representation, tom_inference
)
elif method == "single_prompt":
return await get_user_representation_single_prompt(
chat_history, user_representation, tom_inference, **kwargs
return await user_representation_single_prompt(
chat_history, user_representation, tom_inference
)
elif method == "long_term":
return await get_user_representation_long_term(
chat_history, user_representation, tom_inference, **kwargs
chat_history, user_representation, tom_inference
)
else:
raise ValueError(f"Invalid method: {method}")

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,5 @@
import logging
from typing import Any
from ... import crud, schemas
from ...dependencies import tracked_db
@ -8,14 +9,13 @@ logger = logging.getLogger(__name__)
class CollectionEmbeddingStore:
def __init__(self, workspace_name: str, peer_name: str, collection_name: str):
self.workspace_name = workspace_name
self.peer_name = peer_name
self.collection_name = collection_name
self.workspace_name: str = workspace_name
self.peer_name: str = peer_name
self.collection_name: str = collection_name
async def save_facts(
self,
facts: list[str],
replace_duplicates: bool = True,
similarity_threshold: float = 0.85,
message_id: int | None = None,
) -> None:
@ -30,7 +30,7 @@ class CollectionEmbeddingStore:
for fact in facts:
# Create document with duplicate checking
try:
metadata = {}
metadata: dict[str, Any] = {}
if message_id is not None:
metadata["message_id"] = message_id
await crud.create_document(
@ -86,7 +86,7 @@ class CollectionEmbeddingStore:
Returns:
List of facts that are not duplicates of existing facts
"""
unique_facts = []
unique_facts: list[str] = []
async with tracked_db("embedding_store.remove_duplicates") as db:
for fact in facts:

View File

@ -1,41 +1,70 @@
import json
import logging
import time
from typing import Optional
from langfuse.decorators import observe
from sentry_sdk.ai.monitoring import ai_track
from mirascope import llm
from mirascope.integrations.langfuse import with_langfuse
from pydantic import BaseModel
from src.utils import parse_xml_content
from src.utils.model_client import ModelClient, ModelProvider
from src.config import settings
from src.deriver.tom.single_prompt import UserRepresentationOutput
from src.utils.clients import clients
from src.utils.types import track
# Configure logging
logger = logging.getLogger(__name__)
# Constants for fact extraction
FACT_EXTRACTION_PROVIDER = ModelProvider.GEMINI
FACT_EXTRACTION_MODEL = "gemini-2.0-flash-lite"
USER_REPRESENTATION_PROVIDER = ModelProvider.GROQ
USER_REPRESENTATION_MODEL = "llama-3.3-70b-versatile"
MAX_FACT_DISTANCE = 0.85
class PotentialSurprise(BaseModel):
content: str
reason: str
confidence_level: float
@ai_track("User Representation")
@observe()
class UserRepresentation(BaseModel):
current_state: str
tentative_patterns: list[str]
knowledge_gaps: list[str]
expectation_violations: list[PotentialSurprise]
updates: list[str]
class InformationPiece(BaseModel):
quote: str
category: str
explanation: str
semantic_retrieval: str
class InformationExtraction(BaseModel):
pieces: list[InformationPiece]
challenge: str
class FactExtraction(BaseModel):
information_extraction: InformationExtraction
facts: list[str]
@track("User Representation")
@with_langfuse()
@llm.call(
provider=(
settings.LLM.TOM_INFERENCE_PROVIDER
if settings.LLM.TOM_INFERENCE_PROVIDER != "custom"
else "openai"
),
model=settings.LLM.TOM_INFERENCE_MODEL,
response_model=UserRepresentationOutput,
client=clients[settings.LLM.TOM_INFERENCE_PROVIDER],
)
async def get_user_representation_long_term(
chat_history: str,
user_representation: str = "None",
tom_inference: str = "None",
facts: Optional[list[str]] = None,
) -> str:
if facts is None:
facts = []
facts_str = "\n".join([f"- {fact}" for fact in facts])
logger.debug(f"Facts: {facts_str}")
system_prompt = """You are a system for maintaining factual user representations based on conversation history and theory of mind analysis.
facts: list[str] | None = None,
):
facts_str = "\n".join(f"- {fact}" for fact in facts) if facts else "None available"
return f"""
You are a system for maintaining factual user representations based on conversation history and theory of mind analysis.
Your job is to update the existing user representation (if provided) with the new information from the conversation history and theory of mind analysis.
@ -45,82 +74,60 @@ REQUIREMENTS:
3. Track certainty levels for all information
4. Maintain areas of uncertainty explicitly
5. Update representation incrementally
6. DO NOT generate persistent information - it will be injected separately. Always include the <KNOWN_FACTS> tag in your response in order to inject the facts.
6. Use the persistent facts provided to inform your understanding of the user
OUTPUT FORMAT:
<representation>
CURRENT STATE:
- Active Context: Current situation/activity
- Temporary Conditions: Immediate circumstances
<CURRENT_CURSOR_POSITION>
- Present Mood/Activity: What user is doing right now
current_state: str
- Active Context: Current situation/activity
- Temporary Conditions: Immediate circumstances
- Present Mood/Activity: What user is doing right now
tentative_patterns: list[str]
- Possible Traits: Mark confidence (Low/Medium/High)
- Potential Interests: Need more evidence
- Speculative Elements: Clearly marked as unconfirmed
knowledge_gaps: list[str]
- List key missing information
- Note areas needing clarification
expectation_violations: list
content: str
reason: str
confidence_level: float
- Based on the above information, if the next message were to surprise you, what could it contain?
- Include 3-5 possible surprises
updates: list[str]
- New Information: Recent observations
- Changes: Modified interpretations
- Removals: Information no longer supported
<KNOWN_FACTS>
CONVERSATION:
{chat_history}
TENTATIVE PATTERNS:
- Possible Traits: Mark confidence (Low/Medium/High)
- Potential Interests: Need more evidence
- Speculative Elements: Clearly marked as unconfirmed
PREDICTION OF USER MENTAL STATE - MIGHT BE INCORRECT:
{tom_inference or "Doesn't exist"}
KNOWLEDGE GAPS:
- List key missing information
- Note areas needing clarification
EXISTING USER REPRESENTATION - INCOMPLETE, TO BE UPDATED:
{user_representation or "Doesn't exist"}
EXPECTATION VIOLATIONS:
- Based on the above information, if the next message were to surprise you, what could it contain?
- Format: "POTENTIAL SURPRISE: [possible content] [reason] [confidence level]"
- Include 3-5 possible surprises
UPDATES:
- New Information: Recent observations
- Changes: Modified interpretations
- Removals: Information no longer supported
</representation>
PERSISTENT FACTS ABOUT USER:
{facts_str}
"""
# Build the context message
context_str = f"CONVERSATION:\n{chat_history}\n\n"
if tom_inference != "None":
context_str += f"PREDICTION OF USER MENTAL STATE - MIGHT BE INCORRECT:\n{tom_inference}\n\n"
if user_representation != "None":
context_str += f"EXISTING USER REPRESENTATION - INCOMPLETE, TO BE UPDATED:\n{user_representation}"
messages = [
{
"role": "user",
"content": f"Please analyze this information and provide an updated user representation. DO NOT generate persistent information - it will be injected separately:\n{context_str}",
}
]
# Create a new model client
client = ModelClient(
provider=USER_REPRESENTATION_PROVIDER, model=USER_REPRESENTATION_MODEL
)
# Generate the response with caching enabled
response = await client.generate(
messages=messages,
system=system_prompt,
max_tokens=1000,
temperature=0,
use_caching=True, # Enable caching for the system prompt
)
# Inject the facts into the response
persistent_info = f"""PERSISTENT INFORMATION:
{facts_str}"""
return response.replace("<KNOWN_FACTS>", persistent_info)
@ai_track("Fact Extraction")
@observe()
async def extract_facts_long_term(chat_history: str) -> list[str]:
logger.debug("Starting fact extraction from chat history")
extract_start = time.time()
system_prompt = """
You are an AI assistant specialized in extracting and formatting relevant information about users from conversations. Your task is to analyze a given conversation and create a list of concise, factual statements about the user. These statements will be stored in a vector embedding database to enhance future interactions.
@track("Fact Extraction")
@with_langfuse()
@llm.call(
provider=(
settings.LLM.TOM_INFERENCE_PROVIDER
if settings.LLM.TOM_INFERENCE_PROVIDER != "custom"
else "openai"
),
model=settings.LLM.TOM_INFERENCE_MODEL,
response_model=FactExtraction,
client=clients[settings.LLM.TOM_INFERENCE_PROVIDER],
)
async def extract_facts_long_term(chat_history: str):
return f"""
You are an AI assistant specialized in extracting and formatting relevant information about users from conversations. Your task is to analyze a given conversation and create a list of concise, factual statements about the user. These statements will be stored in a vector embedding database to enhance future interactions.
Here is the conversation you need to analyze:
@ -132,22 +139,22 @@ Instructions:
1. Carefully read through the conversation. Extract only new facts, from only the last message sent by the user - treat the rest of the conversation only as context. Ignore facts in the last message that are already stated in the conversation.
2. Identify key new pieces of information from the last message sent by the user that would be valuable for future interactions. Look for:
- Personal details (name, age, occupation, location, etc.)
- Preferences (likes, dislikes, interests, hobbies)
- Experiences (travel, education, work history)
- Expressive style (writing style, tone, etc.)
- Relationships (family, friends, pets)
- Goals or aspirations
- Challenges or problems they're facing
- Opinions or beliefs
2. Identify key new pieces of information from the last message sent by the user that would be valuable for future interactions. Look for:
- Personal details (name, age, occupation, location, etc.)
- Preferences (likes, dislikes, interests, hobbies)
- Experiences (travel, education, work history)
- Expressive style (writing style, tone, etc.)
- Relationships (family, friends, pets)
- Goals or aspirations
- Challenges or problems they're facing
- Opinions or beliefs
3. For each piece of information you identify:
a. Verify that it is factual and explicitly stated in the conversation, not inferred.
b. Formulate it as a concise statement that would aid in semantic retrieval.
c. Ensure it is not similar to information previously stated in the conversation.
4. Before providing your final output, wrap your analysis in <information_extraction> tags. In this analysis:
4. Before providing your final output, wrap your analysis in information_extraction. In this analysis:
- List each piece of information you've identified.
- For each piece of information:
* Quote the relevant part of the conversation.
@ -156,57 +163,7 @@ Instructions:
* Show how you've formulated the fact for optimal semantic retrieval.
- Discuss any challenges you encountered in extracting or formatting the information.
5. After your analysis, provide your final output as a JSON array of strings. Each string should be a single fact about the user. Wrap the facts in <facts> tags.
5. After your analysis, provide your final output as a list of strings. Each string should be a single fact about the user.
Example of the expected output format:
<information_extraction>
[Analysis goes here]
</information_extraction>
<facts>
{{
"facts":
[
"User is 28 years old",
"User's friend Mary works as a software engineer",
"Favorite food is sushi"
]
}}
</facts>
Remember to focus on clear, concise statements that capture key information about the user. Each fact should be worded in a way that will aid its semantic retrieval from a vector embedding database. It's OK for this section to be quite long.
"""
message = system_prompt.format(chat_history=chat_history)
messages = [{"role": "user", "content": message}]
logger.debug("Calling LLM for fact extraction")
llm_start = time.time()
# Create a new model client
client = ModelClient(provider=FACT_EXTRACTION_PROVIDER, model=FACT_EXTRACTION_MODEL)
# Generate the response with caching enabled
response = await client.generate(
messages=messages,
max_tokens=1000,
temperature=0.0,
use_caching=True, # Enable caching for the system prompt
)
llm_time = time.time() - llm_start
logger.debug(f"LLM response received in {llm_time:.2f}s")
try:
logger.debug("Parsing JSON response")
facts_str = parse_xml_content(response, "facts")
response_data = json.loads(facts_str)
facts = response_data["facts"]
logger.debug(f"Extracted {len(facts)} facts")
if facts:
logger.debug(f"Sample facts: {facts[:3] if len(facts) > 3 else facts}")
except (json.JSONDecodeError, KeyError) as e:
logger.error(f"Error parsing response: {str(e)}")
facts = []
total_time = time.time() - extract_start
logger.debug(f"Total extraction completed in {total_time:.2f}s")
return facts
Remember to focus on clear, concise statements that capture key information about the user. Each fact should be worded in a way that will aid its semantic retrieval from a vector embedding database.
"""

View File

@ -1,18 +1,130 @@
import logging
from typing import Any, Optional
from enum import Enum
import sentry_sdk
from langfuse.decorators import langfuse_context, observe
from sentry_sdk.ai.monitoring import ai_track
from mirascope import llm
from mirascope.integrations.langfuse import with_langfuse
from pydantic import BaseModel
from src.utils.model_client import ModelClient, ModelProvider
from src.config import settings
from src.utils.clients import clients
from src.utils.types import track
logger = logging.getLogger(__name__)
DEF_PROVIDER = ModelProvider.GROQ
DEF_MODEL = "llama-3.3-70b-versatile"
TOM_SYSTEM_PROMPT = """You are a system for analyzing conversations to make evidence-based inferences about user mental states.
# Enums for strongly typed fields
class InfoType(str, Enum):
STYLE = "STYLE"
STATEMENT = "STATEMENT"
class CertaintyLevel(str, Enum):
LIKELY = "LIKELY"
POTENTIAL = "POTENTIAL"
SPECULATIVE = "SPECULATIVE"
# ToM Inference Output Models
class CurrentState(BaseModel):
immediate_context: str
active_goals: str
present_mood: str
class SupportedObservation(BaseModel):
detail: str
source: str
class TentativeInference(BaseModel):
interpretation: str
basis: str
class KnowledgeGap(BaseModel):
topic: str
class ExpectationViolation(BaseModel):
possible_surprise: str
reason: str
confidence_level: float
class TomInferenceOutput(BaseModel):
current_state: CurrentState
tentative_inferences: list[TentativeInference]
knowledge_gaps: list[KnowledgeGap]
expectation_violations: list[ExpectationViolation]
# User Representation Output Models
class SourcedInfo(BaseModel):
detail: str
source: str
class UserCurrentState(BaseModel):
active_context: SourcedInfo
temporary_conditions: SourcedInfo
present_mood_activity: SourcedInfo
class PersistentInfo(BaseModel):
detail: str
source: str
info_type: InfoType
class TentativePattern(BaseModel):
pattern: str
source: str
certainty_level: CertaintyLevel
class UserKnowledgeGap(BaseModel):
missing_info: str
class UserExpectationViolation(BaseModel):
potential_surprise: str
reason: str
confidence_level: float
class UpdateSection(BaseModel):
new_information: list[SourcedInfo]
changes: list[SourcedInfo]
removals: list[SourcedInfo]
class UserRepresentationOutput(BaseModel):
current_state: UserCurrentState
persistent_information: list[PersistentInfo]
tentative_patterns: list[TentativePattern]
knowledge_gaps: list[UserKnowledgeGap]
expectation_violations: list[UserExpectationViolation]
updates: UpdateSection
@track("Tom Inference")
@with_langfuse()
@llm.call(
provider=(
settings.LLM.TOM_INFERENCE_PROVIDER
if settings.LLM.TOM_INFERENCE_PROVIDER != "custom"
else "openai"
),
model=settings.LLM.TOM_INFERENCE_MODEL,
response_model=TomInferenceOutput,
client=clients[settings.LLM.TOM_INFERENCE_PROVIDER],
)
async def tom_inference(
chat_history: str,
user_representation: str | None = None,
):
return f"""
You are a system for analyzing conversations to make evidence-based inferences about user mental states.
REQUIREMENTS:
1. Only make inferences that are directly supported by conversation evidence
@ -21,47 +133,60 @@ REQUIREMENTS:
4. Do not make assumptions about demographics unless explicitly stated
5. Focus on current mental state and immediate context
6. Consider your own knowledge gaps and violations of expectations (what would surprise you)
7. Always wrap your prediction in <prediction> tags.
OUTPUT FORMAT:
<prediction>
CURRENT STATE:
- Immediate Context: User's current situation
- Active Goals: What user is trying to achieve
- Present Mood: Observable emotional state
current_state:
- immediate_context: User's current situation
- active_goals: What user is trying to achieve
- present_mood: Observable emotional state
SUPPORTED OBSERVATIONS:
- List only behaviors/preferences with direct evidence
- Format: "OBSERVATION: [detail] (SOURCE: [exact message])"
tentative_inferences: list of objects with:
- interpretation: Possible but uncertain interpretation
- basis: Supporting message or evidence
TENTATIVE INFERENCES:
- List possible but uncertain interpretations
- Format: "POSSIBLE: [interpretation] (BASIS: [supporting message])"
knowledge_gaps: list of objects with:
- topic: Important unknown information or question
KNOWLEDGE GAPS:
- List important unknown information
- Format: "UNKNOWN: [topic/question]"
EXPECTATION VIOLATIONS:
- Based on the above information, if the next message were to surprise you, what could it contain?
- Format: "POTENTIAL SURPRISE: [possible content] [reason] [confidence level]"
expectation_violations: list of objects with:
- possible_surprise: What content could surprise you in the next message
- reason: Why this would be surprising based on current information
- confidence_level: Float between 0.0 and 1.0 indicating confidence
- Include 3-5 possible surprises
</prediction>"""
USER_REPRESENTATION_SYSTEM_PROMPT = """You are a system for maintaining factual user representations based on conversation history and theory of mind analysis.
<conversation>
{chat_history or "Not provided"}
</conversation>
<user_representation>
{user_representation or "Not provided"}
</user_representation>
"""
@track("User Representation")
@with_langfuse()
@llm.call(
provider=(
settings.LLM.TOM_INFERENCE_PROVIDER
if settings.LLM.TOM_INFERENCE_PROVIDER != "custom"
else "openai"
),
model=settings.LLM.TOM_INFERENCE_MODEL,
response_model=UserRepresentationOutput,
client=clients[settings.LLM.TOM_INFERENCE_PROVIDER],
)
async def user_representation(
chat_history: str,
user_representation: str | None = None,
tom_inference: str | None = None,
):
return f"""
You are a system for maintaining factual user representations based on conversation history and theory of mind analysis.
Your job is to update the existing user representation (if provided) with the new information from the conversation history and theory of mind analysis.
Copy over information as-is from the existing user representation. Add new information as needed. Only remove content from this section if new information contradicts it. This is especially important for Persistent Information and Tentative Patterns.
If the existing user representation contains sources, copy them over to the new user representation.
Sometimes, especially at the beginning of a conversation, the user representation might contain information from previous conversations. In this case, preserve all previous information unless new information contradicts it, especially in regards to traits, interests, style, and other information about the user that is likely to be useful across conversations.
Always use the format below. If the existing user representation is in another format, convert it to the format below.
Always wrap your output in <representation> tags.
REQUIREMENTS:
1. Distinguish between temporary states and persistent patterns
2. Only incorporate verified information into core profile
@ -70,127 +195,44 @@ REQUIREMENTS:
5. Update representation incrementally
OUTPUT FORMAT:
<representation>
CURRENT STATE:
- ACTIVE CONTEXT: [detail on situation/activity/location] (SOURCE: [exact message]) # Current situation/activity/location
- TEMPORARY CONDITIONS: [detail on immediate circumstances] (SOURCE: [exact message]) # Immediate circumstances
- PRESENT MOOD/ACTIVITY: [what user is doing right now] (SOURCE: [exact message]) # What user is doing right now
current_state:
- active_context: object with "detail" (current situation/activity/location) and "source" (exact message)
- temporary_conditions: object with "detail" (immediate circumstances) and "source" (exact message)
- present_mood_activity: object with "detail" (what user is doing right now) and "source" (exact message)
PERSISTENT INFORMATION:
- STYLE: [pattern] (SOURCE: [exact message]) # Communication style: observed patterns in language use
- STATEMENT: [fact] (SOURCE: [exact message]) # Explicitly stated information
persistent_information: list of objects with:
- detail: The specific information or pattern
- source: Exact message that supports this
- info_type: Must be exactly "STYLE" for communication patterns or "STATEMENT" for explicit facts
TENTATIVE PATTERNS:
- LIKELY PATTERN: [pattern] (SOURCE: [exact message]) # Patterns that are almost certain to be true
- POTENTIAL PATTERN: [pattern] (SOURCE: [exact message]) # Patterns that are possible but less likely
- SPECULATIVE PATTERN: [pattern] (SOURCE: [exact message]) # Patterns that are highly uncertain but remotely possible
tentative_patterns: list of objects with:
- pattern: The observed pattern
- source: Supporting evidence from specific message
- certainty_level: Must be exactly "LIKELY" (almost certain), "POTENTIAL" (possible), or "SPECULATIVE" (uncertain)
KNOWLEDGE GAPS:
- List key missing information
- Note areas needing clarification
knowledge_gaps: list of objects with:
- missing_info: Key information that is missing or needs clarification
EXPECTATION VIOLATIONS:
- Based on the above information, if the next message were to surprise you, what could it contain?
- Format: "POTENTIAL SURPRISE: [possible content] [reason] [confidence level]"
expectation_violations: list of objects with:
- potential_surprise: What could surprise you in the next message
- reason: Why this would be surprising based on current information
- confidence_level: Float between 0.0 and 1.0
- Include 3-5 possible surprises
UPDATES:
- New Information: Recent observations
- Changes: Modified interpretations
- Removals: Information no longer supported
</representation>"""
updates:
- new_information: List of objects with "detail" (recent observation) and "source" (supporting message)
- changes: List of objects with "detail" (modified interpretation) and "source" (supporting message)
- removals: List of objects with "detail" (information no longer supported) and "source" (contradicting message)
<conversation>
{chat_history or "Not provided"}
</conversation>
@ai_track("Tom Inference")
@observe()
async def get_tom_inference_single_prompt(
chat_history: str,
user_representation: Optional[str] = None,
**kwargs,
) -> str:
with sentry_sdk.start_transaction(op="tom-inference", name="ToM Inference"):
# Create a new model client
client = ModelClient(provider=DEF_PROVIDER, model=DEF_MODEL)
<existing_user_representation>
{user_representation or "Not provided"}
</existing_user_representation>
# Prepare the messages
messages: list[dict[str, Any]] = [
{
"role": "user",
"content": f"Please analyze this conversation and provide a prediction following the format above:\n{chat_history}",
}
]
# Add existing user representation if available
if user_representation:
messages.append(
{
"role": "user",
"content": f"Consider this existing user representation for context, but focus on current state:\n{user_representation}",
}
)
langfuse_context.update_current_observation(input=messages, model=DEF_MODEL)
# Generate the response with caching enabled
try:
response = await client.generate(
messages=messages,
system=TOM_SYSTEM_PROMPT,
max_tokens=1000,
temperature=0,
use_caching=True, # Enable caching for the system prompt
)
except Exception as e:
sentry_sdk.capture_exception(e)
logger.error(f"Error generating Tom inference: {e}")
raise e
return response
@ai_track("User Representation")
@observe()
async def get_user_representation_single_prompt(
chat_history: str,
user_representation: Optional[str] = None,
tom_inference: Optional[str] = None,
**kwargs,
) -> str:
with sentry_sdk.start_transaction(
op="user-representation-inference", name="User Representation"
):
# Create a new model client
client = ModelClient(provider=DEF_PROVIDER, model=DEF_MODEL)
# Build the context message
context_str = f"CONVERSATION:\n{chat_history}\n\n"
if tom_inference:
context_str += f"PREDICTION OF USER MENTAL STATE - MIGHT BE INCORRECT:\n{tom_inference}\n\n"
if user_representation:
context_str += f"EXISTING USER REPRESENTATION - INCOMPLETE, TO BE UPDATED:\n{user_representation}"
# Prepare the messages
messages: list[dict[str, Any]] = [
{
"role": "user",
"content": f"Please analyze this information and provide an updated user representation:\n{context_str}",
}
]
langfuse_context.update_current_observation(input=messages, model=DEF_MODEL)
# Generate the response with caching enabled
try:
response = await client.generate(
messages=messages,
system=USER_REPRESENTATION_SYSTEM_PROMPT,
max_tokens=1000,
temperature=0,
use_caching=True, # Enable caching for the system prompt
)
except Exception as e:
sentry_sdk.capture_exception(e)
logger.error(f"Error generating user representation: {e}")
raise e
return response
<tom_analysis>
{tom_inference or "Not provided"}
</tom_analysis>
"""

View File

@ -2,21 +2,22 @@
Custom exceptions for the Honcho application.
"""
from typing import Optional
from typing import final
class HonchoException(Exception):
"""Base exception for all Honcho-specific errors."""
status_code = 500
detail = "An unexpected error occurred"
status_code: int = 500
detail: str = "An unexpected error occurred"
def __init__(self, detail: Optional[str] = None, status_code: Optional[int] = None):
def __init__(self, detail: str | None = None, status_code: int | None = None):
self.detail = detail or self.detail
self.status_code = status_code or self.status_code
super().__init__(self.detail)
@final
class ResourceNotFoundException(HonchoException):
"""Exception raised when a requested resource is not found."""
@ -24,6 +25,7 @@ class ResourceNotFoundException(HonchoException):
detail = "Resource not found"
@final
class ValidationException(HonchoException):
"""Exception raised when validation fails."""
@ -31,6 +33,7 @@ class ValidationException(HonchoException):
detail = "Validation error"
@final
class ConflictException(HonchoException):
"""Exception raised when there's a resource conflict."""
@ -38,6 +41,7 @@ class ConflictException(HonchoException):
detail = "Resource conflict"
@final
class AuthenticationException(HonchoException):
"""Exception raised when authentication fails."""
@ -45,6 +49,7 @@ class AuthenticationException(HonchoException):
detail = "Authentication failed"
@final
class AuthorizationException(HonchoException):
"""Exception raised when authorization fails."""
@ -52,6 +57,7 @@ class AuthorizationException(HonchoException):
detail = "Not authorized to access this resource"
@final
class DisabledException(HonchoException):
"""Exception raised when a feature is disabled."""

View File

@ -1,13 +1,18 @@
import logging
import re
import uuid
from collections.abc import Awaitable, Callable
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING
import sentry_sdk
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi_pagination import add_pagination
if TYPE_CHECKING:
from sentry_sdk._types import Event, Hint
from sentry_sdk.integrations.fastapi import FastApiIntegration
from sentry_sdk.integrations.starlette import StarletteIntegration
@ -63,9 +68,9 @@ async def setup_admin_jwt():
SENTRY_ENABLED = settings.SENTRY.ENABLED
if SENTRY_ENABLED:
def before_send(event, hint):
def before_send(event: Event, hint: Hint) -> Event | None:
if "exc_info" in hint:
exc_type, exc_value, _ = hint["exc_info"]
_, exc_value, _ = hint["exc_info"]
# Filter out HonchoExceptions from being sent to Sentry
if isinstance(exc_value, HonchoException):
return None
@ -95,7 +100,7 @@ if SENTRY_ENABLED:
@asynccontextmanager
async def lifespan(app: FastAPI):
async def lifespan(_: FastAPI):
yield
await engine.dispose()
@ -149,7 +154,7 @@ app.include_router(keys.router, prefix="/v2")
# Global exception handlers
@app.exception_handler(HonchoException)
async def honcho_exception_handler(request: Request, exc: HonchoException):
async def honcho_exception_handler(_request: Request, exc: HonchoException):
"""Handle all Honcho-specific exceptions."""
logger.error(f"{exc.__class__.__name__}: {exc.detail}", exc_info=exc)
return JSONResponse(
@ -159,7 +164,7 @@ async def honcho_exception_handler(request: Request, exc: HonchoException):
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
async def global_exception_handler(_request: Request, exc: Exception):
"""Handle all unhandled exceptions."""
logger.error(f"Unhandled exception: {str(exc)}", exc_info=True)
if SENTRY_ENABLED:
@ -171,9 +176,9 @@ async def global_exception_handler(request: Request, exc: Exception):
@app.middleware("http")
async def track_request(request: Request, call_next):
if not settings.DB.TRACING:
return await call_next(request)
async def track_request(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
):
# Create a request ID that includes endpoint information
endpoint = re.sub(r"/[A-Za-z0-9_-]{21}", "", request.url.path).replace("/", "_")
request_id = f"{request.method}:{endpoint}:{str(uuid.uuid4())[:8]}"

View File

@ -1,5 +1,6 @@
import datetime
from logging import getLogger
from typing import Any, final
import tiktoken
from dotenv import load_dotenv
@ -23,7 +24,9 @@ from sqlalchemy import (
)
from sqlalchemy.dialects.postgresql import JSONB, TEXT
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm.properties import MappedColumn
from sqlalchemy.sql import func
from typing_extensions import override
from .db import Base
@ -93,19 +96,20 @@ session_peers_table = Table(
)
@final
class Workspace(Base):
__tablename__ = "workspaces"
__tablename__: str = "workspaces"
id: Mapped[str] = mapped_column(TEXT, default=generate_nanoid, primary_key=True)
name: Mapped[str] = mapped_column(TEXT, index=True, unique=True)
peers = relationship("Peer", back_populates="workspace")
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), index=True, default=func.now()
)
h_metadata: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
internal_metadata: Mapped[dict] = mapped_column(
h_metadata: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB, default=dict)
internal_metadata: Mapped[dict[str, Any]] = mapped_column(
"internal_metadata", JSONB, default=dict
)
configuration: Mapped[dict] = mapped_column(JSONB, default=dict)
configuration: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
__table_args__ = (
CheckConstraint("length(id) = 21", name="id_length"),
@ -114,12 +118,13 @@ class Workspace(Base):
)
@final
class Peer(Base):
__tablename__ = "peers"
__tablename__: str = "peers"
id: Mapped[str] = mapped_column(TEXT, default=generate_nanoid, primary_key=True)
name: Mapped[str] = mapped_column(TEXT, index=True)
h_metadata: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
internal_metadata: Mapped[dict] = mapped_column(
h_metadata: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB, default=dict)
internal_metadata: Mapped[dict[str, Any]] = mapped_column(
"internal_metadata", JSONB, default=dict
)
created_at: Mapped[datetime.datetime] = mapped_column(
@ -128,7 +133,7 @@ class Peer(Base):
workspace_name: Mapped[str] = mapped_column(
ForeignKey("workspaces.name"), index=True
)
configuration: Mapped[dict] = mapped_column(JSONB, default=dict)
configuration: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
workspace = relationship("Workspace", back_populates="peers")
sessions = relationship(
@ -148,13 +153,14 @@ class Peer(Base):
return f"Peer(id={self.id}, name={self.name}, workspace_name={self.workspace_name}, created_at={self.created_at}, h_metadata={self.h_metadata}, configuration={self.configuration})"
@final
class Session(Base):
__tablename__ = "sessions"
__tablename__: str = "sessions"
id: Mapped[str] = mapped_column(TEXT, primary_key=True, default=generate_nanoid)
name: Mapped[str] = mapped_column(TEXT, index=True)
is_active: Mapped[bool] = mapped_column(default=True)
h_metadata: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
internal_metadata: Mapped[dict] = mapped_column(
h_metadata: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB, default=dict)
internal_metadata: Mapped[dict[str, Any]] = mapped_column(
"internal_metadata", JSONB, default=dict
)
created_at: Mapped[datetime.datetime] = mapped_column(
@ -164,7 +170,7 @@ class Session(Base):
workspace_name: Mapped[str] = mapped_column(
ForeignKey("workspaces.name"), index=True
)
configuration: Mapped[dict] = mapped_column(JSONB, default=dict)
configuration: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict)
peers = relationship(
"Peer", secondary=session_peers_table, back_populates="sessions"
@ -181,8 +187,9 @@ class Session(Base):
return f"Session(id={self.id}, name={self.name}, workspace_name={self.workspace_name}, is_active={self.is_active}, created_at={self.created_at}, h_metadata={self.h_metadata})"
@final
class Message(Base):
__tablename__ = "messages"
__tablename__: str = "messages"
id: Mapped[int] = mapped_column(
BigInteger, Identity(), primary_key=True, autoincrement=True
)
@ -191,8 +198,8 @@ class Message(Base):
)
session_name: Mapped[str | None] = mapped_column(index=True, nullable=True)
content: Mapped[str] = mapped_column(TEXT)
h_metadata: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
internal_metadata: Mapped[dict] = mapped_column(
h_metadata: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB, default=dict)
internal_metadata: Mapped[dict[str, Any]] = mapped_column(
"internal_metadata", JSONB, default=dict
)
token_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
@ -234,26 +241,28 @@ class Message(Base):
),
)
@override
def __repr__(self) -> str:
return f"Message(id={self.id}, session_name={self.session_name}, peer_name={self.peer_name}, content={self.content})"
@event.listens_for(Message, "before_insert")
def calculate_token_count_on_insert(_mapper, _connection, target):
def calculate_token_count_on_insert(_mapper: Any, _connection: Any, target: Message):
"""Calculate token count before inserting a new message."""
target.token_count = count_tokens(target.content)
@final
class Collection(Base):
__tablename__ = "collections"
__tablename__: str = "collections"
id: Mapped[str] = mapped_column(TEXT, default=generate_nanoid, primary_key=True)
name: Mapped[str] = mapped_column(TEXT, index=True)
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), index=True, default=func.now()
)
h_metadata: Mapped[dict] = mapped_column("metadata", JSONB, default=dict)
internal_metadata: Mapped[dict] = mapped_column(
h_metadata: Mapped[dict[str, Any]] = mapped_column("metadata", JSONB, default=dict)
internal_metadata: Mapped[dict[str, Any]] = mapped_column(
"internal_metadata", JSONB, default=dict
)
documents = relationship(
@ -280,14 +289,15 @@ class Collection(Base):
)
@final
class Document(Base):
__tablename__ = "documents"
__tablename__: str = "documents"
id: Mapped[str] = mapped_column(TEXT, default=generate_nanoid, primary_key=True)
internal_metadata: Mapped[dict] = mapped_column(
internal_metadata: Mapped[dict[str, Any]] = mapped_column(
"internal_metadata", JSONB, default=dict
)
content: Mapped[str] = mapped_column(TEXT)
embedding = mapped_column(Vector(1536))
embedding: MappedColumn[Any] = mapped_column(Vector(1536))
created_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), index=True, default=func.now()
)
@ -326,20 +336,22 @@ class Document(Base):
)
@final
class QueueItem(Base):
__tablename__ = "queue"
__tablename__: str = "queue"
id: Mapped[int] = mapped_column(
BigInteger, Identity(), primary_key=True, autoincrement=True
)
session_id: Mapped[str] = mapped_column(
ForeignKey("sessions.id"), index=True, nullable=True
)
payload: Mapped[dict] = mapped_column(JSONB, nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
processed: Mapped[bool] = mapped_column(Boolean, default=False)
@final
class ActiveQueueSession(Base):
__tablename__ = "active_queue_sessions"
__tablename__: str = "active_queue_sessions"
id: Mapped[str] = mapped_column(TEXT, default=generate_nanoid, primary_key=True)
session_id: Mapped[str | None] = mapped_column(
@ -363,5 +375,15 @@ class ActiveQueueSession(Base):
)
@final
class SessionPeer(Base):
__table__ = session_peers_table
__table__: Table = session_peers_table
# Type annotations for the columns
workspace_name: Mapped[str]
session_name: Mapped[str]
peer_name: Mapped[str]
configuration: Mapped[dict[str, Any]]
internal_metadata: Mapped[dict[str, Any]]
joined_at: Mapped[datetime.datetime]
left_at: Mapped[datetime.datetime | None]

View File

@ -1,10 +1,11 @@
import logging
import os
from typing import Any, Optional
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Body, Depends, Path, Query
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import paginate
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import insert
from src import crud, schemas
@ -25,11 +26,11 @@ router = APIRouter(
def create_processed_payload(
message: dict,
sender_name: Optional[str],
target_name: Optional[str],
message: dict[str, Any],
sender_name: str | None,
target_name: str | None,
task_type: str,
) -> dict:
) -> dict[str, Any]:
"""
Create a processed payload from a message for queue processing.
@ -53,7 +54,7 @@ def create_processed_payload(
return processed_payload
async def enqueue(payload: list[dict]):
async def enqueue(payload: list[dict[str, Any]]):
"""
Add message(s) to the deriver queue for processing.
@ -268,7 +269,7 @@ async def create_messages_for_session(
messages: schemas.MessageBatchCreate = Body(
..., description="Batch of messages to create"
),
db=db,
db: AsyncSession = db,
):
workspace_name, session_name = workspace_id, session_id
"""Bulk create messages for a session while maintaining order. Maximum 100 messages per batch."""
@ -310,13 +311,13 @@ async def create_messages_for_session(
async def get_messages(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
options: Optional[schemas.MessageGet] = Body(
options: schemas.MessageGet | None = Body(
None, description="Filtering options for the messages list"
),
reverse: Optional[bool] = Query(
reverse: bool | None = Query(
False, description="Whether to reverse the order of results"
),
db=db,
db: AsyncSession = db,
):
"""Get all messages for a session"""
try:
@ -344,7 +345,7 @@ async def get_message(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
message_id: str = Path(..., description="ID of the message to retrieve"),
db=db,
db: AsyncSession = db,
):
"""Get a Message by ID"""
honcho_message = await crud.get_message(
@ -364,7 +365,7 @@ async def update_message(
message: schemas.MessageUpdate = Body(
..., description="Updated message parameters"
),
db=db,
db: AsyncSession = db,
):
"""Update the metadata of a Message"""
try:

View File

@ -1,7 +1,5 @@
import logging
from typing import Optional
from anthropic import AsyncMessageStreamManager
from fastapi import (
APIRouter,
BackgroundTasks,
@ -14,6 +12,8 @@ from fastapi.exceptions import HTTPException
from fastapi.responses import StreamingResponse
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import paginate
from mirascope.llm import Stream
from sqlalchemy.ext.asyncio import AsyncSession
from src import agent, crud, schemas
from src.dependencies import db
@ -39,10 +39,10 @@ router = APIRouter(
)
async def get_peers(
workspace_id: str = Path(..., description="ID of the workspace"),
options: Optional[schemas.PeerGet] = Body(
options: schemas.PeerGet | None = Body(
None, description="Filtering options for the peers list"
),
db=db,
db: AsyncSession = db,
):
"""Get All Peers for a Workspace"""
filter_param = None
@ -65,7 +65,7 @@ async def get_or_create_peer(
workspace_id: str = Path(..., description="ID of the workspace"),
peer: schemas.PeerCreate = Body(..., description="Peer creation parameters"),
jwt_params: JWTParams = Depends(require_auth()),
db=db,
db: AsyncSession = db,
):
"""
Get a Peer by ID
@ -102,7 +102,7 @@ async def update_peer(
workspace_id: str = Path(..., description="ID of the workspace"),
peer_id: str = Path(..., description="ID of the peer to update"),
peer: schemas.PeerUpdate = Body(..., description="Updated peer parameters"),
db=db,
db: AsyncSession = db,
):
"""Update a Peer's name and/or metadata"""
updated_peer = await crud.update_peer(
@ -121,10 +121,10 @@ async def update_peer(
async def get_sessions_for_peer(
workspace_id: str = Path(..., description="ID of the workspace"),
peer_id: str = Path(..., description="ID of the peer"),
options: Optional[schemas.SessionGet] = Body(
options: schemas.SessionGet | None = Body(
None, description="Filtering options for the sessions list"
),
db=db,
db: AsyncSession = db,
):
"""Get All Sessions for a Peer"""
filter_param = None
@ -168,7 +168,7 @@ async def chat(
options: schemas.DialecticOptions = Body(
..., description="Dialectic Endpoint Parameters"
),
db=db,
db: AsyncSession = db,
):
# Get or create the peer to ensure it exists
await crud.get_or_create_peers(
@ -189,10 +189,11 @@ async def chat(
options.queries,
stream=True,
)
if isinstance(stream, AsyncMessageStreamManager):
async with stream as stream_manager:
async for text in stream_manager.text_stream:
yield text
if isinstance(stream, Stream):
async for chunk, _ in stream:
yield chunk.content
else:
raise HTTPException(status_code=500, detail="Invalid stream type")
except Exception as e:
logger.error(f"Error in stream: {str(e)}")
raise HTTPException(status_code=500, detail=str(e)) from e
@ -216,7 +217,7 @@ async def create_messages_for_peer(
messages: schemas.MessageBatchCreate = Body(
..., description="Batch of messages to create"
),
db=db,
db: AsyncSession = db,
):
"""Create messages for a peer"""
workspace_name, peer_name = workspace_id, peer_id
@ -263,13 +264,13 @@ async def create_messages_for_peer(
async def get_messages_for_peer(
workspace_id: str = Path(..., description="ID of the workspace"),
peer_id: str = Path(..., description="ID of the peer"),
options: Optional[schemas.MessageGet] = Body(
options: schemas.MessageGet | None = Body(
None, description="Filtering options for the messages list"
),
reverse: Optional[bool] = Query(
reverse: bool | None = Query(
False, description="Whether to reverse the order of results"
),
db=db,
db: AsyncSession = db,
):
"""Get all messages for a peer"""
try:
@ -305,7 +306,7 @@ async def get_working_representation(
options: schemas.PeerRepresentationGet = Body(
..., description="Options for getting the peer representation"
),
db=db,
db: AsyncSession = db,
):
"""Get a peer's working representation for a session.
@ -331,7 +332,7 @@ async def search_peer(
workspace_id: str = Path(..., description="ID of the workspace"),
peer_id: str = Path(..., description="ID of the peer"),
query: str = Body(..., description="Search query"),
db=db,
db: AsyncSession = db,
):
"""Search a Peer"""
stmt = await crud.search(query, workspace_name=workspace_id, peer_name=peer_id)

View File

@ -1,9 +1,9 @@
import logging
from typing import Optional
from fastapi import APIRouter, Body, Depends, Path, Query, Response
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import paginate
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, schemas
from src.dependencies import db
@ -33,7 +33,7 @@ async def get_or_create_session(
..., description="Session creation parameters"
),
jwt_params: JWTParams = Depends(require_auth()),
db=db,
db: AsyncSession = db,
):
"""
Get a specific session in a workspace.
@ -77,10 +77,10 @@ async def get_or_create_session(
)
async def get_sessions(
workspace_id: str = Path(..., description="ID of the workspace"),
options: Optional[schemas.SessionGet] = Body(
options: schemas.SessionGet | None = Body(
None, description="Filtering and pagination options for the sessions list"
),
db=db,
db: AsyncSession = db,
):
"""Get All Sessions in a Workspace"""
filter_param = None
@ -117,7 +117,7 @@ async def update_session(
session: schemas.SessionUpdate = Body(
..., description="Updated session parameters"
),
db=db,
db: AsyncSession = db,
):
"""Update the metadata of a Session"""
try:
@ -140,7 +140,7 @@ async def update_session(
async def delete_session(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session to delete"),
db=db,
db: AsyncSession = db,
):
"""Delete a session by marking it as inactive"""
try:
@ -164,8 +164,8 @@ async def delete_session(
async def clone_session(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session to clone"),
db=db,
message_id: Optional[str] = Query(
db: AsyncSession = db,
message_id: str | None = Query(
None, description="Message ID to cut off the clone at"
),
):
@ -198,23 +198,22 @@ async def add_peers_to_session(
peers: dict[str, schemas.SessionPeerConfig] = Body(
..., description="List of peer IDs to add to the session"
),
db=db,
db: AsyncSession = db,
):
"""Add peers to a session"""
try:
workspace_name, session_name = workspace_id, session_id
session = await crud.get_or_create_session(
db,
session=schemas.SessionCreate(
name=session_name,
name=session_id,
peers=peers,
),
workspace_name=workspace_name,
workspace_name=workspace_id,
)
logger.info(f"Added peers to session {session_name} successfully")
logger.info(f"Added peers to session {session_id} successfully")
return session
except ValueError as e:
logger.warning(f"Failed to add peers to session {session_name}: {str(e)}")
logger.warning(f"Failed to add peers to session {session_id}: {str(e)}")
raise ResourceNotFoundException("Session not found") from e
@ -231,27 +230,26 @@ async def set_session_peers(
peers: dict[str, schemas.SessionPeerConfig] = Body(
..., description="List of peer IDs to set for the session"
),
db=db,
db: AsyncSession = db,
):
"""Set the peers in a session"""
try:
workspace_name, session_name = workspace_id, session_id
await crud.set_peers_for_session(
db,
workspace_name=workspace_name,
session_name=session_name,
workspace_name=workspace_id,
session_name=session_id,
peer_names=peers,
)
# Get the session to return
session = await crud.get_or_create_session(
db,
session=schemas.SessionCreate(name=session_name),
workspace_name=workspace_name,
session=schemas.SessionCreate(name=session_id),
workspace_name=workspace_id,
)
logger.info(f"Set peers for session {session_name} successfully")
logger.info(f"Set peers for session {session_id} successfully")
return session
except ValueError as e:
logger.warning(f"Failed to set peers for session {session_name}: {str(e)}")
logger.warning(f"Failed to set peers for session {session_id}: {str(e)}")
raise ResourceNotFoundException("Failed to set peers for session") from e
@ -268,27 +266,26 @@ async def remove_peers_from_session(
peers: list[str] = Body(
..., description="List of peer IDs to remove from the session"
),
db=db,
db: AsyncSession = db,
):
"""Remove peers from a session"""
try:
session_name = session_id
await crud.remove_peers_from_session(
db,
workspace_name=workspace_id,
session_name=session_name,
session_name=session_id,
peer_names=set(peers),
)
# Get the session to return
session = await crud.get_or_create_session(
db,
session=schemas.SessionCreate(name=session_name),
session=schemas.SessionCreate(name=session_id),
workspace_name=workspace_id,
)
logger.info(f"Removed peers from session {session_name} successfully")
logger.info(f"Removed peers from session {session_id} successfully")
return session
except ValueError as e:
logger.warning(f"Failed to remove peers from session {session_name}: {str(e)}")
logger.warning(f"Failed to remove peers from session {session_id}: {str(e)}")
raise ResourceNotFoundException("Session not found") from e
@ -303,7 +300,7 @@ async def get_peer_config(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
peer_id: str = Path(..., description="ID of the peer"),
db=db,
db: AsyncSession = db,
):
"""Get the configuration for a peer in a session"""
return await crud.get_peer_config(
@ -325,7 +322,7 @@ async def set_peer_config(
session_id: str = Path(..., description="ID of the session"),
peer_id: str = Path(..., description="ID of the peer"),
config: schemas.SessionPeerConfig = Body(..., description="Peer configuration"),
db=db,
db: AsyncSession = db,
):
"""Set the configuration for a peer in a session"""
try:
@ -357,17 +354,16 @@ async def set_peer_config(
async def get_session_peers(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
db=db,
db: AsyncSession = db,
):
"""Get peers from a session"""
try:
session_name = session_id
peers_query = await crud.get_peers_from_session(
workspace_name=workspace_id, session_name=session_name
workspace_name=workspace_id, session_name=session_id
)
return await paginate(db, peers_query)
except ValueError as e:
logger.warning(f"Failed to get peers from session {session_name}: {str(e)}")
logger.warning(f"Failed to get peers from session {session_id}: {str(e)}")
raise ResourceNotFoundException("Session not found") from e
@ -381,7 +377,7 @@ async def get_session_peers(
async def get_session_context(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
tokens: Optional[int] = Query(
tokens: int | None = Query(
None,
description="Number of tokens to use for the context. Includes summary if set to true",
),
@ -389,7 +385,7 @@ async def get_session_context(
False,
description="Whether to summarize the session history prior to the cutoff message",
), # default to false
db=db,
db: AsyncSession = db,
):
"""
Produce a context object from the session. The caller provides a token limit which the entire context must fit into.
@ -449,7 +445,7 @@ async def get_session_context(
return schemas.SessionContext(
name=session_id,
messages=messages,
messages=messages, # pyright: ignore
summary=summary_content,
)
@ -465,7 +461,7 @@ async def search_session(
workspace_id: str = Path(..., description="ID of the workspace"),
session_id: str = Path(..., description="ID of the session"),
query: str = Body(..., description="Search query"),
db=db,
db: AsyncSession = db,
):
"""Search a Session"""
stmt = await crud.search(

View File

@ -1,9 +1,9 @@
import logging
from typing import Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from fastapi_pagination import Page
from fastapi_pagination.ext.sqlalchemy import paginate
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, schemas
from src.dependencies import db
@ -24,7 +24,7 @@ async def get_or_create_workspace(
..., description="Workspace creation parameters"
),
jwt_params: JWTParams = Depends(require_auth()),
db=db,
db: AsyncSession = db,
):
"""
Get a Workspace by ID.
@ -53,10 +53,10 @@ async def get_or_create_workspace(
dependencies=[Depends(require_auth(admin=True))],
)
async def get_all_workspaces(
options: Optional[schemas.WorkspaceGet] = Body(
options: schemas.WorkspaceGet | None = Body(
None, description="Filtering and pagination options for the workspaces list"
),
db=db,
db: AsyncSession = db,
):
"""Get all Workspaces"""
filter_param = None
@ -81,7 +81,7 @@ async def update_workspace(
workspace: schemas.WorkspaceUpdate = Body(
..., description="Updated workspace parameters"
),
db=db,
db: AsyncSession = db,
):
"""Update a Workspace"""
# ResourceNotFoundException will be caught by global handler if workspace not found
@ -99,7 +99,7 @@ async def update_workspace(
async def search_workspace(
workspace_id: str = Path(..., description="ID of the workspace to search"),
query: str = Body(..., description="Search query"),
db=db,
db: AsyncSession = db,
):
"""Search a Workspace"""
stmt = await crud.search(query, workspace_name=workspace_id)
@ -114,14 +114,14 @@ async def search_workspace(
)
async def get_deriver_status(
workspace_id: str = Path(..., description="ID of the workspace"),
peer_id: Optional[str] = Query(None, description="Optional peer ID to filter by"),
session_id: Optional[str] = Query(
peer_id: str | None = Query(None, description="Optional peer ID to filter by"),
session_id: str | None = Query(
None, description="Optional session ID to filter by"
),
include_sender: bool = Query(
False, description="Include work units triggered by this peer"
),
db=db,
db: AsyncSession = db,
):
"""Get the deriver processing status, optionally scoped to a peer and/or session"""
# Validate that at least one of peer_id or session_id is provided

View File

@ -1,5 +1,6 @@
# pyright: reportUnannotatedClassAttribute=false # pyright: ignore
import datetime
from typing import Annotated, Optional
from typing import Annotated, Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
@ -15,28 +16,32 @@ class WorkspaceCreate(WorkspaceBase):
str,
Field(alias="id", min_length=1, max_length=100, pattern=RESOURCE_NAME_PATTERN),
]
metadata: dict = {}
configuration: dict = {}
metadata: dict[str, Any] = {}
configuration: dict[str, Any] = {}
model_config = ConfigDict(populate_by_name=True)
model_config = ConfigDict(populate_by_name=True) # pyright: ignore
class WorkspaceGet(WorkspaceBase):
filter: dict | None = None
filter: dict[str, Any] | None = None
class WorkspaceUpdate(WorkspaceBase):
metadata: dict | None = None
configuration: dict | None = None
metadata: dict[str, Any] | None = None
configuration: dict[str, Any] | None = None
class Workspace(WorkspaceBase):
name: str = Field(serialization_alias="id")
h_metadata: dict = Field(default_factory=dict, serialization_alias="metadata")
configuration: dict = Field(default_factory=dict)
h_metadata: dict[str, Any] = Field(
default_factory=dict, serialization_alias="metadata"
)
configuration: dict[str, Any] = Field(default_factory=dict)
created_at: datetime.datetime
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
model_config = ConfigDict( # pyright: ignore
from_attributes=True, populate_by_name=True
)
class PeerBase(BaseModel):
@ -48,36 +53,40 @@ class PeerCreate(PeerBase):
str,
Field(alias="id", min_length=1, max_length=100, pattern=RESOURCE_NAME_PATTERN),
]
metadata: dict | None = None
configuration: dict | None = None
metadata: dict[str, Any] | None = None
configuration: dict[str, Any] | None = None
model_config = ConfigDict(populate_by_name=True)
model_config = ConfigDict(populate_by_name=True) # pyright: ignore
class PeerGet(PeerBase):
filter: dict | None = None
filter: dict[str, Any] | None = None
class PeerUpdate(PeerBase):
metadata: dict | None = None
configuration: dict | None = None
metadata: dict[str, Any] | None = None
configuration: dict[str, Any] | None = None
class Peer(PeerBase):
name: str = Field(serialization_alias="id")
workspace_name: str = Field(serialization_alias="workspace_id")
created_at: datetime.datetime
h_metadata: dict = Field(default_factory=dict, serialization_alias="metadata")
configuration: dict = Field(default_factory=dict)
h_metadata: dict[str, Any] = Field(
default_factory=dict, serialization_alias="metadata"
)
configuration: dict[str, Any] = Field(default_factory=dict)
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
model_config = ConfigDict( # pyright: ignore
from_attributes=True, populate_by_name=True
)
class PeerRepresentationGet(BaseModel):
session_id: str = Field(
..., description="Get the working representation within this session"
)
target: Optional[str] = Field(
target: str | None = Field(
None,
description="Optional peer ID to get the representation for, from the perspective of this peer",
)
@ -97,15 +106,15 @@ class MessageBase(BaseModel):
class MessageCreate(MessageBase):
content: Annotated[str, Field(min_length=0, max_length=50000)]
peer_name: str = Field(alias="peer_id")
metadata: dict | None = None
metadata: dict[str, Any] | None = None
class MessageGet(MessageBase):
filter: dict | None = None
filter: dict[str, Any] | None = None
class MessageUpdate(MessageBase):
metadata: dict | None = None
metadata: dict[str, Any] | None = None
class Message(MessageBase):
@ -113,12 +122,16 @@ class Message(MessageBase):
content: str
peer_name: str = Field(serialization_alias="peer_id")
session_name: str | None = Field(serialization_alias="session_id")
h_metadata: dict = Field(default_factory=dict, serialization_alias="metadata")
h_metadata: dict[str, Any] = Field(
default_factory=dict, serialization_alias="metadata"
)
created_at: datetime.datetime
workspace_name: str = Field(serialization_alias="workspace_id")
token_count: int
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
model_config = ConfigDict( # pyright: ignore
from_attributes=True, populate_by_name=True
)
class MessageBatchCreate(BaseModel):
@ -147,32 +160,36 @@ class SessionCreate(SessionBase):
str,
Field(alias="id", min_length=1, max_length=100, pattern=RESOURCE_NAME_PATTERN),
]
metadata: dict | None = None
metadata: dict[str, Any] | None = None
peer_names: dict[str, SessionPeerConfig] | None = Field(default=None, alias="peers")
configuration: dict | None = None
configuration: dict[str, Any] | None = None
model_config = ConfigDict(populate_by_name=True)
model_config = ConfigDict(populate_by_name=True) # pyright: ignore
class SessionGet(SessionBase):
filter: dict | None = None
filter: dict[str, Any] | None = None
is_active: bool = False
class SessionUpdate(SessionBase):
metadata: dict | None = None
configuration: dict | None = None
metadata: dict[str, Any] | None = None
configuration: dict[str, Any] | None = None
class Session(SessionBase):
name: str = Field(serialization_alias="id")
is_active: bool
workspace_name: str = Field(serialization_alias="workspace_id")
h_metadata: dict = Field(default_factory=dict, serialization_alias="metadata")
configuration: dict = Field(default_factory=dict)
h_metadata: dict[str, Any] = Field(
default_factory=dict, serialization_alias="metadata"
)
configuration: dict[str, Any] = Field(default_factory=dict)
created_at: datetime.datetime
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
model_config = ConfigDict( # pyright: ignore
from_attributes=True, populate_by_name=True
)
class SessionContext(SessionBase):
@ -180,7 +197,9 @@ class SessionContext(SessionBase):
messages: list[Message]
summary: str
model_config = ConfigDict(from_attributes=True, populate_by_name=True)
model_config = ConfigDict( # pyright: ignore
from_attributes=True, populate_by_name=True
)
class DocumentBase(BaseModel):
@ -189,19 +208,19 @@ class DocumentBase(BaseModel):
class DocumentCreate(DocumentBase):
content: Annotated[str, Field(min_length=1, max_length=100000)]
metadata: dict = {}
metadata: dict[str, Any] = {}
class DocumentUpdate(DocumentBase):
content: Annotated[str, Field(min_length=1, max_length=100000)]
metadata: dict | None = None
metadata: dict[str, Any] | None = None
class DialecticOptions(BaseModel):
session_id: Optional[str] = Field(
session_id: str | None = Field(
None, description="ID of the session to scope the representation to"
)
target: Optional[str] = Field(
target: str | None = Field(
None,
description="Optional peer to get the representation for, from the perspective of this peer",
)
@ -209,13 +228,13 @@ class DialecticOptions(BaseModel):
stream: bool = False
@field_validator("queries")
def validate_queries(cls, v):
def validate_queries(cls, v: str | list[str]) -> str | list[str]:
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):
else:
if len(v) > MAX_LIST_LENGTH:
raise ValueError("Too many queries")
if any(len(q) > MAX_STRING_LENGTH for q in v):
@ -227,12 +246,60 @@ class DialecticResponse(BaseModel):
content: str
class SessionCounts(BaseModel):
"""Counts for a specific session in queue processing."""
completed: int
in_progress: int
pending: int
class QueueCounts(BaseModel):
"""Aggregated counts for queue processing status."""
total: int
completed: int
in_progress: int
pending: int
sessions: dict[str, SessionCounts]
class QueueStatusRow(BaseModel):
"""Represents a row from the queue status SQL query result."""
session_id: str | None
total: int
completed: int
in_progress: int
pending: int
session_total: int
session_completed: int
session_in_progress: int
session_pending: int
class PeerConfigResult(BaseModel):
"""Result from querying peer configuration data."""
peer_name: str
peer_configuration: dict[str, Any]
session_peer_configuration: dict[str, Any]
class SessionPeerData(BaseModel):
"""Data for managing session peer relationships."""
peer_names: dict[str, SessionPeerConfig]
class MessageBulkData(BaseModel):
"""Data for bulk message operations."""
messages: list[MessageCreate]
session_name: str
workspace_name: str
class DeriverStatus(BaseModel):
peer_id: Optional[str] = Field(
peer_id: str | None = Field(
default=None,
description="ID of the peer (optional when filtering by session only)",
)
session_id: Optional[str] = Field(
session_id: str | None = Field(
default=None, description="Session ID if filtered by session"
)
total_work_units: int = Field(description="Total work units")
@ -241,6 +308,6 @@ class DeriverStatus(BaseModel):
description="Work units currently being processed"
)
pending_work_units: int = Field(description="Work units waiting to be processed")
sessions: Optional[dict[str, "DeriverStatus"]] = Field(
sessions: dict[str, "DeriverStatus"] | None = Field(
default=None, description="Per-session status when not filtered by session"
)

View File

@ -1,15 +1,13 @@
import datetime
import logging
from typing import Annotated, Optional
from typing import Annotated
import jwt
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from src.config import settings
from src.dependencies import get_db
from .exceptions import AuthenticationException
@ -57,11 +55,11 @@ class JWTParams(BaseModel):
"""
t: str = datetime.datetime.now().isoformat()
exp: Optional[str] = None
ad: Optional[bool] = None
w: Optional[str] = None
p: Optional[str] = None
s: Optional[str] = None
exp: str | None = None
ad: bool | None = None
w: str | None = None
p: str | None = None
s: str | None = None
def create_admin_jwt() -> str:
@ -76,7 +74,9 @@ def create_jwt(params: JWTParams) -> str:
payload = {k: v for k, v in params.__dict__.items() if v is not None}
if not settings.AUTH.JWT_SECRET:
raise ValueError("AUTH_JWT_SECRET is not set, cannot create JWT.")
return jwt.encode(payload, settings.AUTH.JWT_SECRET.encode("utf-8"), algorithm="HS256")
return jwt.encode( # pyright: ignore
payload, settings.AUTH.JWT_SECRET.encode("utf-8"), algorithm="HS256"
)
async def verify_jwt(token: str) -> JWTParams:
@ -86,7 +86,7 @@ async def verify_jwt(token: str) -> JWTParams:
try:
if not settings.AUTH.JWT_SECRET:
raise ValueError("AUTH_JWT_SECRET is not set, cannot verify JWT.")
decoded = jwt.decode(
decoded = jwt.decode( # pyright: ignore
token, settings.AUTH.JWT_SECRET.encode("utf-8"), algorithms=["HS256"]
)
if "t" in decoded:
@ -113,10 +113,10 @@ async def verify_jwt(token: str) -> JWTParams:
def require_auth(
admin: Optional[bool] = None,
workspace_name: Optional[str] = None,
peer_name: Optional[str] = None,
session_name: Optional[str] = None,
admin: bool | None = None,
workspace_name: str | None = None,
peer_name: str | None = None,
session_name: str | None = None,
):
"""
Generate a dependency that requires authentication for the given parameters.
@ -125,7 +125,6 @@ def require_auth(
async def auth_dependency(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
db: AsyncSession = Depends(get_db),
):
workspace_name_param = (
request.path_params.get(workspace_name)
@ -158,10 +157,10 @@ def require_auth(
async def auth(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
admin: Optional[bool] = None,
workspace_name: Optional[str] = None,
peer_name: Optional[str] = None,
session_name: Optional[str] = None,
admin: bool | None = None,
workspace_name: str | None = None,
peer_name: str | None = None,
session_name: str | None = None,
) -> JWTParams:
"""Authenticate the given JWT and return the decoded parameters."""
if not settings.AUTH.USE_AUTH:

View File

@ -1,99 +0,0 @@
import asyncio
from collections import OrderedDict
class LRUCache:
"""
A simple asyncio-safe LRU cache implementation.
This cache automatically evicts the least recently used items when it reaches
its capacity limit. All operations are protected by an asyncio lock for
concurrent access safety.
"""
def __init__(self, capacity=100):
"""
Initialize the LRU cache.
Args:
capacity (int): Maximum number of items to store in the cache
"""
self._cache = OrderedDict()
self._capacity = max(1, capacity)
self._lock = asyncio.Lock()
async def get(self, key):
"""
Get a value from the cache.
Args:
key: The key to look up
Returns:
The cached value or None if not found
"""
async with self._lock:
if key not in self._cache:
return None
# Move accessed item to the end to mark as most recently used
value = self._cache.pop(key)
self._cache[key] = value
return value
async def put(self, key, value):
"""
Add or update an entry in the cache.
Args:
key: The key to store
value: The value to store
Returns:
The value that was stored
"""
async with self._lock:
# If key already exists, remove it first to update its position
if key in self._cache:
self._cache.pop(key)
# If at capacity, remove the least recently used item
elif len(self._cache) >= self._capacity:
self._cache.popitem(last=False)
# Add the new item
self._cache[key] = value
return value
async def delete(self, key):
"""
Remove an item from the cache.
Args:
key: The key to remove
Returns:
bool: True if the key was removed, False if it didn't exist
"""
async with self._lock:
if key in self._cache:
self._cache.pop(key)
return True
return False
def generate_cache_key(model_type: str, app_id: str, user_id: str, model_id: str):
"""
Generate a cache key for a given model type, app ID, user ID, and model ID.
Args:
model_type (str): The type of model to generate a cache key for (e.g. "collection", "document", "message", "session", "user")
app_id (str): The ID of the app to generate a cache key for
user_id (str): The ID of the user to generate a cache key for
model_id (str): The ID of the model to generate a cache key for
Returns:
str: A cache key for the given model type, app ID, user ID, and model ID
"""
return f"{model_type}:{app_id}:{user_id}:{model_id}"

33
src/utils/clients.py Normal file
View File

@ -0,0 +1,33 @@
from anthropic import AsyncAnthropic
from google import genai
from groq import Groq
from openai import AsyncOpenAI
from src.config import settings
from src.utils.types import Providers
clients: dict[Providers, AsyncAnthropic | AsyncOpenAI | genai.Client | Groq] = {}
if settings.LLM.ANTHROPIC_API_KEY:
anthropic = AsyncAnthropic(api_key=settings.LLM.ANTHROPIC_API_KEY)
clients["anthropic"] = anthropic
if settings.LLM.OPENAI_API_KEY:
openai_client = AsyncOpenAI(
api_key=settings.LLM.OPENAI_API_KEY,
)
clients["openai"] = openai_client
if settings.LLM.OPENAI_COMPATIBLE_BASE_URL:
clients["custom"] = AsyncOpenAI(
api_key=settings.LLM.OPENAI_COMPATIBLE_API_KEY,
base_url=settings.LLM.OPENAI_COMPATIBLE_BASE_URL,
)
if settings.LLM.GEMINI_API_KEY:
google = genai.Client(api_key=settings.LLM.GEMINI_API_KEY)
clients["google"] = google
if settings.LLM.GROQ_API_KEY:
groq = Groq(api_key=settings.LLM.GROQ_API_KEY)
clients["groq"] = groq

View File

@ -1,12 +1,14 @@
import datetime
import logging
from enum import Enum
from typing import Optional, TypedDict
from typing import TypedDict
from mirascope import llm
from mirascope.integrations.langfuse import with_langfuse
from sqlalchemy.ext.asyncio import AsyncSession
from src.config import settings
from src.utils.model_client import ModelClient, ModelProvider
from src.utils.clients import clients
from .. import crud, models
@ -58,9 +60,89 @@ class SummaryType(Enum):
LONG = "honcho_chat_summary_long"
# Default model settings for summary generation
# DEFAULT_PROVIDER = ModelProvider.GEMINI
# DEFAULT_MODEL = "gemini-2.0-flash-lite"
# Mirascope functions for summaries
@with_langfuse()
@llm.call(
provider=(
settings.LLM.SUMMARY_PROVIDER
if settings.LLM.SUMMARY_PROVIDER != "custom"
else "openai"
),
model=settings.LLM.SUMMARY_MODEL,
call_params={"max_tokens": 1000},
client=clients[settings.LLM.SUMMARY_PROVIDER],
)
async def create_short_summary(
messages: list[models.Message],
previous_summary: str | None = None,
):
return f"""
You are a system that summarizes parts of a conversation to create a concise and accurate summary.
Focus on capturing:
1. Key facts and information shared
2. User preferences, opinions, and questions
3. Important context and requests
4. Core topics discussed
5. User's apparent emotional state
It is very important that you clearly distinguish between the user's messages and the assistant's messages, and that only the user's literal words are attributed to them.
Provide a concise, factual summary that captures the essence of the conversation.
Your summary should be detailed enough to serve as context for future messages,
but brief enough to be helpful.
Return only the summary without any explanation or meta-commentary.
<conversation>
{format_messages(messages)}
</conversation>
<previous_summary>
{previous_summary or ""}
</previous_summary>
"""
@with_langfuse()
@llm.call(
provider=(
settings.LLM.SUMMARY_PROVIDER
if settings.LLM.SUMMARY_PROVIDER != "custom"
else "openai"
),
model=settings.LLM.SUMMARY_MODEL,
call_params={"max_tokens": 2000},
client=clients[settings.LLM.SUMMARY_PROVIDER],
)
async def create_long_summary(
messages: list[models.Message],
previous_summary: str | None = None,
):
return f"""
You are a system that creates comprehensive summaries of conversations.
Focus on capturing:
1. Key facts and information shared
2. User preferences, opinions, and questions
3. Important context and requests
4. Core topics discussed in detail
5. User's apparent emotional state and personality traits
6. Important themes and patterns across the conversation
It is very important that you clearly distinguish between the user's messages and the assistant's messages, and that only the user's literal words are attributed to them.
Provide a thorough and detailed summary that captures the essence of the conversation.
Your summary should serve as a comprehensive record of the important information in this conversation.
Return only the summary without any explanation or meta-commentary.
<conversation>
{format_messages(messages)}
</conversation>
<previous_summary>
{previous_summary or ""}
</previous_summary>
"""
async def get_summary(
@ -68,7 +150,7 @@ async def get_summary(
workspace_name: str,
session_name: str,
summary_type: SummaryType = SummaryType.SHORT,
) -> Optional[Summary]:
) -> Summary | None:
"""
Get summary for a given session or peer.
@ -95,7 +177,7 @@ async def get_summary(
# If session doesn't exist, there's no summary to retrieve
return None
summaries = session.internal_metadata.get("summaries", {})
summaries: dict[str, Summary] = session.internal_metadata.get("summaries", {})
if not summaries or label not in summaries:
return None
return summaries[label]
@ -103,9 +185,9 @@ async def get_summary(
async def create_summary(
messages: list[models.Message],
previous_summary_text: Optional[str] = None,
previous_summary_text: str | None = None,
summary_type: SummaryType = SummaryType.SHORT,
max_tokens: Optional[int] = None,
max_tokens: int | None = None,
) -> Summary:
"""
Generate a summary of the provided messages using an LLM.
@ -119,80 +201,17 @@ async def create_summary(
Returns:
A summary of the conversation
"""
# Combine messages into a conversation format string
conversation = format_messages(messages)
# Adjust system prompt based on summary type
if summary_type == SummaryType.LONG:
system_prompt = """You are a system that creates comprehensive summaries of conversations.
Focus on capturing:
1. Key facts and information shared
2. User preferences, opinions, and questions
3. Important context and requests
4. Core topics discussed in detail
5. User's apparent emotional state and personality traits
6. Important themes and patterns across the conversation
It is very important that you clearly distinguish between the user's messages and the assistant's messages, and that only the user's literal words are attributed to them.
Provide a thorough and detailed summary that captures the essence of the conversation.
Your summary should serve as a comprehensive record of the important information in this conversation.
Return only the summary without any explanation or meta-commentary."""
else: # short summary
system_prompt = """You are a system that summarizes parts of a conversation to create a concise and accurate summary.
Focus on capturing:
1. Key facts and information shared
2. User preferences, opinions, and questions
3. Important context and requests
4. Core topics discussed
5. User's apparent emotional state
It is very important that you clearly distinguish between the user's messages and the assistant's messages, and that only the user's literal words are attributed to them.
Provide a concise, factual summary that captures the essence of the conversation.
Your summary should be detailed enough to serve as context for future messages,
but brief enough to be helpful.
Return only the summary without any explanation or meta-commentary."""
# Include previous summary if available
if previous_summary_text:
user_prompt = f"""Here is a previous summary of the conversation:
{previous_summary_text}
Now please summarize these additional messages, incorporating the context from the previous summary.
Your summary should summarize the entire conversation in a self-contained way, such that someone could read it and understand the entire conversation.
{conversation}
Provide a {"comprehensive" if summary_type == SummaryType.LONG else "concise"} summary that captures both the previous context and the new information."""
else:
user_prompt = f"""Please summarize the following conversation segment:
{conversation}
Provide a {"comprehensive" if summary_type == SummaryType.LONG else "concise"} summary that captures the key points and context."""
# Create a model client
client = ModelClient(
provider=ModelProvider(settings.LLM.SUMMARY_PROVIDER),
model=settings.LLM.SUMMARY_MODEL,
)
# Generate the summary
llm_messages = [{"role": "user", "content": user_prompt}]
max_tokens = max_tokens or (
settings.LLM.SUMMARY_MAX_TOKENS_SHORT
if summary_type == SummaryType.SHORT
else settings.LLM.SUMMARY_MAX_TOKENS_LONG
)
try:
summary_text = await client.generate(
messages=llm_messages,
system=system_prompt,
max_tokens=max_tokens,
temperature=0.0,
use_caching=True,
if summary_type == SummaryType.SHORT:
response = await create_short_summary(messages, previous_summary_text)
else:
response = await create_long_summary(messages, previous_summary_text)
summary_text = str(response)
calculated_max_tokens = max_tokens or (
1000 if summary_type == SummaryType.SHORT else 2000
)
logger.info("Successfully generated summary for session")
return Summary(
content=summary_text,
@ -200,15 +219,11 @@ Provide a {"comprehensive" if summary_type == SummaryType.LONG else "concise"} s
summary_type=summary_type.value,
created_at=datetime.datetime.now(datetime.timezone.utc).isoformat(),
message_id=messages[-1].id,
# cheating: just use the max tokens instead of counting
# tokens in response. in the future we can update client
# to return tokens if that's doable.
token_count=max_tokens,
token_count=calculated_max_tokens,
)
except Exception as e:
logger.error(f"Error generating summary: {str(e)}")
# Fallback to a basic summary in case of error
# Do not save this failed summary to the session metadata.
return Summary(
content=(
f"Conversation with {len(messages)} messages about {messages[-1].content[:30]}..."
@ -322,7 +337,7 @@ async def get_latest_summary_and_messages_since(
peer_name: str,
cutoff: int | None = None,
summary_type: SummaryType = SummaryType.SHORT,
) -> tuple[list[models.Message], Optional[Summary]]:
) -> tuple[list[models.Message], Summary | None]:
"""
Get all messages since the latest summary for a session or peer.
@ -376,7 +391,7 @@ async def should_create_summary(
peer_name: str,
message_id: int,
summary_type: SummaryType = SummaryType.SHORT,
) -> tuple[bool, list[models.Message], Optional[Summary]]:
) -> tuple[bool, list[models.Message], Summary | None]:
"""
Determine if a new summary should be created for this object (peer or session).

View File

@ -1,591 +0,0 @@
"""
Utility functions for interacting with various language model APIs.
"""
from enum import Enum
from typing import Any, Optional, Protocol
import sentry_sdk
from anthropic import AsyncAnthropic
from google import genai
from google.genai import types as genai_types
from langfuse.decorators import langfuse_context, observe
# from openai import AsyncOpenAI
from langfuse.openai import AsyncOpenAI
from src.config import settings
# Supported model providers
class ModelProvider(str, Enum):
ANTHROPIC = "anthropic"
OPENAI = "openai"
OPENROUTER = "openrouter"
CEREBRAS = "cerebras"
GROQ = "groq"
GEMINI = "gemini"
# Add other providers as needed
# Default models for each provider
DEFAULT_MODELS = {
ModelProvider.ANTHROPIC: "claude-3-7-sonnet-20250219",
ModelProvider.OPENAI: "gpt-4o",
ModelProvider.OPENROUTER: "meta-llama/Llama-3.3-70B-Instruct",
ModelProvider.CEREBRAS: "llama-3.3-70b",
ModelProvider.GROQ: "llama-3.3-70b-versatile",
ModelProvider.GEMINI: "gemini-2.0-flash-lite",
}
OPENAI_COMPATIBLE_PROVIDERS = [
ModelProvider.OPENAI,
ModelProvider.OPENROUTER,
ModelProvider.CEREBRAS,
ModelProvider.GROQ,
]
DEFAULT_TEMPERATURE: float = settings.LLM.DEFAULT_TEMPERATURE
DEFAULT_MAX_TOKENS: int = settings.LLM.DEFAULT_MAX_TOKENS
# Default embedding model
DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
class Message(Protocol):
"""Protocol for a message that works with any provider."""
role: str
content: str
class ModelClient:
"""A client for interacting with various language model APIs."""
def __init__(
self,
provider: ModelProvider = ModelProvider.ANTHROPIC,
model: Optional[str] = None,
api_key: Optional[str] = None,
base_url: Optional[str] = None,
):
"""
Initialize the model client.
Args:
provider: The model provider to use
model: The specific model to use, or None to use the default model for the provider
api_key: The API key to use, or None to read from environment variables
base_url: Custom base URL for the API endpoints (used for OpenRouter)
"""
self.provider = provider
self.model = model or DEFAULT_MODELS[provider]
self.base_url = base_url
self.openai_client = None
self.gemini_client = None
# Setup provider-specific clients
if provider == ModelProvider.ANTHROPIC:
self.api_key = api_key or settings.LLM.ANTHROPIC_API_KEY
if not self.api_key:
raise ValueError("Anthropic API key is required")
self.client = AsyncAnthropic(api_key=self.api_key)
elif provider in OPENAI_COMPATIBLE_PROVIDERS:
# Use specific API key based on provider
if provider == ModelProvider.OPENAI:
self.api_key = api_key or settings.LLM.OPENAI_API_KEY
elif provider == ModelProvider.GROQ:
self.api_key = api_key or settings.LLM.GROQ_API_KEY
else:
self.api_key = api_key or settings.LLM.OPENAI_COMPATIBLE_API_KEY
self.base_url = base_url or settings.LLM.OPENAI_COMPATIBLE_BASE_URL
if not self.api_key:
raise ValueError(f"{provider.value} API key is required")
self.openai_client = AsyncOpenAI(
api_key=self.api_key, base_url=self.base_url
)
elif provider == ModelProvider.GEMINI:
self.api_key = api_key or settings.LLM.GEMINI_API_KEY
if not self.api_key:
raise ValueError("Gemini API key is required")
self.gemini_client = genai.Client(api_key=self.api_key)
else:
raise ValueError(f"Unsupported provider: {provider}")
def create_message(self, role: str, content: str) -> dict[str, str]:
"""
Create a message that works with the current provider.
Args:
role: The role of the message (e.g., "user", "assistant")
content: The message content
Returns:
A message compatible with the current provider
"""
# For now, just return a dictionary that works with both Anthropic and OpenAI
return {"role": role, "content": content}
@observe()
async def generate(
self,
messages: list[dict[str, Any]],
system: Optional[str] = None,
max_tokens: int = DEFAULT_MAX_TOKENS,
temperature: float = DEFAULT_TEMPERATURE,
extra_headers: Optional[dict[str, str]] = None,
use_caching: bool = False,
) -> str:
"""
Generate a response using the configured model.
Args:
messages: The conversation history
system: Optional system prompt
max_tokens: Maximum number of tokens to generate
temperature: Temperature for generation
extra_headers: Optional headers to add to the request
use_caching: Whether to use provider-side caching for the response
Returns:
The generated text
"""
with sentry_sdk.start_transaction(
op="llm-api", name=f"{self.provider} API Call"
):
# Log to langfuse
langfuse_context.update_current_observation(
input=messages, model=self.model
)
if self.provider == ModelProvider.ANTHROPIC:
return await self._generate_anthropic(
messages,
system,
max_tokens,
temperature,
extra_headers,
use_caching,
)
elif self.provider in OPENAI_COMPATIBLE_PROVIDERS:
return await self._generate_openai(
messages, system, max_tokens, temperature
)
elif self.provider == ModelProvider.GEMINI:
return await self._generate_gemini(
messages, system, max_tokens, temperature
)
else:
raise ValueError(f"Unsupported provider: {self.provider}")
@observe(as_type="generation")
async def _generate_anthropic(
self,
messages: list[dict[str, Any]],
system: Optional[str] = None,
max_tokens: int = DEFAULT_MAX_TOKENS,
temperature: float = DEFAULT_TEMPERATURE,
extra_headers: Optional[dict[str, str]] = None,
use_caching: bool = False,
) -> str:
"""Generate a response using the Anthropic API."""
if not self.client:
raise ValueError("Anthropic client not initialized.")
params = {
"model": self.model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
# Handle system prompt with caching if enabled
if system:
if use_caching:
params["system"] = [
{
"type": "text",
"text": system,
"cache_control": {"type": "ephemeral"},
}
]
else:
params["system"] = system
langfuse_context.update_current_observation(input=messages, model=self.model)
response = await self.client.messages.create(**params)
# Extract the text from the response
if response.content and len(response.content) > 0:
content_block = response.content[0]
# Check content_block by checking for attribute 'type' instead of using isinstance
if (
content_block
and hasattr(content_block, "type")
and content_block.type == "text"
):
return content_block.text
return str(content_block)
return ""
@observe(as_type="generation")
async def _generate_openai(
self,
messages: list[dict[str, str]],
system: Optional[str] = None,
max_tokens: int = DEFAULT_MAX_TOKENS,
temperature: float = DEFAULT_TEMPERATURE,
) -> str:
"""Generate text using OpenAI or OpenRouter API."""
if not self.openai_client:
raise ValueError("OpenAI client not initialized")
# Prepare messages
formatted_messages = []
# Add system message if provided
if system:
formatted_messages.append({"role": "system", "content": system})
# Add the rest of the messages
formatted_messages.extend(messages)
# Make the API call with the OpenAI client
response = await self.openai_client.chat.completions.create(
model=self.model,
messages=formatted_messages,
max_tokens=max_tokens,
temperature=temperature,
)
# Extract the generated text
choice = response.choices[0]
if choice and choice.message and choice.message.content:
return choice.message.content
return ""
@observe()
async def stream(
self,
messages: list[dict[str, Any]],
system: Optional[str] = None,
max_tokens: int = DEFAULT_MAX_TOKENS,
temperature: float = DEFAULT_TEMPERATURE,
extra_headers: Optional[dict[str, str]] = None,
use_caching: bool = False,
) -> Any:
"""
Stream a response using the configured model.
Args:
messages: The conversation history
system: Optional system prompt
max_tokens: Maximum number of tokens to generate
temperature: Temperature for generation
extra_headers: Optional headers to add to the request
use_caching: Whether to use provider-side caching for the response
Returns:
A streaming response from the provider
"""
with sentry_sdk.start_transaction(
op="llm-api-stream", name=f"{self.provider} API Stream"
):
# Log to langfuse
langfuse_context.update_current_observation(
input=messages, model=self.model
)
if self.provider == ModelProvider.ANTHROPIC:
return await self._stream_anthropic(
messages,
system,
max_tokens,
temperature,
extra_headers,
use_caching,
)
elif self.provider in OPENAI_COMPATIBLE_PROVIDERS:
return await self._stream_openai(
messages, system, max_tokens, temperature
)
elif self.provider == ModelProvider.GEMINI:
return await self._stream_gemini(
messages, system, max_tokens, temperature
)
else:
raise ValueError(f"Unsupported provider: {self.provider}")
@observe(as_type="generation")
async def _stream_anthropic(
self,
messages: list[dict[str, Any]],
system: Optional[str] = None,
max_tokens: int = DEFAULT_MAX_TOKENS,
temperature: float = DEFAULT_TEMPERATURE,
extra_headers: Optional[dict[str, str]] = None,
use_caching: bool = False,
) -> Any:
"""Stream text using Anthropic API."""
if not self.client:
raise ValueError("Anthropic client not initialized.")
params = {
"model": self.model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
}
# Handle system prompt with caching if enabled
if system:
if use_caching:
params["system"] = [
{
"type": "text",
"text": system,
"cache_control": {"type": "ephemeral"},
}
]
else:
params["system"] = system
langfuse_context.update_current_observation(input=messages, model=self.model)
# Return the stream directly without awaiting it
return self.client.messages.stream(**params)
@observe(as_type="generation")
async def _stream_openai(
self,
messages: list[dict[str, str]],
system: Optional[str] = None,
max_tokens: int = DEFAULT_MAX_TOKENS,
temperature: float = DEFAULT_TEMPERATURE,
) -> Any:
"""Stream text using OpenAI or OpenRouter API."""
if not self.openai_client:
raise ValueError("OpenAI client not initialized")
# Prepare messages
formatted_messages = []
# Add system message if provided
if system:
formatted_messages.append({"role": "system", "content": system})
# Add the rest of the messages
formatted_messages.extend(messages)
# Make the API call with the OpenAI client
stream = await self.openai_client.chat.completions.create(
model=self.model,
messages=formatted_messages,
max_tokens=max_tokens,
temperature=temperature,
stream=True,
)
return stream
@observe(as_type="generation")
async def _generate_gemini(
self,
messages: list[dict[str, str]],
system: Optional[str] = None,
max_tokens: int = DEFAULT_MAX_TOKENS,
temperature: float = DEFAULT_TEMPERATURE,
) -> str:
"""Generate text using Gemini API."""
if not self.gemini_client:
raise ValueError("Gemini client not initialized")
# Format messages for Gemini
gemini_messages = []
# Convert messages to Gemini format
for message in messages:
role = message["role"]
# Map roles to what Gemini expects
if role == "user":
gemini_role = "user"
elif role == "assistant":
gemini_role = "model"
else:
# Skip system messages as they're handled through config
continue
gemini_messages.append(
genai_types.Content(
role=gemini_role,
parts=[genai_types.Part.from_text(text=message["content"])],
)
)
# Set generation config
generate_content_config = genai_types.GenerateContentConfig(
temperature=temperature,
max_output_tokens=max_tokens,
response_mime_type="text/plain",
)
# Add system instruction if provided
if system:
generate_content_config.system_instruction = system
# Make the API call
if not gemini_messages:
# If we have no messages but have a system prompt, create a default user message
if system:
default_content = genai_types.Content(
role="user",
parts=[
genai_types.Part.from_text(
text="Please respond based on the system instructions."
)
],
)
# model = self.gemini_client.get_model(self.model)
response = await self.gemini_client.aio.models.generate_content(
model=self.model,
contents=default_content,
config=generate_content_config,
)
else:
raise ValueError("No messages provided for Gemini generation")
else:
# Normal case with messages
# model = get_model(self.model)
response = await self.gemini_client.aio.models.generate_content(
model=self.model,
contents=gemini_messages
if len(gemini_messages) > 1
else gemini_messages[0],
config=generate_content_config,
)
# Extract text from response
if response and response.text:
return response.text
return ""
@observe(as_type="generation")
async def _stream_gemini(
self,
messages: list[dict[str, str]],
system: Optional[str] = None,
max_tokens: int = DEFAULT_MAX_TOKENS,
temperature: float = DEFAULT_TEMPERATURE,
) -> Any:
"""Stream text using Gemini API."""
if not self.gemini_client:
raise ValueError("Gemini client not initialized")
# Format messages for Gemini
gemini_messages = []
# Convert messages to Gemini format
for message in messages:
role = message["role"]
# Map roles to what Gemini expects
if role == "user":
gemini_role = "user"
elif role == "assistant":
gemini_role = "model"
else:
# Skip system messages as they're handled through config
continue
gemini_messages.append(
genai_types.Content(
role=gemini_role,
parts=[genai_types.Part.from_text(text=message["content"])],
)
)
# Set generation config
generate_content_config = genai_types.GenerateContentConfig(
temperature=temperature,
max_output_tokens=max_tokens,
response_mime_type="text/plain",
)
# Add system instruction if provided
if system:
generate_content_config.system_instruction = system
# Make the streaming API call
if not gemini_messages:
# If we have no messages but have a system prompt, create a default user message
if system:
default_content = genai_types.Content(
role="user",
parts=[
genai_types.Part.from_text(
text="Please respond based on the system instructions."
)
],
)
stream = await self.gemini_client.aio.models.generate_content_stream(
model=self.model,
contents=default_content,
config=generate_content_config,
)
else:
raise ValueError("No messages provided for Gemini streaming")
else:
# Normal case with messages
stream = await self.gemini_client.aio.models.generate_content_stream(
model=self.model,
contents=gemini_messages
if len(gemini_messages) > 1
else gemini_messages[0],
config=generate_content_config,
)
return stream
@observe()
async def embed(
self,
text: str,
model: Optional[str] = None,
) -> list[float]:
"""
Generate embeddings for the given text.
Args:
text: The text to embed
model: The embedding model to use (optional, defaults to text-embedding-3-small)
Returns:
The embedding vector as a list of floats
"""
# Currently only supports OpenAI-compatible providers for embeddings
if self.provider not in [ModelProvider.OPENAI, ModelProvider.OPENROUTER]:
raise ValueError(
f"Embeddings not supported for provider: {self.provider}. "
"Please use OpenAI or OpenRouter for embeddings."
)
if not self.openai_client:
raise ValueError("OpenAI client not initialized")
embedding_model = model or DEFAULT_EMBEDDING_MODEL
with sentry_sdk.start_transaction(
op="embedding-api", name=f"{self.provider} Embedding Call"
):
langfuse_context.update_current_observation(
input=text, model=embedding_model
)
response = await self.openai_client.embeddings.create(
model=embedding_model, input=text
)
return response.data[0].embedding

22
src/utils/types.py Normal file
View File

@ -0,0 +1,22 @@
from collections.abc import Callable
from typing import Literal, ParamSpec, TypeVar
from mirascope import Provider
from sentry_sdk.ai.monitoring import ai_track
R = TypeVar("R")
P = ParamSpec("P")
def track(description: str) -> Callable[[Callable[P, R]], Callable[P, R]]:
def decorator(f: Callable[P, R]) -> Callable[P, R]:
def _inner(*args: P.args, **kwargs: P.kwargs) -> R:
result: R = ai_track(description)(f)(*args, **kwargs)
return result
return _inner
return decorator
Providers = Provider | Literal["custom"]

View File

@ -1,35 +1,48 @@
import logging # noqa: I001
import jwt
from nanoid import generate as generate_nanoid
from unittest.mock import patch, MagicMock, AsyncMock
from collections.abc import AsyncGenerator
from typing import Any
from collections.abc import Callable
from unittest.mock import AsyncMock, MagicMock, patch
import jwt
import pytest
import pytest_asyncio
from fastapi import Request
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from nanoid import generate as generate_nanoid
from sqlalchemy import text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.engine.url import make_url
from sqlalchemy.engine.url import URL, make_url
from sqlalchemy.exc import OperationalError, ProgrammingError
from sqlalchemy_utils import create_database, database_exists, drop_database
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy_utils import (
create_database, # pyright: ignore[reportUnknownVariableType]
database_exists, # pyright: ignore[reportUnknownVariableType]
drop_database, # pyright: ignore[reportUnknownVariableType]
)
from src import models
from src.db import Base
from src.dependencies import get_db
from src.exceptions import HonchoException
from src.security import create_admin_jwt, create_jwt, JWTParams
from src.main import app
from src.config import settings
from src.models import Peer, Workspace
from src.security import JWTParams, create_admin_jwt, create_jwt
# Create a custom handler that doesn't get closed prematurely
class TestHandler(logging.Handler):
def __init__(self):
super().__init__()
self.records = []
self.records: list[logging.LogRecord] = []
def emit(self, record):
def emit(self, record: logging.LogRecord):
self.records.append(record)
@ -57,7 +70,7 @@ DEFAULT_DB_URL = str(CONNECTION_URI.set(database="postgres"))
# We'll use settings.AUTH directly where needed
def create_test_database(db_url):
def create_test_database(db_url: URL):
"""Helper function create a database if it does not already exist
uses the `sqlalchemy_utils` library to create the database and takes a DB URL
as the input
@ -78,7 +91,7 @@ def create_test_database(db_url):
raise
async def setup_test_database(db_url):
async def setup_test_database(db_url: URL):
"""Helper function to setup the test database
takes a DB URL as input and returns a SQLAlchemy engine
@ -120,7 +133,7 @@ async def db_engine():
# Save the original schema to restore later
original_schema = Base.metadata.schema
Base.metadata.schema = "public"
# Update all table schemas to public
for table in Base.metadata.tables.values():
table.schema = "public"
@ -144,7 +157,7 @@ async def db_engine():
@pytest_asyncio.fixture(scope="function")
async def db_session(db_engine):
async def db_session(db_engine: AsyncEngine):
"""Create a database session for the scope of a single test function"""
Session = async_sessionmaker(bind=db_engine, expire_on_commit=False)
async with Session() as session:
@ -153,12 +166,14 @@ async def db_session(db_engine):
@pytest.fixture(scope="function")
async def client(db_session):
async def client(db_session: AsyncSession):
"""Create a FastAPI TestClient for the scope of a single test function"""
# Register exception handlers for tests
@app.exception_handler(HonchoException)
async def test_exception_handler(request: Request, exc: HonchoException):
async def test_exception_handler( # pyright: ignore
_: Request, exc: HonchoException
):
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
@ -176,7 +191,13 @@ async def client(db_session):
def create_invalid_jwt() -> str:
return jwt.encode({"ad": "invalid"}, "this is not the secret", algorithm="HS256")
return jwt.encode( # pyright: ignore[reportUnknownMemberType]
{"ad": "invalid"}, "this is not the secret", algorithm="HS256"
)
class AuthClient(TestClient):
auth_type: str | None = None
@pytest.fixture(
@ -187,7 +208,11 @@ def create_invalid_jwt() -> str:
("admin", create_admin_jwt), # Admin JWT
]
)
def auth_client(client, request, monkeypatch):
def auth_client(
client: AuthClient,
request: pytest.FixtureRequest,
monkeypatch: pytest.MonkeyPatch,
):
"""
Fixture that provides a client with different authentication states.
Always ensures USE_AUTH is set to True.
@ -210,7 +235,9 @@ def auth_client(client, request, monkeypatch):
@pytest_asyncio.fixture(scope="function")
async def sample_data(db_session):
async def sample_data(
db_session: AsyncSession,
) -> AsyncGenerator[tuple[Workspace, Peer], Any]:
"""Helper function to create test data"""
# Create test app
test_workspace = models.Workspace(name=str(generate_nanoid()))
@ -237,7 +264,10 @@ def mock_langfuse():
patch("langfuse.decorators.langfuse_context") as mock_context,
):
# Mock the decorator to just return the function
mock_observe.return_value = lambda func: func
def return_value(func: Callable[..., Any]):
return func
mock_observe.return_value = return_value
# Mock the context object
mock_context_obj = MagicMock()
@ -260,56 +290,98 @@ def mock_langfuse():
@pytest.fixture(autouse=True)
def mock_openai_embeddings():
"""Mock OpenAI embeddings API calls for testing"""
with patch("src.crud.openai_client.embeddings.create") as mock_create:
mock_response = AsyncMock()
mock_response.data = [MagicMock(embedding=[0.1] * 1536)]
mock_create.return_value = mock_response
with patch("src.crud.embedding_client.embed") as mock_create:
mock_create.return_value = [0.1] * 1536
yield mock_create
@pytest.fixture(autouse=True)
def mock_model_client(request):
"""Mock ModelClient to avoid needing API keys during tests"""
# Skip mocking for ModelClient unit tests
if "test_model_client" in request.node.name or "test_model_client.py" in str(
request.fspath
):
yield None
return
# Create a mock instance
mock_client_instance = MagicMock()
mock_client_instance.generate = AsyncMock(return_value="Test summary content")
mock_client_instance.stream = AsyncMock()
def mock_mirascope_functions():
"""Mock Mirascope LLM functions to avoid needing API keys during tests"""
# Create mock responses for different function types
with (
patch("src.utils.history.ModelClient") as mock_history_client,
patch("src.deriver.tom.single_prompt.ModelClient") as mock_single_prompt_client,
patch("src.deriver.tom.long_term.ModelClient") as mock_long_term_client,
patch("src.agent.ModelClient") as mock_agent_client,
patch(
"src.utils.history.create_short_summary", new_callable=AsyncMock
) as mock_short_summary,
patch(
"src.utils.history.create_long_summary", new_callable=AsyncMock
) as mock_long_summary,
patch(
"src.deriver.tom.single_prompt.tom_inference", new_callable=AsyncMock
) as mock_tom_inference,
patch(
"src.deriver.tom.tom_inference_single_prompt",
new_callable=AsyncMock,
) as mock_tom_inference_conversational,
patch(
"src.deriver.tom.single_prompt.user_representation", new_callable=AsyncMock
) as mock_user_rep_inference,
patch(
"src.deriver.tom.long_term.get_user_representation_long_term",
new_callable=AsyncMock,
) as mock_long_rep,
patch(
"src.deriver.tom.long_term.extract_facts_long_term",
new_callable=AsyncMock,
) as mock_extract_facts,
patch(
"src.agent.dialectic_call", new_callable=AsyncMock
) as mock_dialectic_call,
patch(
"src.agent.dialectic_stream", new_callable=AsyncMock
) as mock_dialectic_stream,
patch(
"src.agent.generate_semantic_queries_llm", new_callable=AsyncMock
) as mock_semantic_queries,
):
# Make all class constructors return our mock instance
mock_history_client.return_value = mock_client_instance
mock_single_prompt_client.return_value = mock_client_instance
mock_long_term_client.return_value = mock_client_instance
mock_agent_client.return_value = mock_client_instance
# Mock return values for different function types
mock_short_summary.return_value = "Test short summary content"
mock_long_summary.return_value = "Test long summary content"
mock_tom_inference.return_value = AsyncMock(inference="Test tom inference")
mock_tom_inference_conversational.return_value = AsyncMock(
inference="Test tom inference"
)
mock_user_rep_inference.return_value = "Test user representation"
# Mock single_tom to return a proper Pydantic object
mock_long_rep.return_value = MagicMock(
current_state="Test state",
tentative_patterns=[],
knowledge_gaps=[],
expectation_violations=[],
updates=[],
)
mock_extract_facts.return_value = MagicMock(facts=["fact 1", "fact 2"])
# Create a proper async mock result for dialectic_call
mock_dialectic_result = MagicMock()
mock_dialectic_result.content = "Test dialectic response"
mock_dialectic_call.return_value = mock_dialectic_result
mock_dialectic_stream.return_value = AsyncMock()
mock_semantic_queries.return_value = ["test query 1", "test query 2"]
yield {
"history": mock_history_client,
"single_prompt": mock_single_prompt_client,
"long_term": mock_long_term_client,
"agent": mock_agent_client,
"instance": mock_client_instance,
"short_summary": mock_short_summary,
"long_summary": mock_long_summary,
"tom_inference": mock_tom_inference,
"tom_inference_conversational": mock_tom_inference_conversational,
"user_rep_inference": mock_user_rep_inference,
"long_rep": mock_long_rep,
"extract_facts": mock_extract_facts,
"dialectic_call": mock_dialectic_call,
"dialectic_stream": mock_dialectic_stream,
"semantic_queries": mock_semantic_queries,
}
@pytest.fixture(autouse=True)
def mock_tracked_db(db_session):
def mock_tracked_db(db_session: AsyncSession):
"""Mock tracked_db to use the test database session"""
from contextlib import asynccontextmanager
@asynccontextmanager
async def mock_tracked_db_context(operation_name=None):
async def mock_tracked_db_context(_: str | None = None):
yield db_session
with patch("src.dependencies.tracked_db", mock_tracked_db_context):
@ -324,11 +396,11 @@ def mock_crud_collection_operations():
from src import models
async def mock_get_or_create_collection(
db, workspace_name, peer_name, collection_name
_: AsyncSession, workspace_name: str, peer_name: str, collection_name: str
):
# Create a mock collection object that doesn't require database commit
mock_collection = models.Collection(
name="honcho",
name=collection_name,
workspace_name=workspace_name,
peer_name=peer_name,
)
@ -343,27 +415,23 @@ def mock_crud_collection_operations():
@pytest.fixture(autouse=True)
def mock_agent_api_calls(request):
def mock_agent_api_calls():
"""Mock API calls made by the agent during tests"""
# Mock the agent-specific functions
with (
patch("src.agent.generate_semantic_queries") as mock_generate_queries,
patch("src.deriver.tom.get_tom_inference") as mock_tom_inference,
patch("src.agent.get_user_representation_long_term") as mock_user_rep,
patch(
"src.deriver.tom.embeddings.CollectionEmbeddingStore.get_relevant_facts"
) as mock_get_facts,
patch("src.agent.Dialectic.call") as mock_dialectic_call,
patch("src.agent.Dialectic.stream") as mock_dialectic_stream,
patch(
"src.agent.dialectic_call", new_callable=AsyncMock
) as mock_dialectic_call,
patch("src.agent.dialectic_stream") as mock_dialectic_stream,
):
# Mock semantic query generation
mock_generate_queries.return_value = ["test query 1", "test query 2"]
# Mock ToM inference
mock_tom_inference.return_value = (
"<prediction>Test prediction about user mental state</prediction>"
)
# Mock user representation generation
mock_user_rep.return_value = (
"<representation>Test user representation</representation>"
@ -373,12 +441,13 @@ def mock_agent_api_calls(request):
mock_get_facts.return_value = ["fact 1", "fact 2", "fact 3"]
# Mock Dialectic API calls
mock_dialectic_call.return_value = [{"text": "Test dialectic response"}]
mock_dialectic_result = MagicMock()
mock_dialectic_result.content = "Test dialectic response"
mock_dialectic_call.return_value = mock_dialectic_result
mock_dialectic_stream.return_value = AsyncMock()
yield {
"generate_queries": mock_generate_queries,
"tom_inference": mock_tom_inference,
"user_rep": mock_user_rep,
"get_facts": mock_get_facts,
"dialectic_call": mock_dialectic_call,

View File

@ -1,12 +1,13 @@
from typing import Optional
from unittest.mock import patch
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from nanoid import generate as generate_nanoid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src import crud, models, schemas
from src.models import QueueItem
from src.models import Peer, QueueItem, Workspace
from src.routers.messages import enqueue
@ -17,10 +18,10 @@ class TestEnqueueFunction:
# Helper methods
def create_sample_payload(
self,
workspace_name="test_workspace",
session_name: Optional[str] = "test_session",
peer_name="test_peer",
count=1,
workspace_name: str = "test_workspace",
session_name: str | None = "test_session",
peer_name: str = "test_peer",
count: int = 1,
):
"""Create sample payload for testing"""
return [
@ -35,14 +36,14 @@ class TestEnqueueFunction:
for i in range(count)
]
async def count_queue_items(self, db_session):
async def count_queue_items(self, db_session: AsyncSession):
"""Helper to count queue items in database"""
result = await db_session.execute(select(QueueItem))
return len(result.scalars().all())
# Edge Cases & Input Validation Tests
@pytest.mark.asyncio
async def test_empty_payload_skips_enqueue(self, caplog):
async def test_empty_payload_skips_enqueue(self, caplog: pytest.LogCaptureFixture):
"""Test that empty payload list is handled gracefully"""
with caplog.at_level("DEBUG"):
await enqueue([])
@ -50,7 +51,7 @@ class TestEnqueueFunction:
assert "Empty payload list, skipping enqueue" in caplog.text
@pytest.mark.asyncio
async def test_malformed_payload_logs_error(self, caplog):
async def test_malformed_payload_logs_error(self, caplog: pytest.LogCaptureFixture):
"""Test that malformed payload logs appropriate error"""
malformed_payload = [{"incomplete": "data"}]
@ -64,7 +65,10 @@ class TestEnqueueFunction:
@pytest.mark.asyncio
@patch("src.routers.messages.tracked_db")
async def test_none_session_with_observe_me_true(
self, mock_tracked_db, db_session, sample_data
self,
mock_tracked_db: AsyncMock,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test peer-only processing when observe_me=True"""
mock_tracked_db.return_value.__aenter__.return_value = db_session
@ -99,7 +103,11 @@ class TestEnqueueFunction:
@pytest.mark.asyncio
@patch("src.routers.messages.tracked_db")
async def test_none_session_with_observe_me_false(
self, mock_tracked_db, db_session, sample_data, caplog
self,
mock_tracked_db: AsyncMock,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
caplog: pytest.LogCaptureFixture,
):
"""Test peer-only processing when observe_me=False"""
mock_tracked_db.return_value.__aenter__.return_value = db_session
@ -131,7 +139,10 @@ class TestEnqueueFunction:
@pytest.mark.asyncio
@patch("src.routers.messages.tracked_db")
async def test_session_with_deriver_disabled(
self, mock_tracked_db, db_session, sample_data, caplog
self,
mock_tracked_db: AsyncMock,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test that deriver disabled sessions skip enqueue"""
mock_tracked_db.return_value.__aenter__.return_value = db_session
@ -163,7 +174,10 @@ class TestEnqueueFunction:
@pytest.mark.asyncio
@patch("src.routers.messages.tracked_db")
async def test_session_normal_processing_single_peer(
self, mock_tracked_db, db_session, sample_data
self,
mock_tracked_db: AsyncMock,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
mock_tracked_db.return_value.__aenter__.return_value = db_session
@ -204,7 +218,10 @@ class TestEnqueueFunction:
@pytest.mark.asyncio
@patch("src.routers.messages.tracked_db")
async def test_session_with_multiple_peers_none_observe_others(
self, mock_tracked_db, db_session, sample_data
self,
mock_tracked_db: AsyncMock,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test session processing with multiple peers where some observe others"""
mock_tracked_db.return_value.__aenter__.return_value = db_session
@ -254,7 +271,7 @@ class TestEnqueueFunction:
# Explicitly match up payloads by sender/target/task_type
# For each message, we expect a representation and a summary queue item
expected_payloads = []
expected_payloads: list[dict[str, Any]] = []
for _ in range(NUM_MESSAGES):
expected_payloads.append(
{
@ -280,7 +297,10 @@ class TestEnqueueFunction:
@pytest.mark.asyncio
@patch("src.routers.messages.tracked_db")
async def test_session_with_multiple_peers_all_observe_others(
self, mock_tracked_db, db_session, sample_data
self,
mock_tracked_db: AsyncMock,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test session processing with multiple peers where some observe others"""
mock_tracked_db.return_value.__aenter__.return_value = db_session
@ -329,7 +349,7 @@ class TestEnqueueFunction:
# Explicitly match up payloads by sender/target/task_type
# For each message, we expect a representation and a summary queue item
expected_payloads = []
expected_payloads: list[dict[str, Any]] = []
for _ in range(NUM_MESSAGES):
expected_payloads.append(
{
@ -362,7 +382,10 @@ class TestEnqueueFunction:
@pytest.mark.asyncio
@patch("src.routers.messages.tracked_db")
async def test_session_with_multiple_peers_some_observe_others(
self, mock_tracked_db, db_session, sample_data
self,
mock_tracked_db: AsyncMock,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test session processing with multiple peers where some observe others"""
mock_tracked_db.return_value.__aenter__.return_value = db_session
@ -419,7 +442,7 @@ class TestEnqueueFunction:
# Explicitly match up payloads by sender/target/task_type
# For each message, we expect a representation and a summary queue item
expected_payloads = []
expected_payloads: list[dict[str, Any]] = []
for _ in range(NUM_MESSAGES):
expected_payloads.append(
{
@ -456,7 +479,10 @@ class TestEnqueueFunction:
@pytest.mark.asyncio
@patch("src.routers.messages.tracked_db")
async def test_session_peer_config_overrides_peer_config(
self, mock_tracked_db, db_session, sample_data
self,
mock_tracked_db: AsyncMock,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test that session peer config overrides peer config"""
mock_tracked_db.return_value.__aenter__.return_value = db_session
@ -500,7 +526,10 @@ class TestEnqueueFunction:
@pytest.mark.asyncio
@patch("src.routers.messages.tracked_db")
async def test_multi_sender_scenario(
self, mock_tracked_db, db_session, sample_data
self,
mock_tracked_db: AsyncMock,
db_session: AsyncSession,
sample_data: tuple[Workspace, Peer],
):
"""Test complex scenario with multiple peers and mixed configurations"""
mock_tracked_db.return_value.__aenter__.return_value = db_session
@ -562,7 +591,7 @@ class TestEnqueueFunction:
queue_items = result.all()
# Build expected payloads for this scenario
expected_payloads = []
expected_payloads: list[dict[str, Any]] = []
# 2 messages: one from test_peer1, one from additional_sender_peer
for sender in [test_peer1.name, additional_sender_peer.name]:
# representation for sender (self)

View File

@ -1,4 +1,8 @@
def test_create_key_no_params(auth_client):
from src.models import Peer, Workspace
from tests.conftest import AuthClient
def test_create_key_no_params(auth_client: AuthClient):
"""Test creating a key with no parameters"""
response = auth_client.post("/v2/keys")
@ -10,7 +14,9 @@ def test_create_key_no_params(auth_client):
assert response.status_code == 401
def test_create_key_with_params(auth_client, sample_data):
def test_create_key_with_params(
auth_client: AuthClient, sample_data: tuple[Workspace, Peer]
):
"""Test creating a key with specific parameters"""
test_workspace, test_peer = sample_data
@ -46,7 +52,9 @@ def test_create_key_with_params(auth_client, sample_data):
assert "key" in response.json()
def test_create_key_with_expires_at(auth_client, sample_data):
def test_create_key_with_expires_at(
auth_client: AuthClient, sample_data: tuple[Workspace, Peer]
):
"""Test creating a key with an expiration date"""
response = auth_client.post("/v2/keys", params={"expires_at": "2025-01-01"})

View File

@ -1,11 +1,16 @@
import pytest
from fastapi.testclient import TestClient
from nanoid import generate as generate_nanoid
from sqlalchemy.ext.asyncio import AsyncSession
from src import models # Import your SQLAlchemy models
from src.models import Peer, Workspace
@pytest.mark.asyncio
async def test_create_message(client, db_session, sample_data):
async def test_create_message(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
# Create a test session
@ -39,7 +44,9 @@ async def test_create_message(client, db_session, sample_data):
@pytest.mark.asyncio
async def test_create_batch_messages_with_metadata(client, db_session, sample_data):
async def test_create_batch_messages_with_metadata(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
"""Test batch message creation with metadata for each message"""
test_workspace, test_peer = sample_data
@ -81,7 +88,9 @@ async def test_create_batch_messages_with_metadata(client, db_session, sample_da
@pytest.mark.asyncio
async def test_create_batch_messages_without_metadata(client, db_session, sample_data):
async def test_create_batch_messages_without_metadata(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
"""Test batch message creation without metadata (should default to empty dict)"""
test_workspace, test_peer = sample_data
@ -112,7 +121,7 @@ async def test_create_batch_messages_without_metadata(client, db_session, sample
@pytest.mark.asyncio
async def test_create_batch_messages_with_null_metadata(
client, db_session, sample_data
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
"""Test batch message creation with null metadata"""
test_workspace, test_peer = sample_data
@ -144,7 +153,9 @@ async def test_create_batch_messages_with_null_metadata(
@pytest.mark.asyncio
async def test_get_messages(client, db_session, sample_data):
async def test_get_messages(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
# Create a test session and message
@ -178,7 +189,9 @@ async def test_get_messages(client, db_session, sample_data):
@pytest.mark.asyncio
async def test_get_messages_with_reverse(client, db_session, sample_data):
async def test_get_messages_with_reverse(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
"""Test getting messages with reverse parameter"""
test_workspace, test_peer = sample_data
@ -232,7 +245,9 @@ async def test_get_messages_with_reverse(client, db_session, sample_data):
@pytest.mark.asyncio
async def test_get_messages_with_empty_filter(client, db_session, sample_data):
async def test_get_messages_with_empty_filter(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
"""Test getting messages with empty filter object"""
test_workspace, test_peer = sample_data
@ -263,7 +278,9 @@ async def test_get_messages_with_empty_filter(client, db_session, sample_data):
@pytest.mark.asyncio
async def test_get_messages_with_null_filter(client, db_session, sample_data):
async def test_get_messages_with_null_filter(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
"""Test getting messages with null filter"""
test_workspace, test_peer = sample_data
@ -294,7 +311,9 @@ async def test_get_messages_with_null_filter(client, db_session, sample_data):
@pytest.mark.asyncio
async def test_get_messages_no_body(client, db_session, sample_data):
async def test_get_messages_no_body(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
"""Test getting messages without any request body"""
test_workspace, test_peer = sample_data
@ -324,7 +343,9 @@ async def test_get_messages_no_body(client, db_session, sample_data):
@pytest.mark.asyncio
async def test_get_filtered_messages(client, db_session, sample_data):
async def test_get_filtered_messages(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
# Create a test session and messages
@ -368,7 +389,7 @@ async def test_get_filtered_messages(client, db_session, sample_data):
@pytest.mark.asyncio
async def test_get_filtered_messages_with_complex_filter(
client, db_session, sample_data
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
"""Test getting messages with complex metadata filter"""
test_workspace, test_peer = sample_data
@ -419,7 +440,9 @@ async def test_get_filtered_messages_with_complex_filter(
@pytest.mark.asyncio
async def test_update_message(client, db_session, sample_data):
async def test_update_message(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
# Create a test session and message
@ -448,7 +471,9 @@ async def test_update_message(client, db_session, sample_data):
@pytest.mark.asyncio
async def test_update_message_with_complex_metadata(client, db_session, sample_data):
async def test_update_message_with_complex_metadata(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
"""Test updating message with complex metadata structure"""
test_workspace, test_peer = sample_data
@ -485,7 +510,9 @@ async def test_update_message_with_complex_metadata(client, db_session, sample_d
@pytest.mark.asyncio
async def test_update_message_empty_metadata(client, db_session, sample_data):
async def test_update_message_empty_metadata(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
# note that this should not change the metadata of the message.
# this test is to ensure that the metadata is not changed when it is set to None.
@ -524,7 +551,9 @@ async def test_update_message_empty_metadata(client, db_session, sample_data):
@pytest.mark.asyncio
async def test_update_message_with_empty_dict_metadata(client, db_session, sample_data):
async def test_update_message_with_empty_dict_metadata(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
"""Test updating message with empty dictionary metadata"""
test_workspace, test_peer = sample_data
@ -555,7 +584,9 @@ async def test_update_message_with_empty_dict_metadata(client, db_session, sampl
@pytest.mark.asyncio
async def test_get_single_message(client, db_session, sample_data):
async def test_get_single_message(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
# Create a test session and message
@ -587,9 +618,11 @@ async def test_get_single_message(client, db_session, sample_data):
@pytest.mark.asyncio
async def test_get_nonexistent_message(client, db_session, sample_data):
async def test_get_nonexistent_message(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
"""Test getting a message that doesn't exist"""
test_workspace, test_peer = sample_data
test_workspace, _ = sample_data
# Create a test session
test_session = models.Session(
@ -606,9 +639,11 @@ async def test_get_nonexistent_message(client, db_session, sample_data):
@pytest.mark.asyncio
async def test_update_nonexistent_message(client, db_session, sample_data):
async def test_update_nonexistent_message(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
"""Test updating a message that doesn't exist"""
test_workspace, test_peer = sample_data
test_workspace, _ = sample_data
# Create a test session
test_session = models.Session(
@ -626,7 +661,9 @@ async def test_update_nonexistent_message(client, db_session, sample_data):
@pytest.mark.asyncio
async def test_create_messages_for_nonexistent_session(client, sample_data):
async def test_create_messages_for_nonexistent_session(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test creating messages for a session that doesn't exist - should create the session"""
test_workspace, test_peer = sample_data
@ -653,9 +690,11 @@ async def test_create_messages_for_nonexistent_session(client, sample_data):
@pytest.mark.asyncio
async def test_get_messages_for_nonexistent_session(client, sample_data):
async def test_get_messages_for_nonexistent_session(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test getting messages for a session that doesn't exist - should return empty list"""
test_workspace, test_peer = sample_data
test_workspace, _ = sample_data
nonexistent_session_id = str(generate_nanoid())
response = client.post(
@ -670,9 +709,11 @@ async def test_get_messages_for_nonexistent_session(client, sample_data):
@pytest.mark.asyncio
async def test_create_empty_batch_messages(client, db_session, sample_data):
async def test_create_empty_batch_messages(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
"""Test creating an empty batch of messages"""
test_workspace, test_peer = sample_data
test_workspace, _ = sample_data
# Create a test session
test_session = models.Session(
@ -690,7 +731,9 @@ async def test_create_empty_batch_messages(client, db_session, sample_data):
@pytest.mark.asyncio
async def test_create_batch_messages_max_limit(client, db_session, sample_data):
async def test_create_batch_messages_max_limit(
client: TestClient, db_session: AsyncSession, sample_data: tuple[Workspace, Peer]
):
"""Test creating batch messages at the maximum limit (100 messages)"""
test_workspace, test_peer = sample_data

View File

@ -1,7 +1,10 @@
from fastapi.testclient import TestClient
from nanoid import generate as generate_nanoid
from src.models import Peer, Workspace
def test_get_or_create_peer(client, sample_data):
def test_get_or_create_peer(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, _ = sample_data
name = str(generate_nanoid())
response = client.post(
@ -15,7 +18,9 @@ def test_get_or_create_peer(client, sample_data):
assert "id" in data
def test_get_or_create_peer_with_configuration(client, sample_data):
def test_get_or_create_peer_with_configuration(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer creation with configuration parameter"""
test_workspace, _ = sample_data
name = str(generate_nanoid())
@ -31,7 +36,9 @@ def test_get_or_create_peer_with_configuration(client, sample_data):
assert data["configuration"] == configuration
def test_get_or_create_peer_with_all_optional_params(client, sample_data):
def test_get_or_create_peer_with_all_optional_params(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer creation with all optional parameters"""
test_workspace, _ = sample_data
name = str(generate_nanoid())
@ -49,7 +56,9 @@ def test_get_or_create_peer_with_all_optional_params(client, sample_data):
assert data["configuration"] == configuration
def test_get_or_create_existing_peer(client, sample_data):
def test_get_or_create_existing_peer(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, _ = sample_data
name = str(generate_nanoid())
@ -74,7 +83,7 @@ def test_get_or_create_existing_peer(client, sample_data):
assert peer1["metadata"] == peer2["metadata"]
def test_get_peers(client, sample_data):
def test_get_peers(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, _ = sample_data
# Create a few peers with metadata
@ -113,7 +122,9 @@ def test_get_peers(client, sample_data):
assert data["items"][0]["metadata"]["peer_key"] == "peer_value"
def test_get_peers_with_empty_filter(client, sample_data):
def test_get_peers_with_empty_filter(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer listing with empty filter object"""
test_workspace, _ = sample_data
@ -126,7 +137,9 @@ def test_get_peers_with_empty_filter(client, sample_data):
assert isinstance(data["items"], list)
def test_get_peers_with_null_filter(client, sample_data):
def test_get_peers_with_null_filter(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer listing with null filter"""
test_workspace, _ = sample_data
@ -139,7 +152,7 @@ def test_get_peers_with_null_filter(client, sample_data):
assert isinstance(data["items"], list)
def test_update_peer(client, sample_data):
def test_update_peer(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, test_peer = sample_data
response = client.put(
f"/v2/workspaces/{test_workspace.name}/peers/{test_peer.name}",
@ -150,7 +163,9 @@ def test_update_peer(client, sample_data):
assert data["metadata"] == {"new_key": "new_value"}
def test_update_peer_with_configuration(client, sample_data):
def test_update_peer_with_configuration(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer update with configuration parameter"""
test_workspace, test_peer = sample_data
configuration = {"new_feature": True, "legacy_feature": False}
@ -164,7 +179,9 @@ def test_update_peer_with_configuration(client, sample_data):
assert data["configuration"] == configuration
def test_update_peer_with_all_optional_params(client, sample_data):
def test_update_peer_with_all_optional_params(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer update with both metadata and configuration"""
test_workspace, test_peer = sample_data
metadata = {"updated_key": "updated_value", "count": 100}
@ -180,7 +197,9 @@ def test_update_peer_with_all_optional_params(client, sample_data):
assert data["configuration"] == configuration
def test_update_peer_with_null_metadata(client, sample_data):
def test_update_peer_with_null_metadata(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer update with null metadata"""
test_workspace, test_peer = sample_data
@ -200,7 +219,9 @@ def test_update_peer_with_null_metadata(client, sample_data):
assert "metadata" in data
def test_update_peer_with_null_configuration(client, sample_data):
def test_update_peer_with_null_configuration(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer update with null configuration"""
test_workspace, test_peer = sample_data
@ -213,7 +234,9 @@ def test_update_peer_with_null_configuration(client, sample_data):
assert "configuration" in data
def test_get_sessions_for_peer_no_sessions(client, sample_data):
def test_get_sessions_for_peer_no_sessions(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
# Get sessions for the peer
@ -226,7 +249,7 @@ def test_get_sessions_for_peer_no_sessions(client, sample_data):
assert "items" in data
def test_get_sessions_for_peer(client, sample_data):
def test_get_sessions_for_peer(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, test_peer = sample_data
# Create session for the peer
@ -252,7 +275,9 @@ def test_get_sessions_for_peer(client, sample_data):
assert len(data["items"]) == 1
def test_get_sessions_for_peer_with_is_active_filter(client, sample_data):
def test_get_sessions_for_peer_with_is_active_filter(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test getting sessions for peer with is_active parameter"""
test_workspace, test_peer = sample_data
@ -277,7 +302,9 @@ def test_get_sessions_for_peer_with_is_active_filter(client, sample_data):
assert len(inactive_sessions) > 0
def test_get_sessions_for_peer_with_empty_filter(client, sample_data):
def test_get_sessions_for_peer_with_empty_filter(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test getting sessions for peer with empty filter object"""
test_workspace, test_peer = sample_data
@ -291,7 +318,9 @@ def test_get_sessions_for_peer_with_empty_filter(client, sample_data):
assert isinstance(data["items"], list)
def test_create_and_get_messages_for_peer(client, sample_data):
def test_create_and_get_messages_for_peer(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
# Create messages for the peer
@ -334,7 +363,9 @@ def test_create_and_get_messages_for_peer(client, sample_data):
assert data["items"][1]["metadata"] == {"message_key": "message_value2"}
def test_get_messages_for_peer_with_reverse(client, sample_data):
def test_get_messages_for_peer_with_reverse(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test getting messages for peer with reverse parameter"""
test_workspace, test_peer = sample_data
@ -370,7 +401,9 @@ def test_get_messages_for_peer_with_reverse(client, sample_data):
assert len(reversed_data["items"]) > 0
def test_get_messages_for_peer_with_empty_filter(client, sample_data):
def test_get_messages_for_peer_with_empty_filter(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test getting messages for peer with empty filter object"""
test_workspace, test_peer = sample_data
@ -384,7 +417,9 @@ def test_get_messages_for_peer_with_empty_filter(client, sample_data):
assert isinstance(data["items"], list)
def test_get_messages_for_peer_with_null_filter(client, sample_data):
def test_get_messages_for_peer_with_null_filter(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test getting messages for peer with null filter"""
test_workspace, test_peer = sample_data
@ -398,7 +433,7 @@ def test_get_messages_for_peer_with_null_filter(client, sample_data):
assert isinstance(data["items"], list)
def test_chat(client, sample_data):
def test_chat(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, test_peer = sample_data
target_peer = str(generate_nanoid())
@ -416,7 +451,9 @@ def test_chat(client, sample_data):
assert "content" in data
def test_chat_with_optional_params(client, sample_data):
def test_chat_with_optional_params(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test chat endpoint with optional parameters"""
test_workspace, test_peer = sample_data
@ -442,7 +479,9 @@ def test_chat_with_optional_params(client, sample_data):
assert "content" in data
def test_get_peer_representation_with_session(client, sample_data):
def test_get_peer_representation_with_session(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer representation with session_id parameter"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
@ -464,7 +503,7 @@ def test_get_peer_representation_with_session(client, sample_data):
assert response.status_code == 200
def test_search_peer(client, sample_data):
def test_search_peer(client: TestClient, sample_data: tuple[Workspace, Peer]):
"""Test the peer search functionality"""
test_workspace, test_peer = sample_data
@ -495,7 +534,9 @@ def test_search_peer(client, sample_data):
assert isinstance(data["items"], list)
def test_search_peer_empty_query(client, sample_data):
def test_search_peer_empty_query(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test peer search with empty query"""
test_workspace, test_peer = sample_data
@ -511,7 +552,9 @@ def test_search_peer_empty_query(client, sample_data):
assert isinstance(data["items"], list)
def test_search_peer_nonexistent(client, sample_data):
def test_search_peer_nonexistent(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test searching a peer that doesn't exist"""
test_workspace, _ = sample_data
nonexistent_peer_id = str(generate_nanoid())

View File

@ -1,5 +1,7 @@
import pytest
from fastapi.testclient import TestClient
from nanoid import generate as generate_nanoid
from sqlalchemy.ext.asyncio import AsyncSession
from src import models
@ -8,7 +10,11 @@ from src import models
class TestDeriverStatusEndpoint:
"""Test suite for the /deriver/status endpoint"""
async def test_get_deriver_status_peer_only(self, client, db_session, sample_data):
async def test_get_deriver_status_peer_only(
self,
client: TestClient,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test getting deriver status filtered by peer only"""
test_workspace, test_peer = sample_data
@ -31,10 +37,13 @@ class TestDeriverStatusEndpoint:
assert isinstance(data["pending_work_units"], int)
async def test_get_deriver_status_session_only(
self, client, db_session, sample_data
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test getting deriver status filtered by session only"""
test_workspace, test_peer = sample_data
test_workspace, _ = sample_data
# Create a test session
test_session = models.Session(
@ -59,7 +68,10 @@ class TestDeriverStatusEndpoint:
assert isinstance(data["total_work_units"], int)
async def test_get_deriver_status_peer_and_session(
self, client, db_session, sample_data
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test getting deriver status filtered by both peer and session"""
test_workspace, test_peer = sample_data
@ -86,7 +98,9 @@ class TestDeriverStatusEndpoint:
assert "pending_work_units" in data
async def test_get_deriver_status_with_include_sender_true(
self, client, db_session, sample_data
self,
client: TestClient,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test getting deriver status with include_sender=True"""
test_workspace, test_peer = sample_data
@ -101,7 +115,9 @@ class TestDeriverStatusEndpoint:
assert "total_work_units" in data
async def test_get_deriver_status_with_include_sender_false(
self, client, db_session, sample_data
self,
client: TestClient,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test getting deriver status with include_sender=False (default)"""
test_workspace, test_peer = sample_data
@ -115,7 +131,9 @@ class TestDeriverStatusEndpoint:
assert data["peer_id"] == test_peer.name
assert "total_work_units" in data
async def test_get_deriver_status_no_parameters(self, client, sample_data):
async def test_get_deriver_status_no_parameters(
self, client: TestClient, sample_data: tuple[models.Workspace, models.Peer]
):
"""Test getting deriver status without required parameters returns 400"""
test_workspace, _ = sample_data
@ -128,7 +146,9 @@ class TestDeriverStatusEndpoint:
in data["detail"]
)
async def test_get_deriver_status_nonexistent_peer(self, client, sample_data):
async def test_get_deriver_status_nonexistent_peer(
self, client: TestClient, sample_data: tuple[models.Workspace, models.Peer]
):
"""Test getting deriver status for nonexistent peer returns empty result"""
test_workspace, _ = sample_data
nonexistent_peer = str(generate_nanoid())
@ -144,7 +164,9 @@ class TestDeriverStatusEndpoint:
assert data["in_progress_work_units"] == 0
assert data["pending_work_units"] == 0
async def test_get_deriver_status_nonexistent_session(self, client, sample_data):
async def test_get_deriver_status_nonexistent_session(
self, client: TestClient, sample_data: tuple[models.Workspace, models.Peer]
):
"""Test getting deriver status for nonexistent session returns empty result"""
test_workspace, _ = sample_data
nonexistent_session = str(generate_nanoid())
@ -160,7 +182,7 @@ class TestDeriverStatusEndpoint:
assert data["in_progress_work_units"] == 0
assert data["pending_work_units"] == 0
async def test_get_deriver_status_nonexistent_workspace(self, client):
async def test_get_deriver_status_nonexistent_workspace(self, client: TestClient):
"""Test getting deriver status for nonexistent workspace returns empty result"""
nonexistent_workspace = str(generate_nanoid())
fake_peer = str(generate_nanoid())
@ -178,7 +200,10 @@ class TestDeriverStatusEndpoint:
assert data["pending_work_units"] == 0
async def test_get_deriver_status_with_queue_items(
self, client, db_session, sample_data
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test getting deriver status when there are actual queue items"""
test_workspace, test_peer = sample_data
@ -230,7 +255,10 @@ class TestDeriverStatusEndpoint:
assert data["pending_work_units"] >= 1
async def test_get_deriver_status_with_sessions_breakdown(
self, client, db_session, sample_data
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test getting deriver status shows sessions breakdown when appropriate"""
test_workspace, test_peer = sample_data
@ -288,7 +316,9 @@ class TestDeriverStatusEndpoint:
assert "in_progress_work_units" in session_data
assert "pending_work_units" in session_data
async def test_get_deriver_status_empty_parameters(self, client, sample_data):
async def test_get_deriver_status_empty_parameters(
self, client: TestClient, sample_data: tuple[models.Workspace, models.Peer]
):
"""Test various edge cases with empty or invalid parameters"""
test_workspace, _ = sample_data
@ -305,7 +335,7 @@ class TestDeriverStatusEndpoint:
assert response.status_code == 400
async def test_get_deriver_status_boolean_parameter_variations(
self, client, sample_data
self, client: TestClient, sample_data: tuple[models.Workspace, models.Peer]
):
"""Test different boolean parameter formats for include_sender"""
test_workspace, test_peer = sample_data
@ -335,7 +365,10 @@ class TestDeriverStatusEndpoint:
assert response.status_code == 200
async def test_get_deriver_status_response_consistency(
self, client, db_session, sample_data
self,
client: TestClient,
db_session: AsyncSession,
sample_data: tuple[models.Workspace, models.Peer],
):
"""Test that response structure is consistent across different parameter combinations"""
test_workspace, test_peer = sample_data

View File

@ -1,9 +1,11 @@
from nanoid import generate as generate_nanoid
from src.models import Peer, Workspace
from src.security import JWTParams, create_jwt
from tests.conftest import AuthClient
def test_create_workspace_with_auth(auth_client):
def test_create_workspace_with_auth(auth_client: AuthClient):
name = str(generate_nanoid())
response = auth_client.post(
@ -18,7 +20,7 @@ def test_create_workspace_with_auth(auth_client):
assert response.status_code == 200
def test_auth_response_time(auth_client):
def test_auth_response_time(auth_client: AuthClient):
name = str(generate_nanoid())
import time
@ -43,7 +45,7 @@ def test_auth_response_time(auth_client):
assert response.status_code == 200
def test_get_or_create_workspace_with_auth(auth_client):
def test_get_or_create_workspace_with_auth(auth_client: AuthClient):
name = str(generate_nanoid())
response = auth_client.post(
@ -57,7 +59,9 @@ def test_get_or_create_workspace_with_auth(auth_client):
assert response.status_code == 200
def test_get_workspace_with_auth(auth_client, sample_data):
def test_get_workspace_with_auth(
auth_client: AuthClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, _ = sample_data
if auth_client.auth_type == "empty":
@ -75,7 +79,9 @@ def test_get_workspace_with_auth(auth_client, sample_data):
assert response.status_code == 401
def test_update_workspace_with_auth(auth_client, sample_data):
def test_update_workspace_with_auth(
auth_client: AuthClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, _ = sample_data
if auth_client.auth_type == "empty":
@ -97,7 +103,9 @@ def test_update_workspace_with_auth(auth_client, sample_data):
assert response.status_code == 401
def test_update_workspace_with_wrong_auth(auth_client, sample_data):
def test_update_workspace_with_wrong_auth(
auth_client: AuthClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, _ = sample_data
different_workspace = str(generate_nanoid())
@ -122,7 +130,9 @@ def test_update_workspace_with_wrong_auth(auth_client, sample_data):
assert response.status_code == 401
def test_create_peer_with_auth(auth_client, sample_data):
def test_create_peer_with_auth(
auth_client: AuthClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, _ = sample_data
if auth_client.auth_type == "empty":
@ -144,7 +154,9 @@ def test_create_peer_with_auth(auth_client, sample_data):
assert response.status_code == 401
def test_get_peer_by_name_with_auth(auth_client, sample_data):
def test_get_peer_by_name_with_auth(
auth_client: AuthClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
if auth_client.auth_type == "empty":
@ -179,7 +191,9 @@ def test_get_peer_by_name_with_auth(auth_client, sample_data):
assert response.status_code == 200
def test_update_peer_with_auth(auth_client, sample_data):
def test_update_peer_with_auth(
auth_client: AuthClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
if auth_client.auth_type == "empty":
@ -217,7 +231,9 @@ def test_update_peer_with_auth(auth_client, sample_data):
assert response.status_code == 200
def test_create_session_with_auth(auth_client, sample_data):
def test_create_session_with_auth(
auth_client: AuthClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
if auth_client.auth_type == "empty":
@ -253,7 +269,9 @@ def test_create_session_with_auth(auth_client, sample_data):
assert response.status_code == 200
def test_get_session_by_name_with_auth(auth_client, sample_data):
def test_get_session_by_name_with_auth(
auth_client: AuthClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
# First create a session

View File

@ -1,7 +1,13 @@
from typing import Any
import pytest
from fastapi.testclient import TestClient
from nanoid import generate as generate_nanoid
from src.models import Peer, Workspace
def test_get_or_create_session(client, sample_data):
def test_get_or_create_session(client: TestClient, sample_data: tuple[Workspace, Peer]):
# Test get or create session
test_workspace, test_peer = sample_data
@ -40,7 +46,9 @@ def test_get_or_create_session(client, sample_data):
assert data3["workspace_id"] == test_workspace.name
def test_create_session_with_metadata(client, sample_data):
def test_create_session_with_metadata(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
response = client.post(
@ -59,7 +67,9 @@ def test_create_session_with_metadata(client, sample_data):
assert data["workspace_id"] == test_workspace.name
def test_create_session_with_configuration(client, sample_data):
def test_create_session_with_configuration(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session creation with configuration parameter"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
@ -80,7 +90,9 @@ def test_create_session_with_configuration(client, sample_data):
assert data["workspace_id"] == test_workspace.name
def test_create_session_with_all_optional_params(client, sample_data):
def test_create_session_with_all_optional_params(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session creation with all optional parameters"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
@ -104,7 +116,11 @@ def test_create_session_with_all_optional_params(client, sample_data):
assert data["workspace_id"] == test_workspace.name
def test_create_session_with_too_many_peers(client, sample_data, caplog):
def test_create_session_with_too_many_peers(
client: TestClient,
sample_data: tuple[Workspace, Peer],
caplog: pytest.LogCaptureFixture,
):
test_workspace, test_peer = sample_data
# create 10 peers
peer_names = [test_peer.name]
@ -151,7 +167,7 @@ def test_create_session_with_too_many_peers(client, sample_data, caplog):
assert data["workspace_id"] == test_workspace.name
def test_get_sessions(client, sample_data):
def test_get_sessions(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, test_peer = sample_data
# Create a test session
session_id = str(generate_nanoid())
@ -180,7 +196,9 @@ def test_get_sessions(client, sample_data):
assert data["items"][0]["workspace_id"] == test_workspace.name
def test_get_sessions_with_is_active_filter(client, sample_data):
def test_get_sessions_with_is_active_filter(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session listing with is_active parameter"""
test_workspace, test_peer = sample_data
@ -204,9 +222,11 @@ def test_get_sessions_with_is_active_filter(client, sample_data):
assert len(inactive_sessions) > 0
def test_get_sessions_with_empty_filter(client, sample_data):
def test_get_sessions_with_empty_filter(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session listing with empty filter object"""
test_workspace, test_peer = sample_data
test_workspace, _ = sample_data
response = client.post(
f"/v2/workspaces/{test_workspace.name}/sessions/list", json={"filter": {}}
@ -217,7 +237,9 @@ def test_get_sessions_with_empty_filter(client, sample_data):
assert isinstance(data["items"], list)
def test_update_delete_metadata(client, sample_data):
def test_update_delete_metadata(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
# Create a test session
session_id = str(generate_nanoid())
@ -240,7 +262,7 @@ def test_update_delete_metadata(client, sample_data):
assert data["metadata"] == {}
def test_update_session(client, sample_data):
def test_update_session(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, test_peer = sample_data
# Create a test session
session_id = str(generate_nanoid())
@ -262,7 +284,9 @@ def test_update_session(client, sample_data):
assert data["metadata"] == {"new_key": "new_value"}
def test_update_session_with_configuration(client, sample_data):
def test_update_session_with_configuration(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session update with configuration parameter"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
@ -284,7 +308,9 @@ def test_update_session_with_configuration(client, sample_data):
assert data["configuration"] == configuration
def test_update_session_with_all_optional_params(client, sample_data):
def test_update_session_with_all_optional_params(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session update with both metadata and configuration"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
@ -308,7 +334,9 @@ def test_update_session_with_all_optional_params(client, sample_data):
assert data["configuration"] == configuration
def test_update_session_with_null_configuration(client, sample_data):
def test_update_session_with_null_configuration(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session update with null configuration"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
@ -333,7 +361,7 @@ def test_update_session_with_null_configuration(client, sample_data):
assert "configuration" in data
def test_delete_session(client, sample_data):
def test_delete_session(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, test_peer = sample_data
# Create a test session
session_id = str(generate_nanoid())
@ -363,7 +391,7 @@ def test_delete_session(client, sample_data):
assert inactive_session["is_active"] is False
def test_clone_session(client, sample_data):
def test_clone_session(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, test_peer = sample_data
# Create a test session
session_id = str(generate_nanoid())
@ -422,7 +450,9 @@ def test_clone_session(client, sample_data):
assert data["items"][1]["metadata"] == {"key": "value2"}
def test_clone_session_with_cutoff(client, sample_data):
def test_clone_session_with_cutoff(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session cloning with message cutoff parameter"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
@ -457,7 +487,7 @@ def test_clone_session_with_cutoff(client, sample_data):
assert "id" in data
def test_add_peers_to_session(client, sample_data):
def test_add_peers_to_session(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, test_peer = sample_data
# Create another peer
peer2_name = str(generate_nanoid())
@ -486,7 +516,7 @@ def test_add_peers_to_session(client, sample_data):
assert response.status_code == 200
def test_get_session_peers(client, sample_data):
def test_get_session_peers(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, test_peer = sample_data
# Create another peer
peer2_name = str(generate_nanoid())
@ -520,7 +550,7 @@ def test_get_session_peers(client, sample_data):
assert peer2_name in peer_names
def test_set_session_peers(client, sample_data):
def test_set_session_peers(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, test_peer = sample_data
# Create another peer
peer2_name = str(generate_nanoid())
@ -559,7 +589,11 @@ def test_set_session_peers(client, sample_data):
assert data["items"][0]["id"] == peer2_name
def test_set_session_peers_with_limit(client, sample_data, caplog):
def test_set_session_peers_with_limit(
client: TestClient,
sample_data: tuple[Workspace, Peer],
caplog: pytest.LogCaptureFixture,
):
test_workspace, test_peer = sample_data
# Create a test session with multiple peers
@ -584,7 +618,7 @@ def test_set_session_peers_with_limit(client, sample_data, caplog):
peer_names.append(peer_name)
# set peers with 11 peers (as a dict of peer_name: {})
peers_dict = {peer_name: {} for peer_name in peer_names}
peers_dict: dict[str, dict[Any, Any]] = {peer_name: {} for peer_name in peer_names}
response = client.put(
f"/v2/workspaces/{test_workspace.name}/sessions/{session_id}/peers",
json=peers_dict,
@ -593,7 +627,9 @@ def test_set_session_peers_with_limit(client, sample_data, caplog):
assert "Failed to set peers for session" in caplog.text
def test_remove_peers_from_session(client, sample_data):
def test_remove_peers_from_session(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
# Create another peer
peer2_name = str(generate_nanoid())
@ -633,7 +669,7 @@ def test_remove_peers_from_session(client, sample_data):
assert data["items"][0]["id"] == peer2_name
def test_get_session_context(client, sample_data):
def test_get_session_context(client: TestClient, sample_data: tuple[Workspace, Peer]):
"""Test the session context endpoint"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
@ -670,7 +706,9 @@ def test_get_session_context(client, sample_data):
assert data["summary"] == "" # Default is empty when summary=False
def test_get_session_context_with_summary(client, sample_data):
def test_get_session_context_with_summary(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session context with summary parameter"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
@ -690,7 +728,9 @@ def test_get_session_context_with_summary(client, sample_data):
assert "summary" in data
def test_get_session_context_with_tokens(client, sample_data):
def test_get_session_context_with_tokens(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session context with token limit parameter"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
@ -711,7 +751,9 @@ def test_get_session_context_with_tokens(client, sample_data):
assert isinstance(data["messages"], list)
def test_get_session_context_with_all_params(client, sample_data):
def test_get_session_context_with_all_params(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session context with both summary and tokens parameters"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
@ -732,7 +774,7 @@ def test_get_session_context_with_all_params(client, sample_data):
assert "summary" in data
def test_search_session(client, sample_data):
def test_search_session(client: TestClient, sample_data: tuple[Workspace, Peer]):
"""Test the session search functionality"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
@ -770,7 +812,9 @@ def test_search_session(client, sample_data):
assert isinstance(data["items"], list)
def test_search_session_empty_query(client, sample_data):
def test_search_session_empty_query(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test session search with empty query"""
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
@ -793,7 +837,9 @@ def test_search_session_empty_query(client, sample_data):
assert isinstance(data["items"], list)
def test_search_session_nonexistent(client, sample_data):
def test_search_session_nonexistent(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test searching a session that doesn't exist"""
test_workspace, _ = sample_data
nonexistent_session_id = str(generate_nanoid())

View File

@ -1,7 +1,10 @@
from fastapi.testclient import TestClient
from nanoid import generate as generate_nanoid
from src.models import Peer, Workspace
def test_workspace_validations_api(client):
def test_workspace_validations_api(client: TestClient):
# Test name too short
response = client.post("/v2/workspaces", json={"name": "", "metadata": {}})
assert response.status_code == 422
@ -28,7 +31,7 @@ def test_workspace_validations_api(client):
assert error["type"] == "dict_type"
def test_peer_validations_api(client, sample_data):
def test_peer_validations_api(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, _ = sample_data
# Test name too short
@ -53,7 +56,9 @@ def test_peer_validations_api(client, sample_data):
assert error["type"] == "string_too_long"
def test_message_validations_api(client, sample_data):
def test_message_validations_api(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
# Create a test session first
session_id = str(generate_nanoid())
@ -79,7 +84,9 @@ def test_message_validations_api(client, sample_data):
assert error["type"] == "string_too_long"
def test_session_validations_api(client, sample_data):
def test_session_validations_api(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
# Create a test session first
session_id = str(generate_nanoid())
@ -125,7 +132,9 @@ def test_session_validations_api(client, sample_data):
assert data["configuration"] == {"test_flag": "test_value"}
def test_agent_query_validations_api(client, sample_data):
def test_agent_query_validations_api(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
# Create a session first since agent queries are session-based
session_id = str(generate_nanoid())
@ -195,7 +204,9 @@ def test_agent_query_validations_api(client, sample_data):
assert response.status_code == 200
def test_required_field_validations_api(client, sample_data):
def test_required_field_validations_api(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
session_id = str(generate_nanoid())
session_response = client.post(
@ -225,7 +236,9 @@ def test_required_field_validations_api(client, sample_data):
assert error["type"] == "missing"
def test_filter_validations_api(client, sample_data):
def test_filter_validations_api(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
test_workspace, test_peer = sample_data
# Create a session first
session_id = str(generate_nanoid())

View File

@ -1,8 +1,11 @@
import pytest
from fastapi.testclient import TestClient
from nanoid import generate as generate_nanoid
from src.models import Peer, Workspace
def test_get_or_create_workspace(client):
def test_get_or_create_workspace(client: TestClient):
name = str(generate_nanoid())
# This should create the workspace using POST /v2/workspaces
@ -13,7 +16,7 @@ def test_get_or_create_workspace(client):
assert "id" in data
def test_get_or_create_workspace_with_configuration(client):
def test_get_or_create_workspace_with_configuration(client: TestClient):
"""Test workspace creation with configuration parameter"""
name = str(generate_nanoid())
configuration = {"feature1": True, "feature2": False}
@ -27,7 +30,7 @@ def test_get_or_create_workspace_with_configuration(client):
assert data["configuration"] == configuration
def test_get_or_create_workspace_with_all_optional_params(client):
def test_get_or_create_workspace_with_all_optional_params(client: TestClient):
"""Test workspace creation with all optional parameters"""
name = str(generate_nanoid())
metadata = {"key": "value", "number": 42}
@ -44,7 +47,7 @@ def test_get_or_create_workspace_with_all_optional_params(client):
assert data["configuration"] == configuration
def test_get_or_create_existing_workspace(client):
def test_get_or_create_existing_workspace(client: TestClient):
name = str(generate_nanoid())
# Create the workspace
@ -67,7 +70,7 @@ def test_get_or_create_existing_workspace(client):
@pytest.mark.asyncio
async def test_get_all_workspaces(client, db_session, sample_data):
async def test_get_all_workspaces(client: TestClient):
# create a test workspace with metadata
response = client.post(
"/v2/workspaces",
@ -98,7 +101,7 @@ async def test_get_all_workspaces(client, db_session, sample_data):
@pytest.mark.asyncio
async def test_get_all_workspaces_with_empty_filter(client, db_session, sample_data):
async def test_get_all_workspaces_with_empty_filter(client: TestClient):
"""Test workspace listing with empty filter object"""
response = client.post("/v2/workspaces/list", json={"filter": {}})
assert response.status_code == 200
@ -108,7 +111,7 @@ async def test_get_all_workspaces_with_empty_filter(client, db_session, sample_d
@pytest.mark.asyncio
async def test_get_all_workspaces_with_null_filter(client, db_session, sample_data):
async def test_get_all_workspaces_with_null_filter(client: TestClient):
"""Test workspace listing with null filter"""
response = client.post("/v2/workspaces/list", json={"filter": None})
assert response.status_code == 200
@ -117,7 +120,7 @@ async def test_get_all_workspaces_with_null_filter(client, db_session, sample_da
assert isinstance(data["items"], list)
def test_update_workspace(client, sample_data):
def test_update_workspace(client: TestClient, sample_data: tuple[Workspace, Peer]):
test_workspace, _ = sample_data
_new_name = str(generate_nanoid())
response = client.put(
@ -129,7 +132,9 @@ def test_update_workspace(client, sample_data):
assert data["metadata"] == {"new_key": "new_value"}
def test_update_workspace_with_configuration(client, sample_data):
def test_update_workspace_with_configuration(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test workspace update with configuration parameter"""
test_workspace, _ = sample_data
configuration = {"new_feature": True, "legacy_feature": False}
@ -142,7 +147,9 @@ def test_update_workspace_with_configuration(client, sample_data):
assert data["configuration"] == configuration
def test_update_workspace_with_all_optional_params(client, sample_data):
def test_update_workspace_with_all_optional_params(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test workspace update with both metadata and configuration"""
test_workspace, _ = sample_data
metadata = {"updated_key": "updated_value", "count": 100}
@ -158,7 +165,9 @@ def test_update_workspace_with_all_optional_params(client, sample_data):
assert data["configuration"] == configuration
def test_update_workspace_with_null_metadata(client, sample_data):
def test_update_workspace_with_null_metadata(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test workspace update with null metadata (should clear metadata)"""
test_workspace, _ = sample_data
@ -178,7 +187,9 @@ def test_update_workspace_with_null_metadata(client, sample_data):
assert "metadata" in data
def test_update_workspace_with_null_configuration(client, sample_data):
def test_update_workspace_with_null_configuration(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test workspace update with null configuration"""
test_workspace, _ = sample_data
@ -190,7 +201,7 @@ def test_update_workspace_with_null_configuration(client, sample_data):
assert "configuration" in data
def test_create_duplicate_workspace_name(client):
def test_create_duplicate_workspace_name(client: TestClient):
# Create an workspace
name = str(generate_nanoid())
response = client.post("/v2/workspaces", json={"name": name})
@ -205,7 +216,7 @@ def test_create_duplicate_workspace_name(client):
assert data["id"] == name
def test_search_workspace(client, sample_data):
def test_search_workspace(client: TestClient, sample_data: tuple[Workspace, Peer]):
"""Test the workspace search functionality"""
test_workspace, _ = sample_data
@ -224,7 +235,9 @@ def test_search_workspace(client, sample_data):
assert isinstance(data["items"], list)
def test_search_workspace_empty_query(client, sample_data):
def test_search_workspace_empty_query(
client: TestClient, sample_data: tuple[Workspace, Peer]
):
"""Test the workspace search with empty query"""
test_workspace, _ = sample_data
@ -238,7 +251,7 @@ def test_search_workspace_empty_query(client, sample_data):
assert isinstance(data["items"], list)
def test_search_workspace_nonexistent(client):
def test_search_workspace_nonexistent(client: TestClient):
"""Test searching a workspace that doesn't exist"""
nonexistent_workspace_id = str(generate_nanoid())

86
tests/test_agent.py Normal file
View File

@ -0,0 +1,86 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src import agent
@pytest.mark.asyncio
async def test_dialectic_call_function_exists():
"""Test that dialectic_call function exists and can be mocked"""
with patch("src.agent.dialectic_call", new_callable=AsyncMock) as mock_call:
mock_call.return_value = MagicMock(content="test response")
# This would normally make an LLM call, but it's mocked
result = await agent.dialectic_call(
query="test query",
working_representation="test representation",
additional_context="test context",
)
assert mock_call.called
assert result.content == "test response"
@pytest.mark.asyncio
async def test_dialectic_stream_function_exists():
"""Test that dialectic_stream function exists and can be mocked"""
with patch("src.agent.dialectic_stream", new_callable=AsyncMock) as mock_stream:
mock_stream.return_value = AsyncMock()
# This would normally make a streaming LLM call, but it's mocked
result = await agent.dialectic_stream(
query="test query",
working_representation="test representation",
additional_context="test context",
)
assert mock_stream.called
assert result is not None
@pytest.mark.asyncio
async def test_generate_semantic_queries_llm_function_exists():
"""Test that generate_semantic_queries_llm function exists and can be mocked"""
with patch("src.agent.generate_semantic_queries_llm") as mock_queries:
mock_queries.return_value = ["query1", "query2", "query3"]
# This would normally make an LLM call, but it's mocked
result = await agent.generate_semantic_queries_llm("test query")
assert mock_queries.called
assert result == ["query1", "query2", "query3"]
@pytest.mark.asyncio
async def test_run_tom_inference_function():
"""Test that run_tom_inference function works with new Pydantic objects"""
with patch("src.agent.get_tom_inference") as mock_tom:
from src.deriver.tom.single_prompt import (
CurrentState,
TentativeInference,
TomInferenceOutput,
)
# Mock the function to return a proper Pydantic object
mock_tom_response = TomInferenceOutput(
current_state=CurrentState(
immediate_context="test context",
active_goals="test goals",
present_mood="test mood",
),
tentative_inferences=[
TentativeInference(interpretation="test inference", basis="test basis")
],
knowledge_gaps=[],
expectation_violations=[],
)
mock_tom.return_value = mock_tom_response
# Test the function
result = await agent.run_tom_inference("test chat history")
# Verify it extracted the right information from the Pydantic object
assert "test context" in result
assert "test inference" in result
assert mock_tom.called

View File

@ -30,7 +30,10 @@ class TestWorkspaceValidations:
def test_app_invalid_metadata_type(self):
with pytest.raises(ValidationError) as exc_info:
WorkspaceCreate(name="test", metadata="not a dict")
WorkspaceCreate(
name="test",
metadata="not a dict", # pyright: ignore
)
error_dict = exc_info.value.errors()[0]
assert error_dict["type"] == "dict_type"

View File

@ -1,198 +0,0 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.utils.model_client import (
DEFAULT_MAX_TOKENS,
DEFAULT_MODELS,
DEFAULT_TEMPERATURE,
ModelClient,
ModelProvider,
)
@pytest.fixture
def mock_env(monkeypatch):
monkeypatch.setenv("ANTHROPIC_API_KEY", "mock-anthropic-api-key")
monkeypatch.setenv("OPENAI_COMPATIBLE_API_KEY", "mock-openai-api-key")
monkeypatch.setenv("OPENAI_API_KEY", "mock-openai-api-key")
monkeypatch.setenv("GROQ_API_KEY", "mock-groq-api-key")
monkeypatch.setenv("CEREBRAS_API_KEY", "mock-cerebras-api-key")
monkeypatch.setenv("OPENROUTER_API_KEY", "mock-openrouter-api-key")
# Test fixtures
@pytest.fixture
def mock_anthropic_response():
mock_response = MagicMock()
mock_content = MagicMock()
mock_content.type = "text"
mock_content.text = "Test response"
mock_response.content = [mock_content]
return mock_response
@pytest.fixture
def mock_openai_response():
mock_response = MagicMock()
mock_message = MagicMock()
mock_message.content = "Test response"
mock_choice = MagicMock()
mock_choice.message = mock_message
mock_response.choices = [mock_choice]
return mock_response
@pytest.fixture
def mock_anthropic_client():
with patch("src.utils.model_client.AsyncAnthropic") as mock:
client = MagicMock()
client.messages.create = AsyncMock()
client.messages.stream = AsyncMock()
mock.return_value = client
yield client
@pytest.fixture
def mock_openai_client():
with patch("src.utils.model_client.AsyncOpenAI") as mock:
client = MagicMock()
client.chat.completions.create = AsyncMock()
mock.return_value = client
yield client
# Initialization Tests
def test_default_initialization(mock_env):
"""Test default initialization with Anthropic provider."""
client = ModelClient()
assert client.provider == ModelProvider.ANTHROPIC
assert client.model == DEFAULT_MODELS[ModelProvider.ANTHROPIC]
assert client.base_url is None
def test_custom_model_initialization():
"""Test initialization with custom model name."""
custom_model = "custom-model"
client = ModelClient(model=custom_model)
assert client.model == custom_model
def test_custom_api_key_initialization():
"""Test initialization with custom API key."""
custom_key = "test-api-key"
client = ModelClient(api_key=custom_key)
assert client.api_key == custom_key
def test_custom_base_url_initialization():
"""Test initialization with custom base URL."""
custom_url = "https://custom-api.example.com"
client = ModelClient(base_url=custom_url)
assert client.base_url == custom_url
def test_unsupported_provider_initialization():
"""Test initialization with unsupported provider."""
with pytest.raises(ValueError, match="is not a valid ModelProvider"):
ModelClient(provider=ModelProvider("unsupported"))
def test_missing_api_key_initialization():
"""Test initialization without required API key."""
with (
patch("src.utils.model_client.settings") as mock_settings,
pytest.raises(ValueError, match="API key is required"),
):
# Mock the settings to return None for all API keys
mock_settings.LLM.ANTHROPIC_API_KEY = None
mock_settings.LLM.OPENAI_API_KEY = None
mock_settings.LLM.OPENAI_COMPATIBLE_API_KEY = None
mock_settings.LLM.GROQ_API_KEY = None
mock_settings.LLM.GEMINI_API_KEY = None
ModelClient()
# Message Creation Tests
def test_create_message():
"""Test message creation with different roles."""
client = ModelClient()
message = client.create_message("user", "Hello")
assert message == {"role": "user", "content": "Hello"}
# Generation Tests
@pytest.mark.asyncio
async def test_generate_anthropic(mock_anthropic_client, mock_anthropic_response):
"""Test generation with Anthropic provider."""
mock_anthropic_client.messages.create.return_value = mock_anthropic_response
client = ModelClient(provider=ModelProvider.ANTHROPIC)
messages = [{"role": "user", "content": "Hello"}]
response = await client.generate(messages)
assert response == "Test response"
# Verify the API call
mock_anthropic_client.messages.create.assert_called_once()
call_args = mock_anthropic_client.messages.create.call_args[1]
assert call_args["model"] == DEFAULT_MODELS[ModelProvider.ANTHROPIC]
assert call_args["messages"] == messages
assert call_args["max_tokens"] == DEFAULT_MAX_TOKENS
assert call_args["temperature"] == DEFAULT_TEMPERATURE
@pytest.mark.asyncio
async def test_generate_openai(mock_openai_client, mock_openai_response, mock_env):
"""Test generation with OpenAI provider."""
mock_openai_client.chat.completions.create.return_value = mock_openai_response
client = ModelClient(provider=ModelProvider.OPENAI)
messages = [{"role": "user", "content": "Hello"}]
response = await client.generate(messages)
assert response == "Test response"
# Verify the API call
mock_openai_client.chat.completions.create.assert_called_once()
call_args = mock_openai_client.chat.completions.create.call_args[1]
assert call_args["model"] == DEFAULT_MODELS[ModelProvider.OPENAI]
assert call_args["messages"] == messages
assert call_args["max_tokens"] == DEFAULT_MAX_TOKENS
assert call_args["temperature"] == DEFAULT_TEMPERATURE
@pytest.mark.asyncio
async def test_generate_with_system_prompt(
mock_anthropic_client, mock_anthropic_response
):
"""Test generation with system prompt."""
mock_anthropic_client.messages.create.return_value = mock_anthropic_response
client = ModelClient(provider=ModelProvider.ANTHROPIC)
messages = [{"role": "user", "content": "Hello"}]
system = "You are a helpful assistant"
response = await client.generate(messages, system=system)
assert response == "Test response"
# Verify the API call
mock_anthropic_client.messages.create.assert_called_once()
call_args = mock_anthropic_client.messages.create.call_args[1]
assert call_args["system"] == system
@pytest.mark.asyncio
async def test_generate_with_caching(mock_anthropic_client, mock_anthropic_response):
"""Test generation with caching enabled."""
mock_anthropic_client.messages.create.return_value = mock_anthropic_response
client = ModelClient(provider=ModelProvider.ANTHROPIC)
messages = [{"role": "user", "content": "Hello"}]
system = "You are a helpful assistant"
response = await client.generate(messages, system=system, use_caching=True)
assert response == "Test response"
# Verify the API call
mock_anthropic_client.messages.create.assert_called_once()
call_args = mock_anthropic_client.messages.create.call_args[1]
assert call_args["system"] == [
{"type": "text", "text": system, "cache_control": {"type": "ephemeral"}}
]

520
uv.lock
View File

@ -1,9 +1,5 @@
version = 1
requires-python = ">=3.9"
resolution-markers = [
"python_full_version < '3.13'",
"python_full_version >= '3.13'",
]
requires-python = ">=3.10"
[[package]]
name = "alembic"
@ -31,7 +27,7 @@ wheels = [
[[package]]
name = "anthropic"
version = "0.54.0"
version = "0.55.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -42,9 +38,9 @@ dependencies = [
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/89/28/80cb9bb6e7ce77d404145b51da4257455805c17f0a6be528ff3286e3882f/anthropic-0.54.0.tar.gz", hash = "sha256:5e6f997d97ce8e70eac603c3ec2e7f23addeff953fbbb76b19430562bb6ba815", size = 312376 }
sdist = { url = "https://files.pythonhosted.org/packages/a4/19/e2e09bc7fc0c4562ae865b3e5d487931c254c517e1c739b0c8aef2cf3186/anthropic-0.55.0.tar.gz", hash = "sha256:61826efa1bda0e4c7dc6f6a0d82b7d99b3fda970cd048d40ef5fca08a5eabd33", size = 408192 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/b9/6ffb48e82c5e97b03cecee872d134a6b6666c2767b2d32ed709f3a60a8fe/anthropic-0.54.0-py3-none-any.whl", hash = "sha256:c1062a0a905daeec17ca9c06c401e4b3f24cb0495841d29d752568a1d4018d56", size = 288774 },
{ url = "https://files.pythonhosted.org/packages/b3/8f/ba982f539db40f49a610f61562e9b54fb9c85e7b9ede9a46ff6f9e79042f/anthropic-0.55.0-py3-none-any.whl", hash = "sha256:3518433fc0372a13f2b793b4cabecc7734ec9176e063a0f28dac19aa17c57f94", size = 289318 },
]
[[package]]
@ -80,6 +76,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148 },
]
[[package]]
name = "basedpyright"
version = "1.29.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "nodejs-wheel-binaries" },
]
sdist = { url = "https://files.pythonhosted.org/packages/80/fb/bd92196a07e3b4ccee4ff2761a26a05bff77d4da089b67b4b1a547868099/basedpyright-1.29.4.tar.gz", hash = "sha256:2df1976f8591eedf4b4ce8f9d123f43e810cc8cb7cc83c53eec0e2f8044073d0", size = 21961481 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/dc/180fe721a2574fb3aad4051adcca196ac2d18adaf75122f5eeb47436cca2/basedpyright-1.29.4-py3-none-any.whl", hash = "sha256:e087513979972f83010639c6c1a1c13dd3b1d24ee45f8ecff747962cc2063d6f", size = 11476859 },
]
[[package]]
name = "cachetools"
version = "5.5.2"
@ -156,32 +164,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166 },
{ url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064 },
{ url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641 },
{ url = "https://files.pythonhosted.org/packages/28/f8/dfb01ff6cc9af38552c69c9027501ff5a5117c4cc18dcd27cb5259fa1888/charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4", size = 201671 },
{ url = "https://files.pythonhosted.org/packages/32/fb/74e26ee556a9dbfe3bd264289b67be1e6d616329403036f6507bb9f3f29c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7", size = 144744 },
{ url = "https://files.pythonhosted.org/packages/ad/06/8499ee5aa7addc6f6d72e068691826ff093329fe59891e83b092ae4c851c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836", size = 154993 },
{ url = "https://files.pythonhosted.org/packages/f1/a2/5e4c187680728219254ef107a6949c60ee0e9a916a5dadb148c7ae82459c/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597", size = 147382 },
{ url = "https://files.pythonhosted.org/packages/4c/fe/56aca740dda674f0cc1ba1418c4d84534be51f639b5f98f538b332dc9a95/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7", size = 149536 },
{ url = "https://files.pythonhosted.org/packages/53/13/db2e7779f892386b589173dd689c1b1e304621c5792046edd8a978cbf9e0/charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f", size = 151349 },
{ url = "https://files.pythonhosted.org/packages/69/35/e52ab9a276186f729bce7a0638585d2982f50402046e4b0faa5d2c3ef2da/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba", size = 146365 },
{ url = "https://files.pythonhosted.org/packages/a6/d8/af7333f732fc2e7635867d56cb7c349c28c7094910c72267586947561b4b/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12", size = 154499 },
{ url = "https://files.pythonhosted.org/packages/7a/3d/a5b2e48acef264d71e036ff30bcc49e51bde80219bb628ba3e00cf59baac/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518", size = 157735 },
{ url = "https://files.pythonhosted.org/packages/85/d8/23e2c112532a29f3eef374375a8684a4f3b8e784f62b01da931186f43494/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5", size = 154786 },
{ url = "https://files.pythonhosted.org/packages/c7/57/93e0169f08ecc20fe82d12254a200dfaceddc1c12a4077bf454ecc597e33/charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3", size = 150203 },
{ url = "https://files.pythonhosted.org/packages/2c/9d/9bf2b005138e7e060d7ebdec7503d0ef3240141587651f4b445bdf7286c2/charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471", size = 98436 },
{ url = "https://files.pythonhosted.org/packages/6d/24/5849d46cf4311bbf21b424c443b09b459f5b436b1558c04e45dbb7cc478b/charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e", size = 105772 },
{ url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 },
]
[[package]]
name = "click"
version = "8.1.8"
version = "8.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "platform_system == 'Windows'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 }
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 },
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 },
]
[[package]]
@ -253,16 +248,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/5f/cad1c3dbed8b3ee9e16fa832afe365b4e3eeab1fb6edb65ebbf745eabc92/coverage-7.9.1-cp313-cp313t-win32.whl", hash = "sha256:684e2110ed84fd1ca5f40e89aa44adf1729dc85444004111aa01866507adf363", size = 215437 },
{ url = "https://files.pythonhosted.org/packages/99/4d/fad293bf081c0e43331ca745ff63673badc20afea2104b431cdd8c278b4c/coverage-7.9.1-cp313-cp313t-win_amd64.whl", hash = "sha256:437c576979e4db840539674e68c84b3cda82bc824dd138d56bead1435f1cb5d7", size = 216605 },
{ url = "https://files.pythonhosted.org/packages/1f/56/4ee027d5965fc7fc126d7ec1187529cc30cc7d740846e1ecb5e92d31b224/coverage-7.9.1-cp313-cp313t-win_arm64.whl", hash = "sha256:18a0912944d70aaf5f399e350445738a1a20b50fbea788f640751c2ed9208b6c", size = 214392 },
{ url = "https://files.pythonhosted.org/packages/a5/d6/c41dd9b02bf16ec001aaf1cbef665537606899a3db1094e78f5ae17540ca/coverage-7.9.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6f424507f57878e424d9a95dc4ead3fbdd72fd201e404e861e465f28ea469951", size = 212029 },
{ url = "https://files.pythonhosted.org/packages/f8/c0/40420d81d731f84c3916dcdf0506b3e6c6570817bff2576b83f780914ae6/coverage-7.9.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:535fde4001b2783ac80865d90e7cc7798b6b126f4cd8a8c54acfe76804e54e58", size = 212407 },
{ url = "https://files.pythonhosted.org/packages/9b/87/f0db7d62d0e09f14d6d2f6ae8c7274a2f09edf74895a34b412a0601e375a/coverage-7.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02532fd3290bb8fa6bec876520842428e2a6ed6c27014eca81b031c2d30e3f71", size = 241160 },
{ url = "https://files.pythonhosted.org/packages/a9/b7/3337c064f058a5d7696c4867159651a5b5fb01a5202bcf37362f0c51400e/coverage-7.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56f5eb308b17bca3bbff810f55ee26d51926d9f89ba92707ee41d3c061257e55", size = 239027 },
{ url = "https://files.pythonhosted.org/packages/7e/a9/5898a283f66d1bd413c32c2e0e05408196fd4f37e206e2b06c6e0c626e0e/coverage-7.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfa447506c1a52271f1b0de3f42ea0fa14676052549095e378d5bff1c505ff7b", size = 240145 },
{ url = "https://files.pythonhosted.org/packages/e0/33/d96e3350078a3c423c549cb5b2ba970de24c5257954d3e4066e2b2152d30/coverage-7.9.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9ca8e220006966b4a7b68e8984a6aee645a0384b0769e829ba60281fe61ec4f7", size = 239871 },
{ url = "https://files.pythonhosted.org/packages/1d/6e/6fb946072455f71a820cac144d49d11747a0f1a21038060a68d2d0200499/coverage-7.9.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:49f1d0788ba5b7ba65933f3a18864117c6506619f5ca80326b478f72acf3f385", size = 238122 },
{ url = "https://files.pythonhosted.org/packages/e4/5c/bc43f25c8586840ce25a796a8111acf6a2b5f0909ba89a10d41ccff3920d/coverage-7.9.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:68cd53aec6f45b8e4724c0950ce86eacb775c6be01ce6e3669fe4f3a21e768ed", size = 239058 },
{ url = "https://files.pythonhosted.org/packages/11/d8/ce2007418dd7fd00ff8c8b898bb150bb4bac2d6a86df05d7b88a07ff595f/coverage-7.9.1-cp39-cp39-win32.whl", hash = "sha256:95335095b6c7b1cc14c3f3f17d5452ce677e8490d101698562b2ffcacc304c8d", size = 214532 },
{ url = "https://files.pythonhosted.org/packages/20/21/334e76fa246e92e6d69cab217f7c8a70ae0cc8f01438bd0544103f29528e/coverage-7.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:e1b5191d1648acc439b24721caab2fd0c86679d8549ed2c84d5a7ec1bedcc244", size = 215439 },
{ url = "https://files.pythonhosted.org/packages/3e/e5/c723545c3fd3204ebde3b4cc4b927dce709d3b6dc577754bb57f63ca4a4a/coverage-7.9.1-pp39.pp310.pp311-none-any.whl", hash = "sha256:db0f04118d1db74db6c9e1cb1898532c7dcc220f1d2718f058601f7c3f499514", size = 204009 },
{ url = "https://files.pythonhosted.org/packages/08/b8/7ddd1e8ba9701dea08ce22029917140e6f66a859427406579fd8d0ca7274/coverage-7.9.1-py3-none-any.whl", hash = "sha256:66b974b145aa189516b6bf2d8423e888b742517d37872f6ee4c5be0073bd9a3c", size = 204000 },
]
@ -285,6 +270,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 },
]
[[package]]
name = "docstring-parser"
version = "0.16"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/08/12/9c22a58c0b1e29271051222d8906257616da84135af9ed167c9e28f85cb3/docstring_parser-0.16.tar.gz", hash = "sha256:538beabd0af1e2db0146b6bd3caa526c35a34d61af9fd2887f3a8a27a739aa6e", size = 26565 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/7c/e9fcff7623954d86bdc17782036cbf715ecab1bec4847c008557affe1ca8/docstring_parser-0.16-py3-none-any.whl", hash = "sha256:bf0a1387354d3691d102edef7ec124f219ef639982d096e26e3b60aeffa90637", size = 36533 },
]
[[package]]
name = "email-validator"
version = "2.2.0"
@ -449,16 +443,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995 },
{ url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320 },
{ url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 },
{ url = "https://files.pythonhosted.org/packages/3d/d9/a3114df5fba2bf9823e0acc01e9e2abdcd8ea4c5487cf1c3dcd4cc0b48cf/greenlet-3.2.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:42efc522c0bd75ffa11a71e09cd8a399d83fafe36db250a87cf1dacfaa15dc64", size = 267769 },
{ url = "https://files.pythonhosted.org/packages/bc/da/47dfc50f6e5673116e66a737dc58d1eca651db9a9aa8797c1d27e940e211/greenlet-3.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d760f9bdfe79bff803bad32b4d8ffb2c1d2ce906313fc10a83976ffb73d64ca7", size = 625472 },
{ url = "https://files.pythonhosted.org/packages/f5/74/f6ef9f85d981b2fcd665bbee3e69e3c0a10fb962eb4c6a5889ac3b6debfa/greenlet-3.2.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8324319cbd7b35b97990090808fdc99c27fe5338f87db50514959f8059999805", size = 637253 },
{ url = "https://files.pythonhosted.org/packages/66/69/4919bb1c9e43bfc16dc886e7a37fe1bc04bfa4101aba177936a10f313cad/greenlet-3.2.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:8c37ef5b3787567d322331d5250e44e42b58c8c713859b8a04c6065f27efbf72", size = 632611 },
{ url = "https://files.pythonhosted.org/packages/6b/8d/97d988d019f40b6b360b0c71c99e5b4c877a3d92666fe48b081d0e1ea1cd/greenlet-3.2.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ce539fb52fb774d0802175d37fcff5c723e2c7d249c65916257f0a940cee8904", size = 631843 },
{ url = "https://files.pythonhosted.org/packages/59/24/d5e1504ec00768755d4ccc2168b76d9f4524e96694a14ad45bd87796e9bb/greenlet-3.2.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:003c930e0e074db83559edc8705f3a2d066d4aa8c2f198aff1e454946efd0f26", size = 580781 },
{ url = "https://files.pythonhosted.org/packages/9c/df/d009bcca566dbfd2283b306b4e424f4c0e59bf984868f8b789802fe9e607/greenlet-3.2.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7e70ea4384b81ef9e84192e8a77fb87573138aa5d4feee541d8014e452b434da", size = 1109903 },
{ url = "https://files.pythonhosted.org/packages/33/54/5036097197a78388aa6901a5b90b562f3a154a9fbee89c301a26f56f3942/greenlet-3.2.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:22eb5ba839c4b2156f18f76768233fe44b23a31decd9cc0d4cc8141c211fd1b4", size = 1133975 },
{ url = "https://files.pythonhosted.org/packages/e2/15/b001456a430805fdd8b600a788d19a790664eee8863739523395f68df752/greenlet-3.2.3-cp39-cp39-win32.whl", hash = "sha256:4532f0d25df67f896d137431b13f4cdce89f7e3d4a96387a41290910df4d3a57", size = 279320 },
{ url = "https://files.pythonhosted.org/packages/6c/4c/bf2100cbc1bd07f39bee3b09e7eef39beffe29f5453dc2477a2693737913/greenlet-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:aaa7aae1e7f75eaa3ae400ad98f8644bb81e1dc6ba47ce8a93d3f17274e08322", size = 296444 },
]
[[package]]
name = "groq"
version = "0.28.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d0/7d/bb053ba75357bf5e8c33def63fb31c8b0bb86dce07759a0cd8e3232d2df9/groq-0.28.0.tar.gz", hash = "sha256:65e1cab9184cbb32380d62eca50d6162269c7ec0c77e4cc868069cfe93450f9f", size = 131730 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/24/20fc18d1b3e0883aeb24286ca8f26dc1970561b07d9c4412c84561bdf307/groq-0.28.0-py3-none-any.whl", hash = "sha256:c6f86638371c2cba2ca337232e76c8d412e75965ed7e3058d30c9aa5dfe84303", size = 130217 },
]
[[package]]
@ -476,17 +477,16 @@ version = "2.0.1"
source = { virtual = "." }
dependencies = [
{ name = "alembic" },
{ name = "anthropic" },
{ name = "fastapi", extra = ["standard"] },
{ name = "fastapi-pagination" },
{ name = "google-genai" },
{ name = "greenlet" },
{ name = "httpx" },
{ name = "langfuse" },
{ name = "mirascope", extra = ["anthropic", "google", "groq", "langfuse"] },
{ name = "nanoid" },
{ name = "openai" },
{ name = "pgvector" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "python-dotenv" },
@ -494,11 +494,11 @@ dependencies = [
{ name = "sentry-sdk", extra = ["anthropic", "fastapi", "sqlalchemy"] },
{ name = "sqlalchemy" },
{ name = "tiktoken" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
[package.dev-dependencies]
dev = [
{ name = "basedpyright" },
{ name = "coverage" },
{ name = "interrogate" },
{ name = "py-spy" },
@ -511,29 +511,28 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.14.0" },
{ name = "anthropic", specifier = ">=0.36.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.111.0" },
{ name = "fastapi-pagination", specifier = ">=0.12.24" },
{ name = "google-genai", specifier = ">=1.10.0" },
{ name = "greenlet", specifier = ">=3.0.3" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "langfuse", specifier = ">=2.57.1,<3.0.0" },
{ name = "mirascope", extras = ["anthropic", "google", "groq", "langfuse"], specifier = ">=1.25.1" },
{ name = "nanoid", specifier = ">=2.0.0" },
{ name = "openai", specifier = ">=1.43.0" },
{ name = "openai", specifier = ">=1.91.0" },
{ name = "pgvector", specifier = ">=0.2.5" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1.19" },
{ name = "pydantic-settings", specifier = ">=2.9.1" },
{ name = "pydantic", specifier = ">=2.11.7" },
{ name = "pydantic-settings", specifier = ">=2.10.1" },
{ name = "pyjwt", specifier = ">=2.10.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
{ name = "rich", specifier = ">=13.7.1" },
{ name = "sentry-sdk", extras = ["anthropic", "fastapi", "sqlalchemy"], specifier = ">=2.3.1" },
{ name = "sqlalchemy", specifier = ">=2.0.30" },
{ name = "tiktoken", specifier = ">=0.9.0" },
{ name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "basedpyright", specifier = ">=1.29.4" },
{ name = "coverage", specifier = ">=7.6.0" },
{ name = "interrogate", specifier = ">=1.7.0" },
{ name = "py-spy", specifier = ">=0.3.14" },
@ -590,13 +589,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858 },
{ url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042 },
{ url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682 },
{ url = "https://files.pythonhosted.org/packages/51/b1/4fc6f52afdf93b7c4304e21f6add9e981e4f857c2fa622a55dfe21b6059e/httptools-0.6.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:85797e37e8eeaa5439d33e556662cc370e474445d5fab24dcadc65a8ffb04003", size = 201123 },
{ url = "https://files.pythonhosted.org/packages/c2/01/e6ecb40ac8fdfb76607c7d3b74a41b464458d5c8710534d8f163b0c15f29/httptools-0.6.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:db353d22843cf1028f43c3651581e4bb49374d85692a85f95f7b9a130e1b2cab", size = 104507 },
{ url = "https://files.pythonhosted.org/packages/dc/24/c70c34119d209bf08199d938dc9c69164f585ed3029237b4bdb90f673cb9/httptools-0.6.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1ffd262a73d7c28424252381a5b854c19d9de5f56f075445d33919a637e3547", size = 449615 },
{ url = "https://files.pythonhosted.org/packages/2b/62/e7f317fed3703bd81053840cacba4e40bcf424b870e4197f94bd1cf9fe7a/httptools-0.6.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:703c346571fa50d2e9856a37d7cd9435a25e7fd15e236c397bf224afaa355fe9", size = 448819 },
{ url = "https://files.pythonhosted.org/packages/2a/13/68337d3be6b023260139434c49d7aa466aaa98f9aee7ed29270ac7dde6a2/httptools-0.6.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:aafe0f1918ed07b67c1e838f950b1c1fabc683030477e60b335649b8020e1076", size = 422093 },
{ url = "https://files.pythonhosted.org/packages/fc/b3/3a1bc45be03dda7a60c7858e55b6cd0489a81613c1908fb81cf21d34ae50/httptools-0.6.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:0e563e54979e97b6d13f1bbc05a96109923e76b901f786a5eae36e99c01237bd", size = 423898 },
{ url = "https://files.pythonhosted.org/packages/05/72/2ddc2ae5f7ace986f7e68a326215b2e7c32e32fd40e6428fa8f1d8065c7e/httptools-0.6.4-cp39-cp39-win_amd64.whl", hash = "sha256:b799de31416ecc589ad79dd85a0b2657a8fe39327944998dea368c1d4c9e55e6", size = 89552 },
]
[[package]]
@ -731,18 +723,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/b3/2bd02071c5a2430d0b70403a34411fc519c2f227da7b03da9ba6a956f931/jiter-0.10.0-cp314-cp314-win32.whl", hash = "sha256:ac509f7eccca54b2a29daeb516fb95b6f0bd0d0d8084efaf8ed5dfc7b9f0b357", size = 210127 },
{ url = "https://files.pythonhosted.org/packages/03/0c/5fe86614ea050c3ecd728ab4035534387cd41e7c1855ef6c031f1ca93e3f/jiter-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ed975b83a2b8639356151cef5c0d597c68376fc4922b45d0eb384ac058cfa00", size = 318527 },
{ url = "https://files.pythonhosted.org/packages/b3/4a/4175a563579e884192ba6e81725fc0448b042024419be8d83aa8a80a3f44/jiter-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa96f2abba33dc77f79b4cf791840230375f9534e5fac927ccceb58c5e604a5", size = 354213 },
{ url = "https://files.pythonhosted.org/packages/98/fd/aced428e2bd3c6c1132f67c5a708f9e7fd161d0ca8f8c5862b17b93cdf0a/jiter-0.10.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bd6292a43c0fc09ce7c154ec0fa646a536b877d1e8f2f96c19707f65355b5a4d", size = 317665 },
{ url = "https://files.pythonhosted.org/packages/b6/2e/47d42f15d53ed382aef8212a737101ae2720e3697a954f9b95af06d34e89/jiter-0.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:39de429dcaeb6808d75ffe9effefe96a4903c6a4b376b2f6d08d77c1aaee2f18", size = 312152 },
{ url = "https://files.pythonhosted.org/packages/7b/02/aae834228ef4834fc18718724017995ace8da5f70aa1ec225b9bc2b2d7aa/jiter-0.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52ce124f13a7a616fad3bb723f2bfb537d78239d1f7f219566dc52b6f2a9e48d", size = 346708 },
{ url = "https://files.pythonhosted.org/packages/35/d4/6ff39dee2d0a9abd69d8a3832ce48a3aa644eed75e8515b5ff86c526ca9a/jiter-0.10.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:166f3606f11920f9a1746b2eea84fa2c0a5d50fd313c38bdea4edc072000b0af", size = 371360 },
{ url = "https://files.pythonhosted.org/packages/a9/67/c749d962b4eb62445867ae4e64a543cbb5d63cc7d78ada274ac515500a7f/jiter-0.10.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:28dcecbb4ba402916034fc14eba7709f250c4d24b0c43fc94d187ee0580af181", size = 492105 },
{ url = "https://files.pythonhosted.org/packages/f6/d3/8fe1b1bae5161f27b1891c256668f598fa4c30c0a7dacd668046a6215fca/jiter-0.10.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86c5aa6910f9bebcc7bc4f8bc461aff68504388b43bfe5e5c0bd21efa33b52f4", size = 389577 },
{ url = "https://files.pythonhosted.org/packages/ef/28/ecb19d789b4777898a4252bfaac35e3f8caf16c93becd58dcbaac0dc24ad/jiter-0.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ceeb52d242b315d7f1f74b441b6a167f78cea801ad7c11c36da77ff2d42e8a28", size = 353849 },
{ url = "https://files.pythonhosted.org/packages/77/69/261f798f84790da6482ebd8c87ec976192b8c846e79444d0a2e0d33ebed8/jiter-0.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ff76d8887c8c8ee1e772274fcf8cc1071c2c58590d13e33bd12d02dc9a560397", size = 392029 },
{ url = "https://files.pythonhosted.org/packages/cb/08/b8d15140d4d91f16faa2f5d416c1a71ab1bbe2b66c57197b692d04c0335f/jiter-0.10.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a9be4d0fa2b79f7222a88aa488bd89e2ae0a0a5b189462a12def6ece2faa45f1", size = 524386 },
{ url = "https://files.pythonhosted.org/packages/9b/1d/23c41765cc95c0e23ac492a88450d34bf0fd87a37218d1b97000bffe0f53/jiter-0.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9ab7fd8738094139b6c1ab1822d6f2000ebe41515c537235fd45dabe13ec9324", size = 515234 },
{ url = "https://files.pythonhosted.org/packages/9f/14/381d8b151132e79790579819c3775be32820569f23806769658535fe467f/jiter-0.10.0-cp39-cp39-win32.whl", hash = "sha256:5f51e048540dd27f204ff4a87f5d79294ea0aa3aa552aca34934588cf27023cf", size = 211436 },
{ url = "https://files.pythonhosted.org/packages/59/66/f23ae51dea8ee8ce429027b60008ca895d0fa0704f0c7fe5f09014a6cffb/jiter-0.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:1b28302349dc65703a9e4ead16f163b1c339efffbe1049c30a44b001a2a4fff9", size = 208777 },
]
[[package]]
@ -844,16 +824,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 },
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 },
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
{ url = "https://files.pythonhosted.org/packages/a7/ea/9b1530c3fdeeca613faeb0fb5cbcf2389d816072fab72a71b45749ef6062/MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a", size = 14344 },
{ url = "https://files.pythonhosted.org/packages/4b/c2/fbdbfe48848e7112ab05e627e718e854d20192b674952d9042ebd8c9e5de/MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff", size = 12389 },
{ url = "https://files.pythonhosted.org/packages/f0/25/7a7c6e4dbd4f867d95d94ca15449e91e52856f6ed1905d58ef1de5e211d0/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13", size = 21607 },
{ url = "https://files.pythonhosted.org/packages/53/8f/f339c98a178f3c1e545622206b40986a4c3307fe39f70ccd3d9df9a9e425/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144", size = 20728 },
{ url = "https://files.pythonhosted.org/packages/1a/03/8496a1a78308456dbd50b23a385c69b41f2e9661c67ea1329849a598a8f9/MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29", size = 20826 },
{ url = "https://files.pythonhosted.org/packages/e6/cf/0a490a4bd363048c3022f2f475c8c05582179bb179defcee4766fb3dcc18/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0", size = 21843 },
{ url = "https://files.pythonhosted.org/packages/19/a3/34187a78613920dfd3cdf68ef6ce5e99c4f3417f035694074beb8848cd77/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0", size = 21219 },
{ url = "https://files.pythonhosted.org/packages/17/d8/5811082f85bb88410ad7e452263af048d685669bbbfb7b595e8689152498/MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178", size = 20946 },
{ url = "https://files.pythonhosted.org/packages/7c/31/bd635fb5989440d9365c5e3c47556cfea121c7803f5034ac843e8f37c2f2/MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f", size = 15063 },
{ url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506 },
]
[[package]]
@ -865,6 +835,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 },
]
[[package]]
name = "mirascope"
version = "1.25.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docstring-parser" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/e7/4a196658d3e9ffe5840872922e0ea5e9f583bc3cb204c769e55347e142c1/mirascope-1.25.2.tar.gz", hash = "sha256:88df7232c8b41150e0f788371ba5657c08c27eb544dec93aa4d8354adf04f4be", size = 627149 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/1d/cf7f99294c19552f4a80ba092d8b883ab34161f7030c66c38a5c43e27c0d/mirascope-1.25.2-py3-none-any.whl", hash = "sha256:9d96bec0bcb7fa3dd767803d978b71167eb1fd657be865b9f3bfbd23f24d2187", size = 373194 },
]
[package.optional-dependencies]
anthropic = [
{ name = "anthropic" },
]
google = [
{ name = "google-genai" },
{ name = "pillow" },
{ name = "proto-plus" },
]
groq = [
{ name = "groq" },
]
langfuse = [
{ name = "langfuse" },
]
[[package]]
name = "nanoid"
version = "2.0.0"
@ -875,60 +876,86 @@ wheels = [
]
[[package]]
name = "numpy"
version = "2.0.2"
name = "nodejs-wheel-binaries"
version = "22.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015 }
sdist = { url = "https://files.pythonhosted.org/packages/0f/c6/66f36b7b0d528660dfb4a59cb9b8dd6a3f4c0a3939cd49c404a775ea4a63/nodejs_wheel_binaries-22.16.0.tar.gz", hash = "sha256:d695832f026df3a0cf9a089d222225939de9d1b67f8f0a353b79f015aabbe7e2", size = 8061 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/91/3495b3237510f79f5d81f2508f9f13fea78ebfdf07538fc7444badda173d/numpy-2.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:51129a29dbe56f9ca83438b706e2e69a39892b5eda6cedcb6b0c9fdc9b0d3ece", size = 21165245 },
{ url = "https://files.pythonhosted.org/packages/05/33/26178c7d437a87082d11019292dce6d3fe6f0e9026b7b2309cbf3e489b1d/numpy-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f15975dfec0cf2239224d80e32c3170b1d168335eaedee69da84fbe9f1f9cd04", size = 13738540 },
{ url = "https://files.pythonhosted.org/packages/ec/31/cc46e13bf07644efc7a4bf68df2df5fb2a1a88d0cd0da9ddc84dc0033e51/numpy-2.0.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:8c5713284ce4e282544c68d1c3b2c7161d38c256d2eefc93c1d683cf47683e66", size = 5300623 },
{ url = "https://files.pythonhosted.org/packages/6e/16/7bfcebf27bb4f9d7ec67332ffebee4d1bf085c84246552d52dbb548600e7/numpy-2.0.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:becfae3ddd30736fe1889a37f1f580e245ba79a5855bff5f2a29cb3ccc22dd7b", size = 6901774 },
{ url = "https://files.pythonhosted.org/packages/f9/a3/561c531c0e8bf082c5bef509d00d56f82e0ea7e1e3e3a7fc8fa78742a6e5/numpy-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2da5960c3cf0df7eafefd806d4e612c5e19358de82cb3c343631188991566ccd", size = 13907081 },
{ url = "https://files.pythonhosted.org/packages/fa/66/f7177ab331876200ac7563a580140643d1179c8b4b6a6b0fc9838de2a9b8/numpy-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:496f71341824ed9f3d2fd36cf3ac57ae2e0165c143b55c3a035ee219413f3318", size = 19523451 },
{ url = "https://files.pythonhosted.org/packages/25/7f/0b209498009ad6453e4efc2c65bcdf0ae08a182b2b7877d7ab38a92dc542/numpy-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a61ec659f68ae254e4d237816e33171497e978140353c0c2038d46e63282d0c8", size = 19927572 },
{ url = "https://files.pythonhosted.org/packages/3e/df/2619393b1e1b565cd2d4c4403bdd979621e2c4dea1f8532754b2598ed63b/numpy-2.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d731a1c6116ba289c1e9ee714b08a8ff882944d4ad631fd411106a30f083c326", size = 14400722 },
{ url = "https://files.pythonhosted.org/packages/22/ad/77e921b9f256d5da36424ffb711ae79ca3f451ff8489eeca544d0701d74a/numpy-2.0.2-cp310-cp310-win32.whl", hash = "sha256:984d96121c9f9616cd33fbd0618b7f08e0cfc9600a7ee1d6fd9b239186d19d97", size = 6472170 },
{ url = "https://files.pythonhosted.org/packages/10/05/3442317535028bc29cf0c0dd4c191a4481e8376e9f0db6bcf29703cadae6/numpy-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:c7b0be4ef08607dd04da4092faee0b86607f111d5ae68036f16cc787e250a131", size = 15905558 },
{ url = "https://files.pythonhosted.org/packages/8b/cf/034500fb83041aa0286e0fb16e7c76e5c8b67c0711bb6e9e9737a717d5fe/numpy-2.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:49ca4decb342d66018b01932139c0961a8f9ddc7589611158cb3c27cbcf76448", size = 21169137 },
{ url = "https://files.pythonhosted.org/packages/4a/d9/32de45561811a4b87fbdee23b5797394e3d1504b4a7cf40c10199848893e/numpy-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:11a76c372d1d37437857280aa142086476136a8c0f373b2e648ab2c8f18fb195", size = 13703552 },
{ url = "https://files.pythonhosted.org/packages/c1/ca/2f384720020c7b244d22508cb7ab23d95f179fcfff33c31a6eeba8d6c512/numpy-2.0.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:807ec44583fd708a21d4a11d94aedf2f4f3c3719035c76a2bbe1fe8e217bdc57", size = 5298957 },
{ url = "https://files.pythonhosted.org/packages/0e/78/a3e4f9fb6aa4e6fdca0c5428e8ba039408514388cf62d89651aade838269/numpy-2.0.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8cafab480740e22f8d833acefed5cc87ce276f4ece12fdaa2e8903db2f82897a", size = 6905573 },
{ url = "https://files.pythonhosted.org/packages/a0/72/cfc3a1beb2caf4efc9d0b38a15fe34025230da27e1c08cc2eb9bfb1c7231/numpy-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a15f476a45e6e5a3a79d8a14e62161d27ad897381fecfa4a09ed5322f2085669", size = 13914330 },
{ url = "https://files.pythonhosted.org/packages/ba/a8/c17acf65a931ce551fee11b72e8de63bf7e8a6f0e21add4c937c83563538/numpy-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13e689d772146140a252c3a28501da66dfecd77490b498b168b501835041f951", size = 19534895 },
{ url = "https://files.pythonhosted.org/packages/ba/86/8767f3d54f6ae0165749f84648da9dcc8cd78ab65d415494962c86fac80f/numpy-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9ea91dfb7c3d1c56a0e55657c0afb38cf1eeae4544c208dc465c3c9f3a7c09f9", size = 19937253 },
{ url = "https://files.pythonhosted.org/packages/df/87/f76450e6e1c14e5bb1eae6836478b1028e096fd02e85c1c37674606ab752/numpy-2.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c1c9307701fec8f3f7a1e6711f9089c06e6284b3afbbcd259f7791282d660a15", size = 14414074 },
{ url = "https://files.pythonhosted.org/packages/5c/ca/0f0f328e1e59f73754f06e1adfb909de43726d4f24c6a3f8805f34f2b0fa/numpy-2.0.2-cp311-cp311-win32.whl", hash = "sha256:a392a68bd329eafac5817e5aefeb39038c48b671afd242710b451e76090e81f4", size = 6470640 },
{ url = "https://files.pythonhosted.org/packages/eb/57/3a3f14d3a759dcf9bf6e9eda905794726b758819df4663f217d658a58695/numpy-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:286cd40ce2b7d652a6f22efdfc6d1edf879440e53e76a75955bc0c826c7e64dc", size = 15910230 },
{ url = "https://files.pythonhosted.org/packages/45/40/2e117be60ec50d98fa08c2f8c48e09b3edea93cfcabd5a9ff6925d54b1c2/numpy-2.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:df55d490dea7934f330006d0f81e8551ba6010a5bf035a249ef61a94f21c500b", size = 20895803 },
{ url = "https://files.pythonhosted.org/packages/46/92/1b8b8dee833f53cef3e0a3f69b2374467789e0bb7399689582314df02651/numpy-2.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8df823f570d9adf0978347d1f926b2a867d5608f434a7cff7f7908c6570dcf5e", size = 13471835 },
{ url = "https://files.pythonhosted.org/packages/7f/19/e2793bde475f1edaea6945be141aef6c8b4c669b90c90a300a8954d08f0a/numpy-2.0.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9a92ae5c14811e390f3767053ff54eaee3bf84576d99a2456391401323f4ec2c", size = 5038499 },
{ url = "https://files.pythonhosted.org/packages/e3/ff/ddf6dac2ff0dd50a7327bcdba45cb0264d0e96bb44d33324853f781a8f3c/numpy-2.0.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:a842d573724391493a97a62ebbb8e731f8a5dcc5d285dfc99141ca15a3302d0c", size = 6633497 },
{ url = "https://files.pythonhosted.org/packages/72/21/67f36eac8e2d2cd652a2e69595a54128297cdcb1ff3931cfc87838874bd4/numpy-2.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05e238064fc0610c840d1cf6a13bf63d7e391717d247f1bf0318172e759e692", size = 13621158 },
{ url = "https://files.pythonhosted.org/packages/39/68/e9f1126d757653496dbc096cb429014347a36b228f5a991dae2c6b6cfd40/numpy-2.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0123ffdaa88fa4ab64835dcbde75dcdf89c453c922f18dced6e27c90d1d0ec5a", size = 19236173 },
{ url = "https://files.pythonhosted.org/packages/d1/e9/1f5333281e4ebf483ba1c888b1d61ba7e78d7e910fdd8e6499667041cc35/numpy-2.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:96a55f64139912d61de9137f11bf39a55ec8faec288c75a54f93dfd39f7eb40c", size = 19634174 },
{ url = "https://files.pythonhosted.org/packages/71/af/a469674070c8d8408384e3012e064299f7a2de540738a8e414dcfd639996/numpy-2.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec9852fb39354b5a45a80bdab5ac02dd02b15f44b3804e9f00c556bf24b4bded", size = 14099701 },
{ url = "https://files.pythonhosted.org/packages/d0/3d/08ea9f239d0e0e939b6ca52ad403c84a2bce1bde301a8eb4888c1c1543f1/numpy-2.0.2-cp312-cp312-win32.whl", hash = "sha256:671bec6496f83202ed2d3c8fdc486a8fc86942f2e69ff0e986140339a63bcbe5", size = 6174313 },
{ url = "https://files.pythonhosted.org/packages/b2/b5/4ac39baebf1fdb2e72585c8352c56d063b6126be9fc95bd2bb5ef5770c20/numpy-2.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:cfd41e13fdc257aa5778496b8caa5e856dc4896d4ccf01841daee1d96465467a", size = 15606179 },
{ url = "https://files.pythonhosted.org/packages/43/c1/41c8f6df3162b0c6ffd4437d729115704bd43363de0090c7f913cfbc2d89/numpy-2.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9059e10581ce4093f735ed23f3b9d283b9d517ff46009ddd485f1747eb22653c", size = 21169942 },
{ url = "https://files.pythonhosted.org/packages/39/bc/fd298f308dcd232b56a4031fd6ddf11c43f9917fbc937e53762f7b5a3bb1/numpy-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:423e89b23490805d2a5a96fe40ec507407b8ee786d66f7328be214f9679df6dd", size = 13711512 },
{ url = "https://files.pythonhosted.org/packages/96/ff/06d1aa3eeb1c614eda245c1ba4fb88c483bee6520d361641331872ac4b82/numpy-2.0.2-cp39-cp39-macosx_14_0_arm64.whl", hash = "sha256:2b2955fa6f11907cf7a70dab0d0755159bca87755e831e47932367fc8f2f2d0b", size = 5306976 },
{ url = "https://files.pythonhosted.org/packages/2d/98/121996dcfb10a6087a05e54453e28e58694a7db62c5a5a29cee14c6e047b/numpy-2.0.2-cp39-cp39-macosx_14_0_x86_64.whl", hash = "sha256:97032a27bd9d8988b9a97a8c4d2c9f2c15a81f61e2f21404d7e8ef00cb5be729", size = 6906494 },
{ url = "https://files.pythonhosted.org/packages/15/31/9dffc70da6b9bbf7968f6551967fc21156207366272c2a40b4ed6008dc9b/numpy-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e795a8be3ddbac43274f18588329c72939870a16cae810c2b73461c40718ab1", size = 13912596 },
{ url = "https://files.pythonhosted.org/packages/b9/14/78635daab4b07c0930c919d451b8bf8c164774e6a3413aed04a6d95758ce/numpy-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b258c385842546006213344c50655ff1555a9338e2e5e02a0756dc3e803dd", size = 19526099 },
{ url = "https://files.pythonhosted.org/packages/26/4c/0eeca4614003077f68bfe7aac8b7496f04221865b3a5e7cb230c9d055afd/numpy-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5fec9451a7789926bcf7c2b8d187292c9f93ea30284802a0ab3f5be8ab36865d", size = 19932823 },
{ url = "https://files.pythonhosted.org/packages/f1/46/ea25b98b13dccaebddf1a803f8c748680d972e00507cd9bc6dcdb5aa2ac1/numpy-2.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9189427407d88ff25ecf8f12469d4d39d35bee1db5d39fc5c168c6f088a6956d", size = 14404424 },
{ url = "https://files.pythonhosted.org/packages/c8/a6/177dd88d95ecf07e722d21008b1b40e681a929eb9e329684d449c36586b2/numpy-2.0.2-cp39-cp39-win32.whl", hash = "sha256:905d16e0c60200656500c95b6b8dca5d109e23cb24abc701d41c02d74c6b3afa", size = 6476809 },
{ url = "https://files.pythonhosted.org/packages/ea/2b/7fc9f4e7ae5b507c1a3a21f0f15ed03e794c1242ea8a242ac158beb56034/numpy-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:a3f4ab0caa7f053f6797fcd4e1e25caee367db3112ef2b6ef82d749530768c73", size = 15911314 },
{ url = "https://files.pythonhosted.org/packages/8f/3b/df5a870ac6a3be3a86856ce195ef42eec7ae50d2a202be1f5a4b3b340e14/numpy-2.0.2-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7f0a0c6f12e07fa94133c8a67404322845220c06a9e80e85999afe727f7438b8", size = 21025288 },
{ url = "https://files.pythonhosted.org/packages/2c/97/51af92f18d6f6f2d9ad8b482a99fb74e142d71372da5d834b3a2747a446e/numpy-2.0.2-pp39-pypy39_pp73-macosx_14_0_x86_64.whl", hash = "sha256:312950fdd060354350ed123c0e25a71327d3711584beaef30cdaa93320c392d4", size = 6762793 },
{ url = "https://files.pythonhosted.org/packages/12/46/de1fbd0c1b5ccaa7f9a005b66761533e2f6a3e560096682683a223631fe9/numpy-2.0.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26df23238872200f63518dd2aa984cfca675d82469535dc7162dc2ee52d9dd5c", size = 19334885 },
{ url = "https://files.pythonhosted.org/packages/cc/dc/d330a6faefd92b446ec0f0dfea4c3207bb1fef3c4771d19cf4543efd2c78/numpy-2.0.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a46288ec55ebbd58947d31d72be2c63cbf839f0a63b49cb755022310792a3385", size = 15828784 },
{ url = "https://files.pythonhosted.org/packages/d7/dc/417a5c5f99e53a5d2b3be122506312731eb90fb9630c248e327e2e38cc6b/nodejs_wheel_binaries-22.16.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:986b715a96ed703f8ce0c15712f76fc42895cf09067d72b6ef29e8b334eccf64", size = 50957501 },
{ url = "https://files.pythonhosted.org/packages/0e/dd/d6ce48209ed15f5d1fccb29eeaa111f962557123eaf4fd03a7316c42734c/nodejs_wheel_binaries-22.16.0-py2.py3-none-macosx_11_0_x86_64.whl", hash = "sha256:4ae3cf22138891cb44c3ee952862a257ce082b098b29024d7175684a9a77b0c0", size = 51891634 },
{ url = "https://files.pythonhosted.org/packages/80/fa/a07e622fd87717eec3e5cff41575f85ad62717e8698884d28ca809266ca1/nodejs_wheel_binaries-22.16.0-py2.py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71f2de4dc0b64ae43e146897ce811f80ac4f9acfbae6ccf814226282bf4ef174", size = 57857862 },
{ url = "https://files.pythonhosted.org/packages/1f/80/52736f9570a93f8e6b7942981dc9770eca2bc7aa1d200c1d54198374a6ca/nodejs_wheel_binaries-22.16.0-py2.py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dbfccbcd558d2f142ccf66d8c3a098022bf4436db9525b5b8d32169ce185d99e", size = 58395868 },
{ url = "https://files.pythonhosted.org/packages/0f/0e/53616a5ed8fc1fbe9e48bf132862da5a9abf5cc7f8483dab1722ec257187/nodejs_wheel_binaries-22.16.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:447ad796850eb52ca20356ad39b2d296ed8fef3f214921f84a1ccdad49f2eba1", size = 59712469 },
{ url = "https://files.pythonhosted.org/packages/4a/cd/e2b5083df581fc1d08eb93feb6f8fbd3d56b113cef9b59d8e0fb7d4dd4f3/nodejs_wheel_binaries-22.16.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7f526ca6a132b0caf633566a2a78c6985fe92857e7bfdb37380f76205a10b808", size = 60763005 },
{ url = "https://files.pythonhosted.org/packages/71/8d/57112b49214e8bd636f3cc3386eba6be4d23552ec8a0f6efbe814013caa7/nodejs_wheel_binaries-22.16.0-py2.py3-none-win_amd64.whl", hash = "sha256:2fffb4bf1066fb5f660da20819d754f1b424bca1b234ba0f4fa901c52e3975fb", size = 41313324 },
{ url = "https://files.pythonhosted.org/packages/91/03/a852711aec73dfb965844592dfe226024c0da28e37d1ee54083342e38f57/nodejs_wheel_binaries-22.16.0-py2.py3-none-win_arm64.whl", hash = "sha256:2728972d336d436d39ee45988978d8b5d963509e06f063e80fe41b203ee80b28", size = 38828154 },
]
[[package]]
name = "numpy"
version = "2.2.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245 },
{ url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048 },
{ url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542 },
{ url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301 },
{ url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320 },
{ url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050 },
{ url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034 },
{ url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185 },
{ url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149 },
{ url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620 },
{ url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963 },
{ url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743 },
{ url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616 },
{ url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579 },
{ url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005 },
{ url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570 },
{ url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548 },
{ url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521 },
{ url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866 },
{ url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455 },
{ url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348 },
{ url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362 },
{ url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103 },
{ url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382 },
{ url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462 },
{ url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618 },
{ url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511 },
{ url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783 },
{ url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506 },
{ url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190 },
{ url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828 },
{ url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006 },
{ url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765 },
{ url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736 },
{ url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719 },
{ url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072 },
{ url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213 },
{ url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632 },
{ url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532 },
{ url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885 },
{ url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467 },
{ url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144 },
{ url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217 },
{ url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014 },
{ url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935 },
{ url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122 },
{ url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143 },
{ url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260 },
{ url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225 },
{ url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374 },
{ url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391 },
{ url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754 },
{ url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476 },
{ url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666 },
]
[[package]]
name = "openai"
version = "1.88.0"
version = "1.91.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -940,9 +967,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/ea/bbeef604d1fe0f7e9111745bb8a81362973a95713b28855beb9a9832ab12/openai-1.88.0.tar.gz", hash = "sha256:122d35e42998255cf1fc84560f6ee49a844e65c054cd05d3e42fda506b832bb1", size = 470963 }
sdist = { url = "https://files.pythonhosted.org/packages/0f/e2/a22f2973b729eff3f1f429017bdf717930c5de0fbf9e14017bae330e4e7a/openai-1.91.0.tar.gz", hash = "sha256:d6b07730d2f7c6745d0991997c16f85cddfc90ddcde8d569c862c30716b9fc90", size = 472529 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/03/ef68d77a38dd383cbed7fc898857d394d5a8b0520a35f054e7fe05dc3ac1/openai-1.88.0-py3-none-any.whl", hash = "sha256:7edd7826b3b83f5846562a6f310f040c79576278bf8e3687b30ba05bb5dff978", size = 734293 },
{ url = "https://files.pythonhosted.org/packages/7a/d2/f99bdd6fc737d6b3cf0df895508d621fc9a386b375a1230ee81d46c5436e/openai-1.91.0-py3-none-any.whl", hash = "sha256:207f87aa3bc49365e014fac2f7e291b99929f4fe126c4654143440e0ad446a5f", size = 735837 },
]
[[package]]
@ -966,6 +993,65 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/21/b5735d5982892c878ff3d01bb06e018c43fc204428361ee9fc25a1b2125c/pgvector-0.4.1-py3-none-any.whl", hash = "sha256:34bb4e99e1b13d08a2fe82dda9f860f15ddcd0166fbb25bffe15821cbfeb7362", size = 27086 },
]
[[package]]
name = "pillow"
version = "10.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/74/ad3d526f3bf7b6d3f408b73fde271ec69dfac8b81341a318ce825f2b3812/pillow-10.4.0.tar.gz", hash = "sha256:166c1cd4d24309b30d61f79f4a9114b7b2313d7450912277855ff5dfd7cd4a06", size = 46555059 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/69/a31cccd538ca0b5272be2a38347f8839b97a14be104ea08b0db92f749c74/pillow-10.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:4d9667937cfa347525b319ae34375c37b9ee6b525440f3ef48542fcf66f2731e", size = 3509271 },
{ url = "https://files.pythonhosted.org/packages/9a/9e/4143b907be8ea0bce215f2ae4f7480027473f8b61fcedfda9d851082a5d2/pillow-10.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:543f3dc61c18dafb755773efc89aae60d06b6596a63914107f75459cf984164d", size = 3375658 },
{ url = "https://files.pythonhosted.org/packages/8a/25/1fc45761955f9359b1169aa75e241551e74ac01a09f487adaaf4c3472d11/pillow-10.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7928ecbf1ece13956b95d9cbcfc77137652b02763ba384d9ab508099a2eca856", size = 4332075 },
{ url = "https://files.pythonhosted.org/packages/5e/dd/425b95d0151e1d6c951f45051112394f130df3da67363b6bc75dc4c27aba/pillow-10.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4d49b85c4348ea0b31ea63bc75a9f3857869174e2bf17e7aba02945cd218e6f", size = 4444808 },
{ url = "https://files.pythonhosted.org/packages/b1/84/9a15cc5726cbbfe7f9f90bfb11f5d028586595907cd093815ca6644932e3/pillow-10.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6c762a5b0997f5659a5ef2266abc1d8851ad7749ad9a6a5506eb23d314e4f46b", size = 4356290 },
{ url = "https://files.pythonhosted.org/packages/b5/5b/6651c288b08df3b8c1e2f8c1152201e0b25d240e22ddade0f1e242fc9fa0/pillow-10.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a985e028fc183bf12a77a8bbf36318db4238a3ded7fa9df1b9a133f1cb79f8fc", size = 4525163 },
{ url = "https://files.pythonhosted.org/packages/07/8b/34854bf11a83c248505c8cb0fcf8d3d0b459a2246c8809b967963b6b12ae/pillow-10.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:812f7342b0eee081eaec84d91423d1b4650bb9828eb53d8511bcef8ce5aecf1e", size = 4463100 },
{ url = "https://files.pythonhosted.org/packages/78/63/0632aee4e82476d9cbe5200c0cdf9ba41ee04ed77887432845264d81116d/pillow-10.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ac1452d2fbe4978c2eec89fb5a23b8387aba707ac72810d9490118817d9c0b46", size = 4592880 },
{ url = "https://files.pythonhosted.org/packages/df/56/b8663d7520671b4398b9d97e1ed9f583d4afcbefbda3c6188325e8c297bd/pillow-10.4.0-cp310-cp310-win32.whl", hash = "sha256:bcd5e41a859bf2e84fdc42f4edb7d9aba0a13d29a2abadccafad99de3feff984", size = 2235218 },
{ url = "https://files.pythonhosted.org/packages/f4/72/0203e94a91ddb4a9d5238434ae6c1ca10e610e8487036132ea9bf806ca2a/pillow-10.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:ecd85a8d3e79cd7158dec1c9e5808e821feea088e2f69a974db5edf84dc53141", size = 2554487 },
{ url = "https://files.pythonhosted.org/packages/bd/52/7e7e93d7a6e4290543f17dc6f7d3af4bd0b3dd9926e2e8a35ac2282bc5f4/pillow-10.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:ff337c552345e95702c5fde3158acb0625111017d0e5f24bf3acdb9cc16b90d1", size = 2243219 },
{ url = "https://files.pythonhosted.org/packages/a7/62/c9449f9c3043c37f73e7487ec4ef0c03eb9c9afc91a92b977a67b3c0bbc5/pillow-10.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0a9ec697746f268507404647e531e92889890a087e03681a3606d9b920fbee3c", size = 3509265 },
{ url = "https://files.pythonhosted.org/packages/f4/5f/491dafc7bbf5a3cc1845dc0430872e8096eb9e2b6f8161509d124594ec2d/pillow-10.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfe91cb65544a1321e631e696759491ae04a2ea11d36715eca01ce07284738be", size = 3375655 },
{ url = "https://files.pythonhosted.org/packages/73/d5/c4011a76f4207a3c151134cd22a1415741e42fa5ddecec7c0182887deb3d/pillow-10.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dc6761a6efc781e6a1544206f22c80c3af4c8cf461206d46a1e6006e4429ff3", size = 4340304 },
{ url = "https://files.pythonhosted.org/packages/ac/10/c67e20445a707f7a610699bba4fe050583b688d8cd2d202572b257f46600/pillow-10.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e84b6cc6a4a3d76c153a6b19270b3526a5a8ed6b09501d3af891daa2a9de7d6", size = 4452804 },
{ url = "https://files.pythonhosted.org/packages/a9/83/6523837906d1da2b269dee787e31df3b0acb12e3d08f024965a3e7f64665/pillow-10.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bbc527b519bd3aa9d7f429d152fea69f9ad37c95f0b02aebddff592688998abe", size = 4365126 },
{ url = "https://files.pythonhosted.org/packages/ba/e5/8c68ff608a4203085158cff5cc2a3c534ec384536d9438c405ed6370d080/pillow-10.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:76a911dfe51a36041f2e756b00f96ed84677cdeb75d25c767f296c1c1eda1319", size = 4533541 },
{ url = "https://files.pythonhosted.org/packages/f4/7c/01b8dbdca5bc6785573f4cee96e2358b0918b7b2c7b60d8b6f3abf87a070/pillow-10.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59291fb29317122398786c2d44427bbd1a6d7ff54017075b22be9d21aa59bd8d", size = 4471616 },
{ url = "https://files.pythonhosted.org/packages/c8/57/2899b82394a35a0fbfd352e290945440e3b3785655a03365c0ca8279f351/pillow-10.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:416d3a5d0e8cfe4f27f574362435bc9bae57f679a7158e0096ad2beb427b8696", size = 4600802 },
{ url = "https://files.pythonhosted.org/packages/4d/d7/a44f193d4c26e58ee5d2d9db3d4854b2cfb5b5e08d360a5e03fe987c0086/pillow-10.4.0-cp311-cp311-win32.whl", hash = "sha256:7086cc1d5eebb91ad24ded9f58bec6c688e9f0ed7eb3dbbf1e4800280a896496", size = 2235213 },
{ url = "https://files.pythonhosted.org/packages/c1/d0/5866318eec2b801cdb8c82abf190c8343d8a1cd8bf5a0c17444a6f268291/pillow-10.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cbed61494057c0f83b83eb3a310f0bf774b09513307c434d4366ed64f4128a91", size = 2554498 },
{ url = "https://files.pythonhosted.org/packages/d4/c8/310ac16ac2b97e902d9eb438688de0d961660a87703ad1561fd3dfbd2aa0/pillow-10.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:f5f0c3e969c8f12dd2bb7e0b15d5c468b51e5017e01e2e867335c81903046a22", size = 2243219 },
{ url = "https://files.pythonhosted.org/packages/05/cb/0353013dc30c02a8be34eb91d25e4e4cf594b59e5a55ea1128fde1e5f8ea/pillow-10.4.0-cp312-cp312-macosx_10_10_x86_64.whl", hash = "sha256:673655af3eadf4df6b5457033f086e90299fdd7a47983a13827acf7459c15d94", size = 3509350 },
{ url = "https://files.pythonhosted.org/packages/e7/cf/5c558a0f247e0bf9cec92bff9b46ae6474dd736f6d906315e60e4075f737/pillow-10.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866b6942a92f56300012f5fbac71f2d610312ee65e22f1aa2609e491284e5597", size = 3374980 },
{ url = "https://files.pythonhosted.org/packages/84/48/6e394b86369a4eb68b8a1382c78dc092245af517385c086c5094e3b34428/pillow-10.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29dbdc4207642ea6aad70fbde1a9338753d33fb23ed6956e706936706f52dd80", size = 4343799 },
{ url = "https://files.pythonhosted.org/packages/3b/f3/a8c6c11fa84b59b9df0cd5694492da8c039a24cd159f0f6918690105c3be/pillow-10.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf2342ac639c4cf38799a44950bbc2dfcb685f052b9e262f446482afaf4bffca", size = 4459973 },
{ url = "https://files.pythonhosted.org/packages/7d/1b/c14b4197b80150fb64453585247e6fb2e1d93761fa0fa9cf63b102fde822/pillow-10.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:f5b92f4d70791b4a67157321c4e8225d60b119c5cc9aee8ecf153aace4aad4ef", size = 4370054 },
{ url = "https://files.pythonhosted.org/packages/55/77/40daddf677897a923d5d33329acd52a2144d54a9644f2a5422c028c6bf2d/pillow-10.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:86dcb5a1eb778d8b25659d5e4341269e8590ad6b4e8b44d9f4b07f8d136c414a", size = 4539484 },
{ url = "https://files.pythonhosted.org/packages/40/54/90de3e4256b1207300fb2b1d7168dd912a2fb4b2401e439ba23c2b2cabde/pillow-10.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:780c072c2e11c9b2c7ca37f9a2ee8ba66f44367ac3e5c7832afcfe5104fd6d1b", size = 4477375 },
{ url = "https://files.pythonhosted.org/packages/13/24/1bfba52f44193860918ff7c93d03d95e3f8748ca1de3ceaf11157a14cf16/pillow-10.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37fb69d905be665f68f28a8bba3c6d3223c8efe1edf14cc4cfa06c241f8c81d9", size = 4608773 },
{ url = "https://files.pythonhosted.org/packages/55/04/5e6de6e6120451ec0c24516c41dbaf80cce1b6451f96561235ef2429da2e/pillow-10.4.0-cp312-cp312-win32.whl", hash = "sha256:7dfecdbad5c301d7b5bde160150b4db4c659cee2b69589705b6f8a0c509d9f42", size = 2235690 },
{ url = "https://files.pythonhosted.org/packages/74/0a/d4ce3c44bca8635bd29a2eab5aa181b654a734a29b263ca8efe013beea98/pillow-10.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d846aea995ad352d4bdcc847535bd56e0fd88d36829d2c90be880ef1ee4668a", size = 2554951 },
{ url = "https://files.pythonhosted.org/packages/b5/ca/184349ee40f2e92439be9b3502ae6cfc43ac4b50bc4fc6b3de7957563894/pillow-10.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:e553cad5179a66ba15bb18b353a19020e73a7921296a7979c4a2b7f6a5cd57f9", size = 2243427 },
{ url = "https://files.pythonhosted.org/packages/c3/00/706cebe7c2c12a6318aabe5d354836f54adff7156fd9e1bd6c89f4ba0e98/pillow-10.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8bc1a764ed8c957a2e9cacf97c8b2b053b70307cf2996aafd70e91a082e70df3", size = 3525685 },
{ url = "https://files.pythonhosted.org/packages/cf/76/f658cbfa49405e5ecbfb9ba42d07074ad9792031267e782d409fd8fe7c69/pillow-10.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6209bb41dc692ddfee4942517c19ee81b86c864b626dbfca272ec0f7cff5d9fb", size = 3374883 },
{ url = "https://files.pythonhosted.org/packages/46/2b/99c28c4379a85e65378211971c0b430d9c7234b1ec4d59b2668f6299e011/pillow-10.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bee197b30783295d2eb680b311af15a20a8b24024a19c3a26431ff83eb8d1f70", size = 4339837 },
{ url = "https://files.pythonhosted.org/packages/f1/74/b1ec314f624c0c43711fdf0d8076f82d9d802afd58f1d62c2a86878e8615/pillow-10.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ef61f5dd14c300786318482456481463b9d6b91ebe5ef12f405afbba77ed0be", size = 4455562 },
{ url = "https://files.pythonhosted.org/packages/4a/2a/4b04157cb7b9c74372fa867096a1607e6fedad93a44deeff553ccd307868/pillow-10.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:297e388da6e248c98bc4a02e018966af0c5f92dfacf5a5ca22fa01cb3179bca0", size = 4366761 },
{ url = "https://files.pythonhosted.org/packages/ac/7b/8f1d815c1a6a268fe90481232c98dd0e5fa8c75e341a75f060037bd5ceae/pillow-10.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e4db64794ccdf6cb83a59d73405f63adbe2a1887012e308828596100a0b2f6cc", size = 4536767 },
{ url = "https://files.pythonhosted.org/packages/e5/77/05fa64d1f45d12c22c314e7b97398ffb28ef2813a485465017b7978b3ce7/pillow-10.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd2880a07482090a3bcb01f4265f1936a903d70bc740bfcb1fd4e8a2ffe5cf5a", size = 4477989 },
{ url = "https://files.pythonhosted.org/packages/12/63/b0397cfc2caae05c3fb2f4ed1b4fc4fc878f0243510a7a6034ca59726494/pillow-10.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b35b21b819ac1dbd1233317adeecd63495f6babf21b7b2512d244ff6c6ce309", size = 4610255 },
{ url = "https://files.pythonhosted.org/packages/7b/f9/cfaa5082ca9bc4a6de66ffe1c12c2d90bf09c309a5f52b27759a596900e7/pillow-10.4.0-cp313-cp313-win32.whl", hash = "sha256:551d3fd6e9dc15e4c1eb6fc4ba2b39c0c7933fa113b220057a34f4bb3268a060", size = 2235603 },
{ url = "https://files.pythonhosted.org/packages/01/6a/30ff0eef6e0c0e71e55ded56a38d4859bf9d3634a94a88743897b5f96936/pillow-10.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:030abdbe43ee02e0de642aee345efa443740aa4d828bfe8e2eb11922ea6a21ea", size = 2554972 },
{ url = "https://files.pythonhosted.org/packages/48/2c/2e0a52890f269435eee38b21c8218e102c621fe8d8df8b9dd06fabf879ba/pillow-10.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:5b001114dd152cfd6b23befeb28d7aee43553e2402c9f159807bf55f33af8a8d", size = 2243375 },
{ url = "https://files.pythonhosted.org/packages/38/30/095d4f55f3a053392f75e2eae45eba3228452783bab3d9a920b951ac495c/pillow-10.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5b4815f2e65b30f5fbae9dfffa8636d992d49705723fe86a3661806e069352d4", size = 3493889 },
{ url = "https://files.pythonhosted.org/packages/f3/e8/4ff79788803a5fcd5dc35efdc9386af153569853767bff74540725b45863/pillow-10.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:8f0aef4ef59694b12cadee839e2ba6afeab89c0f39a3adc02ed51d109117b8da", size = 3346160 },
{ url = "https://files.pythonhosted.org/packages/d7/ac/4184edd511b14f760c73f5bb8a5d6fd85c591c8aff7c2229677a355c4179/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f4727572e2918acaa9077c919cbbeb73bd2b3ebcfe033b72f858fc9fbef0026", size = 3435020 },
{ url = "https://files.pythonhosted.org/packages/da/21/1749cd09160149c0a246a81d646e05f35041619ce76f6493d6a96e8d1103/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff25afb18123cea58a591ea0244b92eb1e61a1fd497bf6d6384f09bc3262ec3e", size = 3490539 },
{ url = "https://files.pythonhosted.org/packages/b6/f5/f71fe1888b96083b3f6dfa0709101f61fc9e972c0c8d04e9d93ccef2a045/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:dc3e2db6ba09ffd7d02ae9141cfa0ae23393ee7687248d46a7507b75d610f4f5", size = 3476125 },
{ url = "https://files.pythonhosted.org/packages/96/b9/c0362c54290a31866c3526848583a2f45a535aa9d725fd31e25d318c805f/pillow-10.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02a2be69f9c9b8c1e97cf2713e789d4e398c751ecfd9967c18d0ce304efbf885", size = 3579373 },
{ url = "https://files.pythonhosted.org/packages/52/3b/ce7a01026a7cf46e5452afa86f97a5e88ca97f562cafa76570178ab56d8d/pillow-10.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0755ffd4a0c6f267cccbae2e9903d95477ca2f77c4fcf3a3a09570001856c8a5", size = 2554661 },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@ -975,6 +1061,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
]
[[package]]
name = "proto-plus"
version = "1.26.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f4/ac/87285f15f7cce6d4a008f33f1757fb5a13611ea8914eb58c3d0d26243468/proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012", size = 56142 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66", size = 50163 },
]
[[package]]
name = "protobuf"
version = "6.31.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/f3/b9655a711b32c19720253f6f06326faf90580834e2e83f840472d752bc8b/protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a", size = 441797 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/6f/6ab8e4bf962fd5570d3deaa2d5c38f0a363f57b4501047b5ebeb83ab1125/protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9", size = 423603 },
{ url = "https://files.pythonhosted.org/packages/44/3a/b15c4347dd4bf3a1b0ee882f384623e2063bb5cf9fa9d57990a4f7df2fb6/protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447", size = 435283 },
{ url = "https://files.pythonhosted.org/packages/6a/c9/b9689a2a250264a84e66c46d8862ba788ee7a641cdca39bccf64f59284b7/protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402", size = 425604 },
{ url = "https://files.pythonhosted.org/packages/76/a1/7a5a94032c83375e4fe7e7f56e3976ea6ac90c5e85fac8576409e25c39c3/protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39", size = 322115 },
{ url = "https://files.pythonhosted.org/packages/fa/b1/b59d405d64d31999244643d88c45c8241c58f17cc887e73bcb90602327f8/protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6", size = 321070 },
{ url = "https://files.pythonhosted.org/packages/f7/af/ab3c51ab7507a7325e98ffe691d9495ee3d3aa5f589afad65ec920d39821/protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e", size = 168724 },
]
[[package]]
name = "psycopg"
version = "3.2.9"
@ -1042,17 +1154,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/cd/9b5583936515d085a1bec32b45289ceb53b80d9ce1cea0fef4c782dc41a7/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:778588ca9897b6c6bab39b0d3034efff4c5438f5e3bd52fda3914175498202f9", size = 3411439 },
{ url = "https://files.pythonhosted.org/packages/45/6b/6f1164ea1634c87956cdb6db759e0b8c5827f989ee3cdff0f5c70e8331f2/psycopg_binary-3.2.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f0d5b3af045a187aedbd7ed5fc513bd933a97aaff78e61c3745b330792c4345b", size = 3477477 },
{ url = "https://files.pythonhosted.org/packages/7b/1d/bf54cfec79377929da600c16114f0da77a5f1670f45e0c3af9fcd36879bc/psycopg_binary-3.2.9-cp313-cp313-win_amd64.whl", hash = "sha256:2290bc146a1b6a9730350f695e8b670e1d1feb8446597bed0bbe7c3c30e0abcb", size = 2928009 },
{ url = "https://files.pythonhosted.org/packages/0b/4a/e095884dd016b2bde2796043c61cd383b79e5d2a820c33e2c47293707ca8/psycopg_binary-3.2.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587a3f19954d687a14e0c8202628844db692dbf00bba0e6d006659bf1ca91cbe", size = 4034274 },
{ url = "https://files.pythonhosted.org/packages/11/e9/ab3fad6033de260a620f6481e66092417ce31fa194dbf9ac292ab8cb9fd0/psycopg_binary-3.2.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:791759138380df21d356ff991265fde7fe5997b0c924a502847a9f9141e68786", size = 4083015 },
{ url = "https://files.pythonhosted.org/packages/ba/c8/6cd54a349d0b62b080761eb7bda43190003ecbbf17920d57254d5c780e11/psycopg_binary-3.2.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95315b8c8ddfa2fdcb7fe3ddea8a595c1364524f512160c604e3be368be9dd07", size = 4679369 },
{ url = "https://files.pythonhosted.org/packages/51/34/35c65ac413c485e9340d62f14adcb34420acae44425f77aee591d49e6647/psycopg_binary-3.2.9-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18ac08475c9b971237fcc395b0a6ee4e8580bb5cf6247bc9b8461644bef5d9f4", size = 4500889 },
{ url = "https://files.pythonhosted.org/packages/77/a9/f691b8037b0bcef481b09ae4283beedbf048f79b6fe9bda1445dbb14ed18/psycopg_binary-3.2.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac2c04b6345e215e65ca6aef5c05cc689a960b16674eaa1f90a8f86dfaee8c04", size = 4769218 },
{ url = "https://files.pythonhosted.org/packages/ee/38/25afc811c1dfb664b31d66d6f5c070326a1f89f768f1b673273a3abe6912/psycopg_binary-3.2.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1ab25e3134774f1e476d4bb9050cdec25f10802e63e92153906ae934578734", size = 4462834 },
{ url = "https://files.pythonhosted.org/packages/df/e2/eb4a8230e13f691d6e386e22b16d4b90f454839b78ac547be3f399562ee4/psycopg_binary-3.2.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4bfec4a73e8447d8fe8854886ffa78df2b1c279a7592241c2eb393d4499a17e2", size = 3779527 },
{ url = "https://files.pythonhosted.org/packages/26/39/0f79c7d42f0c5711861ce9db55c65e14e7f1e52bd40304b4d6e7cd505e61/psycopg_binary-3.2.9-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:166acc57af5d2ff0c0c342aed02e69a0cd5ff216cae8820c1059a6f3b7cf5f78", size = 3337958 },
{ url = "https://files.pythonhosted.org/packages/11/ce/28b1d98aed9337a721b271778d07c5ac7f85730d96f0185cc6d22684536d/psycopg_binary-3.2.9-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:413f9e46259fe26d99461af8e1a2b4795a4e27cc8ac6f7919ec19bcee8945074", size = 3440567 },
{ url = "https://files.pythonhosted.org/packages/24/54/40a3a8175566f8c1268af0bacf5d7b26371697b6cefa87352c1df4b435e1/psycopg_binary-3.2.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:354dea21137a316b6868ee41c2ae7cce001e104760cf4eab3ec85627aed9b6cd", size = 3498637 },
{ url = "https://files.pythonhosted.org/packages/63/ee/51748bc8af0ba08e7415fcbbd00b7d069c068f8c08509e8dd0dd0a066394/psycopg_binary-3.2.9-cp39-cp39-win_amd64.whl", hash = "sha256:24ddb03c1ccfe12d000d950c9aba93a7297993c4e3905d9f2c9795bb0764d523", size = 2938614 },
]
[[package]]
@ -1182,19 +1283,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162 },
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560 },
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777 },
{ url = "https://files.pythonhosted.org/packages/53/ea/bbe9095cdd771987d13c82d104a9c8559ae9aec1e29f139e286fd2e9256e/pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d", size = 2028677 },
{ url = "https://files.pythonhosted.org/packages/49/1d/4ac5ed228078737d457a609013e8f7edc64adc37b91d619ea965758369e5/pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954", size = 1864735 },
{ url = "https://files.pythonhosted.org/packages/23/9a/2e70d6388d7cda488ae38f57bc2f7b03ee442fbcf0d75d848304ac7e405b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb", size = 1898467 },
{ url = "https://files.pythonhosted.org/packages/ff/2e/1568934feb43370c1ffb78a77f0baaa5a8b6897513e7a91051af707ffdc4/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7", size = 1983041 },
{ url = "https://files.pythonhosted.org/packages/01/1a/1a1118f38ab64eac2f6269eb8c120ab915be30e387bb561e3af904b12499/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4", size = 2136503 },
{ url = "https://files.pythonhosted.org/packages/5c/da/44754d1d7ae0f22d6d3ce6c6b1486fc07ac2c524ed8f6eca636e2e1ee49b/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b", size = 2736079 },
{ url = "https://files.pythonhosted.org/packages/4d/98/f43cd89172220ec5aa86654967b22d862146bc4d736b1350b4c41e7c9c03/pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3", size = 2006508 },
{ url = "https://files.pythonhosted.org/packages/2b/cc/f77e8e242171d2158309f830f7d5d07e0531b756106f36bc18712dc439df/pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a", size = 2113693 },
{ url = "https://files.pythonhosted.org/packages/54/7a/7be6a7bd43e0a47c147ba7fbf124fe8aaf1200bc587da925509641113b2d/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782", size = 2074224 },
{ url = "https://files.pythonhosted.org/packages/2a/07/31cf8fadffbb03be1cb520850e00a8490c0927ec456e8293cafda0726184/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9", size = 2245403 },
{ url = "https://files.pythonhosted.org/packages/b6/8d/bbaf4c6721b668d44f01861f297eb01c9b35f612f6b8e14173cb204e6240/pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e", size = 2242331 },
{ url = "https://files.pythonhosted.org/packages/bb/93/3cc157026bca8f5006250e74515119fcaa6d6858aceee8f67ab6dc548c16/pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9", size = 1910571 },
{ url = "https://files.pythonhosted.org/packages/5b/90/7edc3b2a0d9f0dda8806c04e511a67b0b7a41d2187e2003673a996fb4310/pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3", size = 1956504 },
{ url = "https://files.pythonhosted.org/packages/30/68/373d55e58b7e83ce371691f6eaa7175e3a24b956c44628eb25d7da007917/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa", size = 2023982 },
{ url = "https://files.pythonhosted.org/packages/a4/16/145f54ac08c96a63d8ed6442f9dec17b2773d19920b627b18d4f10a061ea/pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29", size = 1858412 },
{ url = "https://files.pythonhosted.org/packages/41/b1/c6dc6c3e2de4516c0bb2c46f6a373b91b5660312342a0cf5826e38ad82fa/pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d", size = 1892749 },
@ -1213,38 +1301,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/e9/1f7efbe20d0b2b10f6718944b5d8ece9152390904f29a78e68d4e7961159/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf", size = 2239013 },
{ url = "https://files.pythonhosted.org/packages/3c/b2/5309c905a93811524a49b4e031e9851a6b00ff0fb668794472ea7746b448/pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb", size = 2238715 },
{ url = "https://files.pythonhosted.org/packages/32/56/8a7ca5d2cd2cda1d245d34b1c9a942920a718082ae8e54e5f3e5a58b7add/pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1", size = 2066757 },
{ url = "https://files.pythonhosted.org/packages/08/98/dbf3fdfabaf81cda5622154fda78ea9965ac467e3239078e0dcd6df159e7/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101", size = 2024034 },
{ url = "https://files.pythonhosted.org/packages/8d/99/7810aa9256e7f2ccd492590f86b79d370df1e9292f1f80b000b6a75bd2fb/pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64", size = 1858578 },
{ url = "https://files.pythonhosted.org/packages/d8/60/bc06fa9027c7006cc6dd21e48dbf39076dc39d9abbaf718a1604973a9670/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d", size = 1892858 },
{ url = "https://files.pythonhosted.org/packages/f2/40/9d03997d9518816c68b4dfccb88969756b9146031b61cd37f781c74c9b6a/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535", size = 2068498 },
{ url = "https://files.pythonhosted.org/packages/d8/62/d490198d05d2d86672dc269f52579cad7261ced64c2df213d5c16e0aecb1/pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d", size = 2108428 },
{ url = "https://files.pythonhosted.org/packages/9a/ec/4cd215534fd10b8549015f12ea650a1a973da20ce46430b68fc3185573e8/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6", size = 2069854 },
{ url = "https://files.pythonhosted.org/packages/1a/1a/abbd63d47e1d9b0d632fee6bb15785d0889c8a6e0a6c3b5a8e28ac1ec5d2/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca", size = 2237859 },
{ url = "https://files.pythonhosted.org/packages/80/1c/fa883643429908b1c90598fd2642af8839efd1d835b65af1f75fba4d94fe/pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039", size = 2239059 },
{ url = "https://files.pythonhosted.org/packages/d4/29/3cade8a924a61f60ccfa10842f75eb12787e1440e2b8660ceffeb26685e7/pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27", size = 2066661 },
]
[[package]]
name = "pydantic-settings"
version = "2.9.1"
version = "2.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234 }
sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356 },
{ url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235 },
]
[[package]]
name = "pygments"
version = "2.19.1"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
]
[[package]]
@ -1280,7 +1359,6 @@ version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d0/d4/14f53324cb1a6381bef29d698987625d80052bb33932d8e7cbf9b337b17c/pytest_asyncio-1.0.0.tar.gz", hash = "sha256:d15463d13f4456e1ead2594520216b225a16f781e144f8fdf6c5bb4667c48b3f", size = 46960 }
wheels = [
@ -1289,11 +1367,11 @@ wheels = [
[[package]]
name = "python-dotenv"
version = "1.1.0"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 }
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 },
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556 },
]
[[package]]
@ -1347,15 +1425,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 },
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 },
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
{ url = "https://files.pythonhosted.org/packages/65/d8/b7a1db13636d7fb7d4ff431593c510c8b8fca920ade06ca8ef20015493c5/PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d", size = 184777 },
{ url = "https://files.pythonhosted.org/packages/0a/02/6ec546cd45143fdf9840b2c6be8d875116a64076218b61d68e12548e5839/PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f", size = 172318 },
{ url = "https://files.pythonhosted.org/packages/0e/9a/8cc68be846c972bda34f6c2a93abb644fb2476f4dcc924d52175786932c9/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290", size = 720891 },
{ url = "https://files.pythonhosted.org/packages/e9/6c/6e1b7f40181bc4805e2e07f4abc10a88ce4648e7e95ff1abe4ae4014a9b2/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12", size = 722614 },
{ url = "https://files.pythonhosted.org/packages/3d/32/e7bd8535d22ea2874cef6a81021ba019474ace0d13a4819c2a4bce79bd6a/PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19", size = 737360 },
{ url = "https://files.pythonhosted.org/packages/d7/12/7322c1e30b9be969670b672573d45479edef72c9a0deac3bb2868f5d7469/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e", size = 699006 },
{ url = "https://files.pythonhosted.org/packages/82/72/04fcad41ca56491995076630c3ec1e834be241664c0c09a64c9a2589b507/PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725", size = 723577 },
{ url = "https://files.pythonhosted.org/packages/ed/5e/46168b1f2757f1fcd442bc3029cd8767d88a98c9c05770d8b420948743bb/PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631", size = 144593 },
{ url = "https://files.pythonhosted.org/packages/19/87/5124b1c1f2412bb95c59ec481eaf936cd32f0fe2a7b16b97b81c4c017a6a/PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8", size = 162312 },
]
[[package]]
@ -1425,22 +1494,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/e3/c446a64984ea9f69982ba1a69d4658d5014bc7a0ea468a07e1a1265db6e2/regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d", size = 787733 },
{ url = "https://files.pythonhosted.org/packages/2b/f1/e40c8373e3480e4f29f2692bd21b3e05f296d3afebc7e5dcf21b9756ca1c/regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff", size = 262122 },
{ url = "https://files.pythonhosted.org/packages/45/94/bc295babb3062a731f52621cdc992d123111282e291abaf23faa413443ea/regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a", size = 273545 },
{ url = "https://files.pythonhosted.org/packages/89/23/c4a86df398e57e26f93b13ae63acce58771e04bdde86092502496fa57f9c/regex-2024.11.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5704e174f8ccab2026bd2f1ab6c510345ae8eac818b613d7d73e785f1310f839", size = 482682 },
{ url = "https://files.pythonhosted.org/packages/3c/8b/45c24ab7a51a1658441b961b86209c43e6bb9d39caf1e63f46ce6ea03bc7/regex-2024.11.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:220902c3c5cc6af55d4fe19ead504de80eb91f786dc102fbd74894b1551f095e", size = 287679 },
{ url = "https://files.pythonhosted.org/packages/7a/d1/598de10b17fdafc452d11f7dada11c3be4e379a8671393e4e3da3c4070df/regex-2024.11.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e7e351589da0850c125f1600a4c4ba3c722efefe16b297de54300f08d734fbf", size = 284578 },
{ url = "https://files.pythonhosted.org/packages/49/70/c7eaa219efa67a215846766fde18d92d54cb590b6a04ffe43cef30057622/regex-2024.11.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5056b185ca113c88e18223183aa1a50e66507769c9640a6ff75859619d73957b", size = 782012 },
{ url = "https://files.pythonhosted.org/packages/89/e5/ef52c7eb117dd20ff1697968219971d052138965a4d3d9b95e92e549f505/regex-2024.11.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e34b51b650b23ed3354b5a07aab37034d9f923db2a40519139af34f485f77d0", size = 820580 },
{ url = "https://files.pythonhosted.org/packages/5f/3f/9f5da81aff1d4167ac52711acf789df13e789fe6ac9545552e49138e3282/regex-2024.11.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5670bce7b200273eee1840ef307bfa07cda90b38ae56e9a6ebcc9f50da9c469b", size = 809110 },
{ url = "https://files.pythonhosted.org/packages/86/44/2101cc0890c3621b90365c9ee8d7291a597c0722ad66eccd6ffa7f1bcc09/regex-2024.11.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08986dce1339bc932923e7d1232ce9881499a0e02925f7402fb7c982515419ef", size = 780919 },
{ url = "https://files.pythonhosted.org/packages/ce/2e/3e0668d8d1c7c3c0d397bf54d92fc182575b3a26939aed5000d3cc78760f/regex-2024.11.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93c0b12d3d3bc25af4ebbf38f9ee780a487e8bf6954c115b9f015822d3bb8e48", size = 771515 },
{ url = "https://files.pythonhosted.org/packages/a6/49/1bc4584254355e3dba930a3a2fd7ad26ccba3ebbab7d9100db0aff2eedb0/regex-2024.11.6-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:764e71f22ab3b305e7f4c21f1a97e1526a25ebdd22513e251cf376760213da13", size = 696957 },
{ url = "https://files.pythonhosted.org/packages/c8/dd/42879c1fc8a37a887cd08e358af3d3ba9e23038cd77c7fe044a86d9450ba/regex-2024.11.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f056bf21105c2515c32372bbc057f43eb02aae2fda61052e2f7622c801f0b4e2", size = 768088 },
{ url = "https://files.pythonhosted.org/packages/89/96/c05a0fe173cd2acd29d5e13c1adad8b706bcaa71b169e1ee57dcf2e74584/regex-2024.11.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69ab78f848845569401469da20df3e081e6b5a11cb086de3eed1d48f5ed57c95", size = 774752 },
{ url = "https://files.pythonhosted.org/packages/b5/f3/a757748066255f97f14506483436c5f6aded7af9e37bca04ec30c90ca683/regex-2024.11.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:86fddba590aad9208e2fa8b43b4c098bb0ec74f15718bb6a704e3c63e2cef3e9", size = 838862 },
{ url = "https://files.pythonhosted.org/packages/5c/93/c6d2092fd479dcaeea40fc8fa673822829181ded77d294a7f950f1dda6e2/regex-2024.11.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:684d7a212682996d21ca12ef3c17353c021fe9de6049e19ac8481ec35574a70f", size = 842622 },
{ url = "https://files.pythonhosted.org/packages/ff/9c/daa99532c72f25051a90ef90e1413a8d54413a9e64614d9095b0c1c154d0/regex-2024.11.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a03e02f48cd1abbd9f3b7e3586d97c8f7a9721c436f51a5245b3b9483044480b", size = 772713 },
{ url = "https://files.pythonhosted.org/packages/13/5d/61a533ccb8c231b474ac8e3a7d70155b00dfc61af6cafdccd1947df6d735/regex-2024.11.6-cp39-cp39-win32.whl", hash = "sha256:41758407fc32d5c3c5de163888068cfee69cb4c2be844e7ac517a52770f9af57", size = 261756 },
{ url = "https://files.pythonhosted.org/packages/dc/7b/e59b7f7c91ae110d154370c24133f947262525b5d6406df65f23422acc17/regex-2024.11.6-cp39-cp39-win_amd64.whl", hash = "sha256:b2837718570f95dd41675328e111345f9b7095d821bac435aac173ac80b19983", size = 274110 },
]
[[package]]
@ -1607,14 +1660,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511 },
{ url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420 },
{ url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329 },
{ url = "https://files.pythonhosted.org/packages/dd/1c/3d2a893c020fcc18463794e0a687de58044d1c8a9892d23548ca7e71274a/sqlalchemy-2.0.41-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9a420a91913092d1e20c86a2f5f1fc85c1a8924dbcaf5e0586df8aceb09c9cc2", size = 2121327 },
{ url = "https://files.pythonhosted.org/packages/3e/84/389c8f7c7b465682c4e5ba97f6e7825149a6625c629e09b5e872ec3b378f/sqlalchemy-2.0.41-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:906e6b0d7d452e9a98e5ab8507c0da791856b2380fdee61b765632bb8698026f", size = 2110739 },
{ url = "https://files.pythonhosted.org/packages/b2/3d/036e84ecb46d6687fa57dc25ab366dff50773a19364def210b8770fd1516/sqlalchemy-2.0.41-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a373a400f3e9bac95ba2a06372c4fd1412a7cee53c37fc6c05f829bf672b8769", size = 3198018 },
{ url = "https://files.pythonhosted.org/packages/8d/de/112e2142bf730a16a6cb43efc87e36dd62426e155727490c041130c6e852/sqlalchemy-2.0.41-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:087b6b52de812741c27231b5a3586384d60c353fbd0e2f81405a814b5591dc8b", size = 3197074 },
{ url = "https://files.pythonhosted.org/packages/d4/be/a766c78ec3050cb5b734c3087cd20bafd7370b0ab0c8636a87652631af1f/sqlalchemy-2.0.41-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:34ea30ab3ec98355235972dadc497bb659cc75f8292b760394824fab9cf39826", size = 3138698 },
{ url = "https://files.pythonhosted.org/packages/e5/c3/245e39ec45e1a8c86ff1ac3a88b13d0457307ac728eaeb217834a3ac6813/sqlalchemy-2.0.41-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8280856dd7c6a68ab3a164b4a4b1c51f7691f6d04af4d4ca23d6ecf2261b7923", size = 3160877 },
{ url = "https://files.pythonhosted.org/packages/d7/0c/cda8631405f6417208e160070b513bb752da0885e462fce42ac200c8262f/sqlalchemy-2.0.41-cp39-cp39-win32.whl", hash = "sha256:b50eab9994d64f4a823ff99a0ed28a6903224ddbe7fef56a6dd865eec9243440", size = 2089270 },
{ url = "https://files.pythonhosted.org/packages/b0/1f/f68c58970d80ea5a1868ca5dc965d154a3b711f9ab06376ad9840d1475b8/sqlalchemy-2.0.41-cp39-cp39-win_amd64.whl", hash = "sha256:5e22575d169529ac3e0a120cf050ec9daa94b6a9597993d1702884f6954a7d71", size = 2113134 },
{ url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224 },
]
@ -1636,7 +1681,6 @@ version = "0.46.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846 }
wheels = [
@ -1695,12 +1739,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/bb/4513da71cac187383541facd0291c4572b03ec23c561de5811781bbd988f/tiktoken-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc156cb314119a8bb9748257a2eaebd5cc0753b6cb491d26694ed42fc7cb3139", size = 1195649 },
{ url = "https://files.pythonhosted.org/packages/fa/5c/74e4c137530dd8504e97e3a41729b1103a4ac29036cbfd3250b11fd29451/tiktoken-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cd69372e8c9dd761f0ab873112aba55a0e3e506332dd9f7522ca466e817b1b7a", size = 1258465 },
{ url = "https://files.pythonhosted.org/packages/de/a8/8f499c179ec900783ffe133e9aab10044481679bb9aad78436d239eee716/tiktoken-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5ea0edb6f83dc56d794723286215918c1cde03712cbbafa0348b33448faf5b95", size = 894669 },
{ url = "https://files.pythonhosted.org/packages/c4/92/4d681b5c066d417b98f22a0176358d9e606e183c6b61c337d61fb54accb4/tiktoken-0.9.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c6386ca815e7d96ef5b4ac61e0048cd32ca5a92d5781255e13b31381d28667dc", size = 1066217 },
{ url = "https://files.pythonhosted.org/packages/12/dd/af27bbe186df481666de48cf0f2f4e0643ba9c78b472e7bf70144c663b22/tiktoken-0.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:75f6d5db5bc2c6274b674ceab1615c1778e6416b14705827d19b40e6355f03e0", size = 1009441 },
{ url = "https://files.pythonhosted.org/packages/33/35/2792b7dcb8b150d2767322637513c73a3e80833c19212efea80b31087894/tiktoken-0.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e15b16f61e6f4625a57a36496d28dd182a8a60ec20a534c5343ba3cafa156ac7", size = 1144423 },
{ url = "https://files.pythonhosted.org/packages/65/ae/4d1682510172ce3500bbed3b206ebc4efefe280f0bf1179cfb043f88cc16/tiktoken-0.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebcec91babf21297022882344c3f7d9eed855931466c3311b1ad6b64befb3df", size = 1199002 },
{ url = "https://files.pythonhosted.org/packages/1c/2e/df2dc31dd161190f315829775a9652ea01d60f307af8f98e35bdd14a6a93/tiktoken-0.9.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e5fd49e7799579240f03913447c0cdfa1129625ebd5ac440787afc4345990427", size = 1260610 },
{ url = "https://files.pythonhosted.org/packages/70/22/e8fc1bf9cdecc439b7ddc28a45b976a8c699a38874c070749d855696368a/tiktoken-0.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:26242ca9dc8b58e875ff4ca078b9a94d2f0813e6a535dcd2205df5d49d927cc7", size = 894215 },
]
[[package]]
@ -1863,12 +1901,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068 },
{ url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428 },
{ url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018 },
{ url = "https://files.pythonhosted.org/packages/3c/a4/646a9d0edff7cde25fc1734695d3dfcee0501140dd0e723e4df3f0a50acb/uvloop-0.21.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c097078b8031190c934ed0ebfee8cc5f9ba9642e6eb88322b9958b649750f72b", size = 1439646 },
{ url = "https://files.pythonhosted.org/packages/01/2e/e128c66106af9728f86ebfeeb52af27ecd3cb09336f3e2f3e06053707a15/uvloop-0.21.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:46923b0b5ee7fc0020bef24afe7836cb068f5050ca04caf6b487c513dc1a20b2", size = 800931 },
{ url = "https://files.pythonhosted.org/packages/2d/1a/9fbc2b1543d0df11f7aed1632f64bdf5ecc4053cf98cdc9edb91a65494f9/uvloop-0.21.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53e420a3afe22cdcf2a0f4846e377d16e718bc70103d7088a4f7623567ba5fb0", size = 3829660 },
{ url = "https://files.pythonhosted.org/packages/b8/c0/392e235e4100ae3b95b5c6dac77f82b529d2760942b1e7e0981e5d8e895d/uvloop-0.21.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88cb67cdbc0e483da00af0b2c3cdad4b7c61ceb1ee0f33fe00e09c81e3a6cb75", size = 3827185 },
{ url = "https://files.pythonhosted.org/packages/e1/24/a5da6aba58f99aed5255eca87d58d1760853e8302d390820cc29058408e3/uvloop-0.21.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:221f4f2a1f46032b403bf3be628011caf75428ee3cc204a22addf96f586b19fd", size = 3705833 },
{ url = "https://files.pythonhosted.org/packages/1a/5c/6ba221bb60f1e6474474102e17e38612ec7a06dc320e22b687ab563d877f/uvloop-0.21.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2d1f581393673ce119355d56da84fe1dd9d2bb8b3d13ce792524e1607139feff", size = 3804696 },
]
[[package]]
@ -1961,18 +1993,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/69/c4/088825b75489cb5b6a761a4542645718893d395d8c530b38734f19da44d2/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147", size = 452240 },
{ url = "https://files.pythonhosted.org/packages/10/8c/22b074814970eeef43b7c44df98c3e9667c1f7bf5b83e0ff0201b0bd43f9/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8", size = 625607 },
{ url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315 },
{ url = "https://files.pythonhosted.org/packages/47/8a/a45db804b9f0740f8408626ab2bca89c3136432e57c4673b50180bf85dd9/watchfiles-1.1.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:865c8e95713744cf5ae261f3067861e9da5f1370ba91fc536431e29b418676fa", size = 406400 },
{ url = "https://files.pythonhosted.org/packages/64/06/a08684f628fb41addd451845aceedc2407dc3d843b4b060a7c4350ddee0c/watchfiles-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42f92befc848bb7a19658f21f3e7bae80d7d005d13891c62c2cd4d4d0abb3433", size = 397920 },
{ url = "https://files.pythonhosted.org/packages/79/e6/e10d5675af653b1b07d4156906858041149ca222edaf8995877f2605ba9e/watchfiles-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0cc8365ab29487eb4f9979fd41b22549853389e22d5de3f134a6796e1b05a4", size = 451196 },
{ url = "https://files.pythonhosted.org/packages/f6/8a/facd6988100cd0f39e89f6c550af80edb28e3a529e1ee662e750663e6b36/watchfiles-1.1.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90ebb429e933645f3da534c89b29b665e285048973b4d2b6946526888c3eb2c7", size = 458218 },
{ url = "https://files.pythonhosted.org/packages/90/26/34cbcbc4d0f2f8f9cc243007e65d741ae039f7a11ef8ec6e9cd25bee08d1/watchfiles-1.1.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c588c45da9b08ab3da81d08d7987dae6d2a3badd63acdb3e206a42dbfa7cb76f", size = 484851 },
{ url = "https://files.pythonhosted.org/packages/d7/1f/f59faa9fc4b0e36dbcdd28a18c430416443b309d295d8b82e18192d120ad/watchfiles-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c55b0f9f68590115c25272b06e63f0824f03d4fc7d6deed43d8ad5660cabdbf", size = 599520 },
{ url = "https://files.pythonhosted.org/packages/83/72/3637abecb3bf590529f5154ca000924003e5f4bbb9619744feeaf6f0b70b/watchfiles-1.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd17a1e489f02ce9117b0de3c0b1fab1c3e2eedc82311b299ee6b6faf6c23a29", size = 477956 },
{ url = "https://files.pythonhosted.org/packages/f7/f3/d14ffd9acc0c1bd4790378995e320981423263a5d70bd3929e2e0dc87fff/watchfiles-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da71945c9ace018d8634822f16cbc2a78323ef6c876b1d34bbf5d5222fd6a72e", size = 453196 },
{ url = "https://files.pythonhosted.org/packages/7f/38/78ad77bd99e20c0fdc82262be571ef114fc0beef9b43db52adb939768c38/watchfiles-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:51556d5004887045dba3acdd1fdf61dddea2be0a7e18048b5e853dcd37149b86", size = 627479 },
{ url = "https://files.pythonhosted.org/packages/e6/cf/549d50a22fcc83f1017c6427b1c76c053233f91b526f4ad7a45971e70c0b/watchfiles-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04e4ed5d1cd3eae68c89bcc1a485a109f39f2fd8de05f705e98af6b5f1861f1f", size = 624414 },
{ url = "https://files.pythonhosted.org/packages/72/de/57d6e40dc9140af71c12f3a9fc2d3efc5529d93981cd4d265d484d7c9148/watchfiles-1.1.0-cp39-cp39-win32.whl", hash = "sha256:c600e85f2ffd9f1035222b1a312aff85fd11ea39baff1d705b9b047aad2ce267", size = 280020 },
{ url = "https://files.pythonhosted.org/packages/88/bb/7d287fc2a762396b128a0fca2dbae29386e0a242b81d1046daf389641db3/watchfiles-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:3aba215958d88182e8d2acba0fdaf687745180974946609119953c0e112397dc", size = 292758 },
{ url = "https://files.pythonhosted.org/packages/be/7c/a3d7c55cfa377c2f62c4ae3c6502b997186bc5e38156bafcb9b653de9a6d/watchfiles-1.1.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a6fd40bbb50d24976eb275ccb55cd1951dfb63dbc27cae3066a6ca5f4beabd5", size = 406748 },
{ url = "https://files.pythonhosted.org/packages/38/d0/c46f1b2c0ca47f3667b144de6f0515f6d1c670d72f2ca29861cac78abaa1/watchfiles-1.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9f811079d2f9795b5d48b55a37aa7773680a5659afe34b54cc1d86590a51507d", size = 398801 },
{ url = "https://files.pythonhosted.org/packages/70/9c/9a6a42e97f92eeed77c3485a43ea96723900aefa3ac739a8c73f4bff2cd7/watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2726d7bfd9f76158c84c10a409b77a320426540df8c35be172444394b17f7ea", size = 451528 },
@ -1981,10 +2001,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/d3/71c2dcf81dc1edcf8af9f4d8d63b1316fb0a2dd90cbfd427e8d9dd584a90/watchfiles-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c", size = 398816 },
{ url = "https://files.pythonhosted.org/packages/b8/fa/12269467b2fc006f8fce4cd6c3acfa77491dd0777d2a747415f28ccc8c60/watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432", size = 451584 },
{ url = "https://files.pythonhosted.org/packages/bd/d3/254cea30f918f489db09d6a8435a7de7047f8cb68584477a515f160541d6/watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792", size = 454009 },
{ url = "https://files.pythonhosted.org/packages/48/93/5c96bdb65e7f88f7da40645f34c0a3c317a2931ed82161e93c91e8eddd27/watchfiles-1.1.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7b3443f4ec3ba5aa00b0e9fa90cf31d98321cbff8b925a7c7b84161619870bc9", size = 406640 },
{ url = "https://files.pythonhosted.org/packages/e3/25/09204836e93e1b99cce88802ce87264a1d20610c7a8f6de24def27ad95b1/watchfiles-1.1.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7049e52167fc75fc3cc418fc13d39a8e520cbb60ca08b47f6cedb85e181d2f2a", size = 398543 },
{ url = "https://files.pythonhosted.org/packages/5e/dc/6f324a6f32c5ab73b54311b5f393a79df34c1584b8d2404cf7e6d780aa5d/watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54062ef956807ba806559b3c3d52105ae1827a0d4ab47b621b31132b6b7e2866", size = 451787 },
{ url = "https://files.pythonhosted.org/packages/45/5d/1d02ef4caa4ec02389e72d5594cdf9c67f1800a7c380baa55063c30c6598/watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a7bd57a1bb02f9d5c398c0c1675384e7ab1dd39da0ca50b7f09af45fa435277", size = 454272 },
]
[[package]]
@ -2037,29 +2053,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195 },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393 },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837 },
{ url = "https://files.pythonhosted.org/packages/36/db/3fff0bcbe339a6fa6a3b9e3fbc2bfb321ec2f4cd233692272c5a8d6cf801/websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5", size = 175424 },
{ url = "https://files.pythonhosted.org/packages/46/e6/519054c2f477def4165b0ec060ad664ed174e140b0d1cbb9fafa4a54f6db/websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a", size = 173077 },
{ url = "https://files.pythonhosted.org/packages/1a/21/c0712e382df64c93a0d16449ecbf87b647163485ca1cc3f6cbadb36d2b03/websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b", size = 173324 },
{ url = "https://files.pythonhosted.org/packages/1c/cb/51ba82e59b3a664df54beed8ad95517c1b4dc1a913730e7a7db778f21291/websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770", size = 182094 },
{ url = "https://files.pythonhosted.org/packages/fb/0f/bf3788c03fec679bcdaef787518dbe60d12fe5615a544a6d4cf82f045193/websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb", size = 181094 },
{ url = "https://files.pythonhosted.org/packages/5e/da/9fb8c21edbc719b66763a571afbaf206cb6d3736d28255a46fc2fe20f902/websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054", size = 181397 },
{ url = "https://files.pythonhosted.org/packages/2e/65/65f379525a2719e91d9d90c38fe8b8bc62bd3c702ac651b7278609b696c4/websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee", size = 181794 },
{ url = "https://files.pythonhosted.org/packages/d9/26/31ac2d08f8e9304d81a1a7ed2851c0300f636019a57cbaa91342015c72cc/websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed", size = 181194 },
{ url = "https://files.pythonhosted.org/packages/98/72/1090de20d6c91994cd4b357c3f75a4f25ee231b63e03adea89671cc12a3f/websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880", size = 181164 },
{ url = "https://files.pythonhosted.org/packages/2d/37/098f2e1c103ae8ed79b0e77f08d83b0ec0b241cf4b7f2f10edd0126472e1/websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411", size = 176381 },
{ url = "https://files.pythonhosted.org/packages/75/8b/a32978a3ab42cebb2ebdd5b05df0696a09f4d436ce69def11893afa301f0/websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4", size = 176841 },
{ url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109 },
{ url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343 },
{ url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599 },
{ url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207 },
{ url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155 },
{ url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884 },
{ url = "https://files.pythonhosted.org/packages/b7/48/4b67623bac4d79beb3a6bb27b803ba75c1bdedc06bd827e465803690a4b2/websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940", size = 173106 },
{ url = "https://files.pythonhosted.org/packages/ed/f0/adb07514a49fe5728192764e04295be78859e4a537ab8fcc518a3dbb3281/websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e", size = 173339 },
{ url = "https://files.pythonhosted.org/packages/87/28/bd23c6344b18fb43df40d0700f6d3fffcd7cef14a6995b4f976978b52e62/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9", size = 174597 },
{ url = "https://files.pythonhosted.org/packages/6d/79/ca288495863d0f23a60f546f0905ae8f3ed467ad87f8b6aceb65f4c013e4/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b", size = 174205 },
{ url = "https://files.pythonhosted.org/packages/04/e4/120ff3180b0872b1fe6637f6f995bcb009fb5c87d597c1fc21456f50c848/websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f", size = 174150 },
{ url = "https://files.pythonhosted.org/packages/cb/c3/30e2f9c539b8da8b1d76f64012f3b19253271a63413b2d3adb94b143407f/websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123", size = 176877 },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 },
]
@ -2124,16 +2123,5 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/b1/0bb11e29aa5139d90b770ebbfa167267b1fc548d2302c30c8f7572851738/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", size = 106377 },
{ url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986 },
{ url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750 },
{ url = "https://files.pythonhosted.org/packages/8a/f4/6ed2b8f6f1c832933283974839b88ec7c983fd12905e01e97889dadf7559/wrapt-1.17.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99039fa9e6306880572915728d7f6c24a86ec57b0a83f6b2491e1d8ab0235b9a", size = 53308 },
{ url = "https://files.pythonhosted.org/packages/a2/a9/712a53f8f4f4545768ac532619f6e56d5d0364a87b2212531685e89aeef8/wrapt-1.17.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2696993ee1eebd20b8e4ee4356483c4cb696066ddc24bd70bcbb80fa56ff9061", size = 38489 },
{ url = "https://files.pythonhosted.org/packages/fa/9b/e172c8f28a489a2888df18f953e2f6cb8d33b1a2e78c9dfc52d8bf6a5ead/wrapt-1.17.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:612dff5db80beef9e649c6d803a8d50c409082f1fedc9dbcdfde2983b2025b82", size = 38776 },
{ url = "https://files.pythonhosted.org/packages/cf/cb/7a07b51762dcd59bdbe07aa97f87b3169766cadf240f48d1cbe70a1be9db/wrapt-1.17.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62c2caa1585c82b3f7a7ab56afef7b3602021d6da34fbc1cf234ff139fed3cd9", size = 83050 },
{ url = "https://files.pythonhosted.org/packages/a5/51/a42757dd41032afd6d8037617aa3bc6803ba971850733b24dfb7d5c627c4/wrapt-1.17.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c958bcfd59bacc2d0249dcfe575e71da54f9dcf4a8bdf89c4cb9a68a1170d73f", size = 74718 },
{ url = "https://files.pythonhosted.org/packages/bf/bb/d552bfe47db02fcfc950fc563073a33500f8108efa5f7b41db2f83a59028/wrapt-1.17.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc78a84e2dfbc27afe4b2bd7c80c8db9bca75cc5b85df52bfe634596a1da846b", size = 82590 },
{ url = "https://files.pythonhosted.org/packages/77/99/77b06b3c3c410dbae411105bf22496facf03a5496bfaca8fbcf9da381889/wrapt-1.17.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ba0f0eb61ef00ea10e00eb53a9129501f52385c44853dbd6c4ad3f403603083f", size = 81462 },
{ url = "https://files.pythonhosted.org/packages/2d/21/cf0bd85ae66f92600829ea1de8e1da778e5e9f6e574ccbe74b66db0d95db/wrapt-1.17.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1e1fe0e6ab7775fd842bc39e86f6dcfc4507ab0ffe206093e76d61cde37225c8", size = 74309 },
{ url = "https://files.pythonhosted.org/packages/6d/16/112d25e9092398a0dd6fec50ab7ac1b775a0c19b428f049785096067ada9/wrapt-1.17.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c86563182421896d73858e08e1db93afdd2b947a70064b813d515d66549e15f9", size = 81081 },
{ url = "https://files.pythonhosted.org/packages/2b/49/364a615a0cc0872685646c495c7172e4fc7bf1959e3b12a1807a03014e05/wrapt-1.17.2-cp39-cp39-win32.whl", hash = "sha256:f393cda562f79828f38a819f4788641ac7c4085f30f1ce1a68672baa686482bb", size = 36423 },
{ url = "https://files.pythonhosted.org/packages/00/ad/5d2c1b34ba3202cd833d9221833e74d6500ce66730974993a8dc9a94fb8c/wrapt-1.17.2-cp39-cp39-win_amd64.whl", hash = "sha256:36ccae62f64235cf8ddb682073a60519426fdd4725524ae38874adf72b5f2aeb", size = 38772 },
{ url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594 },
]