diff --git a/pyproject.toml b/pyproject.toml index 8520ef6d..a752b09d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "fastapi[standard]>=0.111.0", + "groq>=0.31.0", "python-dotenv>=1.0.0", "sqlalchemy>=2.0.30", "fastapi-pagination>=0.12.24", @@ -21,12 +22,13 @@ dependencies = [ "nanoid>=2.0.0", "alembic>=1.14.0", "pyjwt>=2.10.0", + "tenacity>=9.1.2", "tiktoken>=0.9.0", - "mirascope[anthropic,google,groq,langfuse]>=1.25.5", + "langfuse>=3.3.2", "openai>=1.99.7", "pydantic>=2.11.7", "pydantic-settings>=2.10.1", - "google-generativeai>=0.8.5", + "google-genai>=1.32.0", "pdfplumber>=0.11.7", "typing-extensions>=4.11.0", ] @@ -93,6 +95,6 @@ reportUnusedCallResult = false reportCallInDefaultInitializer = false reportAny = false reportExplicitAny = false -allowedUntypedLibraries = ["langfuse", "langfuse.decorators", "mirascope"] +allowedUntypedLibraries = ["langfuse"] reportImplicitOverride = false reportImportCycles = false diff --git a/src/config.py b/src/config.py index 37cca0ed..7d91d19c 100644 --- a/src/config.py +++ b/src/config.py @@ -14,7 +14,7 @@ from pydantic_settings import ( SettingsConfigDict, ) -from src.utils.types import Providers +from src.utils.types import SupportedProviders # Load .env file for local development. # Make sure this is called before AppSettings is instantiated if you rely on .env for AppSettings construction. @@ -187,14 +187,14 @@ class DeriverSettings(HonchoSettings): ] = 1.0 STALE_SESSION_TIMEOUT_MINUTES: Annotated[int, Field(default=5, gt=0, le=1440)] = 5 - PROVIDER: Providers = "google" + PROVIDER: SupportedProviders = "google" MODEL: str = "gemini-2.5-flash" MAX_OUTPUT_TOKENS: Annotated[int, Field(default=2500, gt=0, le=100_000)] = 2500 # Thinking budget tokens are only applied when using Anthropic as provider THINKING_BUDGET_TOKENS: Annotated[int, Field(default=1024, gt=0, le=5000)] = 1024 - PEER_CARD_PROVIDER: Providers = "openai" + PEER_CARD_PROVIDER: SupportedProviders = "openai" PEER_CARD_MODEL: str = "gpt-5-nano-2025-08-07" # Note: peer cards should be very short, but GPT-5 models need output tokens for thinking which cannot be turned off... PEER_CARD_MAX_OUTPUT_TOKENS: Annotated[ @@ -216,11 +216,11 @@ class DeriverSettings(HonchoSettings): class DialecticSettings(HonchoSettings): model_config = SettingsConfigDict(env_prefix="DIALECTIC_", extra="ignore") # pyright: ignore - PROVIDER: Providers = "anthropic" + PROVIDER: SupportedProviders = "anthropic" MODEL: str = "claude-sonnet-4-20250514" PERFORM_QUERY_GENERATION: bool = False - QUERY_GENERATION_PROVIDER: Providers = "groq" + QUERY_GENERATION_PROVIDER: SupportedProviders = "groq" QUERY_GENERATION_MODEL: str = "llama-3.1-8b-instant" MAX_OUTPUT_TOKENS: Annotated[int, Field(default=2500, gt=0, le=100_000)] = 2500 @@ -243,7 +243,7 @@ class SummarySettings(HonchoSettings): 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 - PROVIDER: Providers = "openai" + PROVIDER: SupportedProviders = "openai" MODEL: str = "gpt-4o-mini-2024-07-18" MAX_TOKENS_SHORT: Annotated[int, Field(default=1000, gt=0, le=10_000)] = 1000 MAX_TOKENS_LONG: Annotated[int, Field(default=4000, gt=0, le=20_000)] = 4000 diff --git a/src/crud/representation.py b/src/crud/representation.py index dec8c923..787dcbdd 100644 --- a/src/crud/representation.py +++ b/src/crud/representation.py @@ -1,5 +1,5 @@ from logging import getLogger -from typing import Any, cast +from typing import Any, Final, cast from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -12,7 +12,7 @@ from src.utils.shared_models import ObservationDict logger = getLogger(__name__) # The collection name for documents that make up a peer's global representation -GLOBAL_REPRESENTATION_COLLECTION_NAME = "global_representation" +GLOBAL_REPRESENTATION_COLLECTION_NAME: Final[str] = "global_representation" # The key for the working representation in the session peer's internal_metadata WORKING_REPRESENTATION_METADATA_KEY = "working_representation" diff --git a/src/deriver/consumer.py b/src/deriver/consumer.py index 4420aaa5..015763c6 100644 --- a/src/deriver/consumer.py +++ b/src/deriver/consumer.py @@ -2,7 +2,7 @@ import logging from typing import Any import sentry_sdk -from langfuse.decorators import langfuse_context +from langfuse import get_client from pydantic import ValidationError from rich.console import Console @@ -24,6 +24,8 @@ logging.getLogger("sqlalchemy.engine.Engine").disabled = True console = Console(markup=True) +lf = get_client() + async def process_item(task_type: str, payload: dict[str, Any]) -> None: """Validate an incoming queue payload and dispatch it to the appropriate handler. @@ -46,7 +48,7 @@ async def process_item(task_type: str, payload: dict[str, Any]) -> None: logger.debug("Finished processing webhook %s", validated.event_type) elif task_type == "summary": if settings.LANGFUSE_PUBLIC_KEY: - langfuse_context.update_current_trace( # type: ignore + lf.update_current_trace( # type: ignore metadata={ "critical_analysis_model": settings.DERIVER.MODEL, } @@ -61,7 +63,7 @@ async def process_item(task_type: str, payload: dict[str, Any]) -> None: await process_summary_task(validated) elif task_type == "representation": if settings.LANGFUSE_PUBLIC_KEY: - langfuse_context.update_current_trace( + lf.update_current_trace( metadata={ "critical_analysis_model": settings.DERIVER.MODEL, } diff --git a/src/deriver/deriver.py b/src/deriver/deriver.py index 8c35da21..ec7fb48f 100644 --- a/src/deriver/deriver.py +++ b/src/deriver/deriver.py @@ -5,7 +5,7 @@ import time from typing import Any import sentry_sdk -from langfuse.decorators import langfuse_context +from langfuse import get_client from src import crud, exceptions from src.config import settings @@ -48,20 +48,9 @@ from .queue_payload import ( logger = logging.getLogger(__name__) logging.getLogger("sqlalchemy.engine.Engine").disabled = True +lf = get_client() + -@honcho_llm_call( - provider=settings.DERIVER.PROVIDER, - model=settings.DERIVER.MODEL, - track_name="Critical Analysis Call", - response_model=ReasoningResponse, - json_mode=True, - max_tokens=settings.DERIVER.MAX_OUTPUT_TOKENS or settings.LLM.DEFAULT_MAX_TOKENS, - thinking_budget_tokens=settings.DERIVER.THINKING_BUDGET_TOKENS - if settings.DERIVER.PROVIDER == "anthropic" - else None, - enable_retry=True, - retry_attempts=3, -) async def critical_analysis_call( peer_id: str, peer_card: list[str] | None, @@ -69,8 +58,8 @@ async def critical_analysis_call( working_representation: str | None, history: str, new_turn: str, -): - return critical_analysis_prompt( +) -> ReasoningResponse: + prompt = critical_analysis_prompt( peer_id=peer_id, peer_card=peer_card, message_created_at=message_created_at, @@ -79,28 +68,51 @@ async def critical_analysis_call( new_turn=new_turn, ) + response = await honcho_llm_call( + provider=settings.DERIVER.PROVIDER, + model=settings.DERIVER.MODEL, + prompt=prompt, + max_tokens=settings.DERIVER.MAX_OUTPUT_TOKENS + or settings.LLM.DEFAULT_MAX_TOKENS, + track_name="Critical Analysis Call", + response_model=ReasoningResponse, + json_mode=True, + thinking_budget_tokens=settings.DERIVER.THINKING_BUDGET_TOKENS, + enable_retry=True, + retry_attempts=3, + ) + + return response.content + -@honcho_llm_call( - provider=settings.DERIVER.PEER_CARD_PROVIDER, - model=settings.DERIVER.PEER_CARD_MODEL, - track_name="Peer Card Call", - response_model=PeerCardQuery, - json_mode=True, - max_tokens=settings.DERIVER.PEER_CARD_MAX_OUTPUT_TOKENS - or settings.LLM.DEFAULT_MAX_TOKENS, - reasoning_effort="minimal", - enable_retry=True, - retry_attempts=1, # unstructured output means we shouldn't need to retry, 1 just in case -) async def peer_card_call( old_peer_card: list[str] | None, new_observations: list[str], -): - return peer_card_prompt( +) -> PeerCardQuery: + """ + Generate peer card prompt, call LLM with response model. + """ + prompt = peer_card_prompt( old_peer_card=old_peer_card, new_observations=new_observations, ) + response = await honcho_llm_call( + provider=settings.DERIVER.PEER_CARD_PROVIDER, + model=settings.DERIVER.PEER_CARD_MODEL, + prompt=prompt, + max_tokens=settings.DERIVER.PEER_CARD_MAX_OUTPUT_TOKENS + or settings.LLM.DEFAULT_MAX_TOKENS, + track_name="Peer Card Call", + response_model=PeerCardQuery, + json_mode=True, + reasoning_effort="minimal", + enable_retry=True, + retry_attempts=3, + ) + + return response.content + @conditional_observe @sentry_sdk.trace @@ -272,7 +284,7 @@ async def process_representation_task( ) if settings.LANGFUSE_PUBLIC_KEY: - langfuse_context.update_current_trace( + lf.update_current_trace( output=format_reasoning_response_as_markdown(final_observations) ) @@ -302,7 +314,7 @@ class CertaintyReasoner: """ if settings.LANGFUSE_PUBLIC_KEY: - langfuse_context.update_current_observation( + lf.update_current_generation( input=format_reasoning_inputs_as_markdown( working_representation, history, @@ -392,7 +404,7 @@ class CertaintyReasoner: ) if settings.LANGFUSE_PUBLIC_KEY: - langfuse_context.update_current_observation( + lf.update_current_generation( output=format_reasoning_response_as_markdown(response), ) diff --git a/src/deriver/prompts.py b/src/deriver/prompts.py index 0746895c..c6f37603 100644 --- a/src/deriver/prompts.py +++ b/src/deriver/prompts.py @@ -8,10 +8,7 @@ and reasoning tasks. import datetime from inspect import cleandoc as c -from mirascope import prompt_template - -@prompt_template() def critical_analysis_prompt( peer_id: str, peer_card: list[str] | None, @@ -102,7 +99,6 @@ New conversation turn to analyze: ) -@prompt_template() def peer_card_prompt( old_peer_card: list[str] | None, new_observations: list[str], diff --git a/src/dialectic/chat.py b/src/dialectic/chat.py index ef656bb9..99e7a468 100644 --- a/src/dialectic/chat.py +++ b/src/dialectic/chat.py @@ -9,18 +9,18 @@ historical observations. import asyncio import logging import uuid +from collections.abc import AsyncIterator import tiktoken from dotenv import load_dotenv -from langfuse.decorators import langfuse_context -from mirascope.llm import Stream +from langfuse import get_client from src import crud from src.config import settings from src.crud.representation import GLOBAL_REPRESENTATION_COLLECTION_NAME from src.dependencies import tracked_db from src.utils import summarizer -from src.utils.clients import honcho_llm_call +from src.utils.clients import HonchoLLMCallStreamChunk, honcho_llm_call from src.utils.embedding_store import EmbeddingStore from src.utils.logging import ( accumulate_metric, @@ -36,18 +36,10 @@ logger = logging.getLogger(__name__) # Load environment variables load_dotenv() +# Create langfuse client +lf = get_client() + -@honcho_llm_call( - provider=settings.DIALECTIC.PROVIDER, - model=settings.DIALECTIC.MODEL, - track_name="Dialectic Call", - max_tokens=settings.DIALECTIC.MAX_OUTPUT_TOKENS, - thinking_budget_tokens=settings.DIALECTIC.THINKING_BUDGET_TOKENS - if settings.DIALECTIC.PROVIDER == "anthropic" - else None, - enable_retry=True, - retry_attempts=3, -) async def dialectic_call( query: str, working_representation: str | None, @@ -71,7 +63,7 @@ async def dialectic_call( Model response """ # Generate the prompt and log it - prompt_result = dialectic_prompt( + prompt = dialectic_prompt( query, working_representation, recent_conversation_history, @@ -82,32 +74,26 @@ async def dialectic_call( target_peer_card, ) - # Pretty print the prompt content - if len(prompt_result) > 0: - # Extract content from the first BaseMessageParam - prompt_content = prompt_result[0].content - else: - prompt_content = str(prompt_result) + response = await honcho_llm_call( + provider=settings.DIALECTIC.PROVIDER, + model=settings.DIALECTIC.MODEL, + prompt=prompt, + max_tokens=settings.DIALECTIC.MAX_OUTPUT_TOKENS, + track_name="Dialectic Call", + thinking_budget_tokens=settings.DIALECTIC.THINKING_BUDGET_TOKENS + if settings.DIALECTIC.PROVIDER == "anthropic" + else None, + enable_retry=True, + retry_attempts=3, + ) logger.debug("=== DIALECTIC PROMPT ===") - logger.debug(prompt_content) + logger.debug(prompt) logger.debug("=== END DIALECTIC PROMPT ===") - return prompt_result + return response.content -@honcho_llm_call( - provider=settings.DIALECTIC.PROVIDER, - model=settings.DIALECTIC.MODEL, - track_name="Dialectic Stream", - max_tokens=settings.DIALECTIC.MAX_OUTPUT_TOKENS, - thinking_budget_tokens=settings.DIALECTIC.THINKING_BUDGET_TOKENS - if settings.DIALECTIC.PROVIDER == "anthropic" - else None, - enable_retry=True, - retry_attempts=3, - stream=True, -) async def dialectic_stream( query: str, working_representation: str | None, @@ -131,7 +117,7 @@ async def dialectic_stream( Streaming model response """ # Generate the prompt and log it - prompt_result = dialectic_prompt( + prompt = dialectic_prompt( query, working_representation, recent_conversation_history, @@ -142,18 +128,25 @@ async def dialectic_stream( target_peer_card, ) - # Pretty print the prompt content - if len(prompt_result) > 0: - # Extract content from the first BaseMessageParam - prompt_content = prompt_result[0].content - else: - prompt_content = str(prompt_result) + response = await honcho_llm_call( + provider=settings.DIALECTIC.PROVIDER, + model=settings.DIALECTIC.MODEL, + prompt=prompt, + max_tokens=settings.DIALECTIC.MAX_OUTPUT_TOKENS, + track_name="Dialectic Stream", + thinking_budget_tokens=settings.DIALECTIC.THINKING_BUDGET_TOKENS + if settings.DIALECTIC.PROVIDER == "anthropic" + else None, + enable_retry=True, + retry_attempts=3, + stream=True, + ) logger.debug("=== DIALECTIC PROMPT (STREAM) ===") - logger.debug(prompt_content) + logger.debug(prompt) logger.debug("=== END DIALECTIC PROMPT ===") - return prompt_result + return response async def chat( @@ -164,7 +157,7 @@ async def chat( query: str, *, stream: bool = False, -) -> Stream | str: +) -> str | AsyncIterator[HonchoLLMCallStreamChunk]: """ Chat with the Dialectic API that builds on-demand user representations. @@ -197,7 +190,7 @@ async def chat( context_window_size -= len(tokenizer.encode(query)) if settings.LANGFUSE_PUBLIC_KEY: - langfuse_context.update_current_trace( + lf.update_current_trace( metadata={ "query_generation_model": settings.DIALECTIC.QUERY_GENERATION_MODEL, "query_generation_provider": settings.DIALECTIC.QUERY_GENERATION_PROVIDER, diff --git a/src/dialectic/prompts.py b/src/dialectic/prompts.py index 81d4076c..99cd1141 100644 --- a/src/dialectic/prompts.py +++ b/src/dialectic/prompts.py @@ -1,9 +1,6 @@ from inspect import cleandoc as c -from mirascope import prompt_template - -@prompt_template() def dialectic_prompt( query: str, working_representation: str | None, @@ -86,7 +83,6 @@ Provide a natural language response that: ) -@prompt_template() def query_generation_prompt(query: str, target_peer_name: str) -> str: """ Generate the prompt for semantic query expansion. @@ -101,7 +97,7 @@ def query_generation_prompt(query: str, target_peer_name: str) -> str: """ return c( f""" - You are a query expansion agent helping AI applications understand their users. The user's name is {target_peer_name}. Your job is to take application queries about this user and generate targeted search queries that will retrieve the most relevant observations using semantic search over an embedding store containing observations about the user. +You are a query expansion agent helping AI applications understand their users. The user's name is {target_peer_name}. Your job is to take application queries about this user and generate targeted search queries that will retrieve the most relevant observations using semantic search over an embedding store containing observations about the user. ## QUERY EXPANSION STRATEGY FOR SEMANTIC SIMILARITY diff --git a/src/dialectic/utils.py b/src/dialectic/utils.py index 8f9cdceb..bc834266 100644 --- a/src/dialectic/utils.py +++ b/src/dialectic/utils.py @@ -3,7 +3,7 @@ import json import logging from typing import Any -from langfuse.decorators import langfuse_context +from langfuse import get_client from src.config import settings from src.models import Document @@ -21,6 +21,8 @@ from .prompts import query_generation_prompt # Configure logging logger = logging.getLogger(__name__) +lf = get_client() + @conditional_observe async def get_observations( @@ -75,7 +77,7 @@ async def get_observations( unique_observations = _deduplicate_observations(all_results) if settings.LANGFUSE_PUBLIC_KEY: - langfuse_context.update_current_observation( + lf.update_current_generation( input={ "query": query, "include_premises": include_premises, @@ -87,7 +89,7 @@ async def get_observations( }, ) - langfuse_context.update_current_trace( + lf.update_current_trace( metadata={ "search_queries": search_queries, "observations_retrieved": unique_observations, @@ -227,13 +229,18 @@ def _format_observations( return "\n".join(parts).strip() -@honcho_llm_call( - provider=settings.DIALECTIC.QUERY_GENERATION_PROVIDER, - model=settings.DIALECTIC.QUERY_GENERATION_MODEL, - response_model=SemanticQueries, - enable_retry=True, - retry_attempts=3, -) -async def generate_semantic_queries(query: str, target_peer_name: str): +async def generate_semantic_queries( + query: str, target_peer_name: str +) -> SemanticQueries: """Generate semantic search queries for observation retrieval.""" - return query_generation_prompt(query, target_peer_name) + prompt = query_generation_prompt(query, target_peer_name) + response = await honcho_llm_call( + provider=settings.DIALECTIC.QUERY_GENERATION_PROVIDER, + model=settings.DIALECTIC.QUERY_GENERATION_MODEL, + prompt=prompt, + max_tokens=settings.LLM.DEFAULT_MAX_TOKENS, + response_model=SemanticQueries, + enable_retry=True, + retry_attempts=3, + ) + return response.content diff --git a/src/routers/peers.py b/src/routers/peers.py index c9b393b5..b022a945 100644 --- a/src/routers/peers.py +++ b/src/routers/peers.py @@ -1,5 +1,5 @@ import logging -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, AsyncIterator from fastapi import ( APIRouter, @@ -11,7 +11,6 @@ from fastapi.exceptions import HTTPException from fastapi.responses import StreamingResponse from fastapi_pagination import Page from fastapi_pagination.ext.sqlalchemy import apaginate -from mirascope.llm import Stream from sqlalchemy.ext.asyncio import AsyncSession from src import crud, schemas @@ -190,8 +189,8 @@ async def chat( query=options.query, stream=options.stream, ) - if isinstance(stream, Stream): - async for chunk, _ in stream: + if isinstance(stream, AsyncIterator): + async for chunk in stream: yield chunk.content else: raise HTTPException(status_code=500, detail="Invalid stream type") diff --git a/src/utils/clients.py b/src/utils/clients.py index 0fa7ddd8..58691440 100644 --- a/src/utils/clients.py +++ b/src/utils/clients.py @@ -1,392 +1,657 @@ -from collections.abc import Awaitable, Callable -from typing import ( - Any, - Literal, - ParamSpec, - Protocol, - TypeVar, - overload, - runtime_checkable, -) - -# --- OpenAI compatibility shim must run BEFORE importing mirascope --- -# Some versions of the OpenAI SDK do not expose ChatCompletionMessageToolCall -# at openai.types.chat, but some integrations import it from there at runtime. -# We defensively define it if missing to avoid import-time failures. -# -# We can get rid of this by getting rid of mirascope... -from openai.types import chat as _openai_chat_types # type: ignore - -if not hasattr(_openai_chat_types, "ChatCompletionMessageToolCall"): - _openai_chat_types.ChatCompletionMessageToolCall = object # pyright: ignore +from collections.abc import AsyncIterator, Callable +from functools import wraps +from typing import Any, Generic, Literal, TypeVar, cast, overload from anthropic import AsyncAnthropic +from anthropic.types import TextBlock +from anthropic.types.message import Message as AnthropicMessage from google import genai +from google.genai.types import GenerateContentResponse from groq import AsyncGroq -from mirascope import llm -from mirascope.core import ResponseModelConfigDict -from mirascope.integrations.langfuse import with_langfuse -from mirascope.llm import Stream +from langfuse import get_client from openai import AsyncOpenAI -from pydantic import BaseModel +from openai.types.chat import ChatCompletion, ChatCompletionChunk +from pydantic import BaseModel, Field from sentry_sdk.ai.monitoring import ai_track from tenacity import retry, stop_after_attempt, wait_exponential from src.config import settings -from src.utils.types import Providers +from src.utils.types import SupportedProviders -clients: dict[Providers, AsyncAnthropic | AsyncOpenAI | genai.Client | AsyncGroq] = {} +T = TypeVar("T") +M = TypeVar("M", bound=BaseModel) + +lf = get_client() + +CLIENTS: dict[ + SupportedProviders, + AsyncAnthropic | AsyncOpenAI | genai.Client | AsyncGroq, +] = {} if settings.LLM.ANTHROPIC_API_KEY: anthropic = AsyncAnthropic(api_key=settings.LLM.ANTHROPIC_API_KEY) - clients["anthropic"] = anthropic + CLIENTS["anthropic"] = anthropic if settings.LLM.OPENAI_API_KEY: openai_client = AsyncOpenAI( api_key=settings.LLM.OPENAI_API_KEY, ) - clients["openai"] = openai_client + CLIENTS["openai"] = openai_client if settings.LLM.OPENAI_COMPATIBLE_BASE_URL: - clients["custom"] = AsyncOpenAI( + 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 + google = genai.client.Client(api_key=settings.LLM.GEMINI_API_KEY) + CLIENTS["google"] = google if settings.LLM.GROQ_API_KEY: groq = AsyncGroq(api_key=settings.LLM.GROQ_API_KEY) - clients["groq"] = groq + CLIENTS["groq"] = groq -providers = [ +SELECTED_PROVIDERS = [ ("Dialectic", settings.DIALECTIC.PROVIDER), ("Summary", settings.SUMMARY.PROVIDER), ("Deriver", settings.DERIVER.PROVIDER), ("Query Generation Provider", settings.DIALECTIC.QUERY_GENERATION_PROVIDER), ] -for provider_name, provider_value in providers: - if provider_value not in clients: +for provider_name, provider_value in SELECTED_PROVIDERS: + if provider_value not in CLIENTS: raise ValueError(f"Missing client for {provider_name}: {provider_value}") -P = ParamSpec("P") -T = TypeVar("T", bound=BaseModel) -T_co = TypeVar("T_co", bound=BaseModel, covariant=True) -F = TypeVar("F", bound=Callable[..., Any]) - -# Define protocols for different return types -@runtime_checkable -class AsyncResponseModelCallable(Protocol[P, T_co]): - async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T_co: ... - - -@runtime_checkable -class SyncResponseModelCallable(Protocol[P, T_co]): - def __call__(self, *args: P.args, **kwargs: P.kwargs) -> T_co: ... - - -@runtime_checkable -class AsyncStreamCallable(Protocol[P]): - async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Stream: ... - - -@runtime_checkable -class SyncStreamCallable(Protocol[P]): - def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Stream: ... - - -@runtime_checkable -class AsyncStringCallable(Protocol[P]): - async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> str: ... - - -@runtime_checkable -class SyncStringCallable(Protocol[P]): - def __call__(self, *args: P.args, **kwargs: P.kwargs) -> str: ... - - -@runtime_checkable -class AsyncCallResponseCallable(Protocol[P]): - async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> llm.CallResponse: ... - - -# Overload for stream=True with async function -@overload -def honcho_llm_call( - *, - provider: Providers | None = None, - model: str | None = None, - track_name: str | None = None, - response_model: type[BaseModel] | None = None, - json_mode: bool = False, - max_tokens: int | None = None, - reasoning_effort: Literal["low", "medium", "high", "minimal"] | None = None, - verbosity: Literal["low", "medium", "high"] | None = None, - thinking_budget_tokens: int | None = None, - enable_retry: bool = True, - retry_attempts: int = 3, - stream: Literal[True], - **extra_call_params: Any, -) -> Callable[[Callable[P, Awaitable[Any]]], AsyncStreamCallable[P]]: ... - - -# Overload for response_model with async function -@overload -def honcho_llm_call( - *, - provider: Providers | None = None, - model: str | None = None, - track_name: str | None = None, - response_model: type[T], - json_mode: bool = False, - max_tokens: int | None = None, - reasoning_effort: Literal["low", "medium", "high", "minimal"] | None = None, - verbosity: Literal["low", "medium", "high"] | None = None, - thinking_budget_tokens: int | None = None, - enable_retry: bool = True, - retry_attempts: int = 3, - stream: Literal[False] = False, - **extra_call_params: Any, -) -> Callable[[Callable[P, Awaitable[Any]]], AsyncResponseModelCallable[P, T]]: ... - - -# Overload for return_call_response=True with async function -@overload -def honcho_llm_call( - *, - provider: Providers | None = None, - model: str | None = None, - track_name: str | None = None, - response_model: None = None, - json_mode: bool = False, - max_tokens: int | None = None, - reasoning_effort: Literal["low", "medium", "high", "minimal"] | None = None, - verbosity: Literal["low", "medium", "high"] | None = None, - thinking_budget_tokens: int | None = None, - enable_retry: bool = True, - retry_attempts: int = 3, - stream: Literal[False] = False, - return_call_response: Literal[True], - **extra_call_params: Any, -) -> Callable[[Callable[P, Awaitable[Any]]], AsyncCallResponseCallable[P]]: ... - - -# Overload for no response_model with async function (string return) -@overload -def honcho_llm_call( - *, - provider: Providers | None = None, - model: str | None = None, - track_name: str | None = None, - response_model: None = None, - json_mode: bool = False, - max_tokens: int | None = None, - reasoning_effort: Literal["low", "medium", "high", "minimal"] | None = None, - verbosity: Literal["low", "medium", "high"] | None = None, - thinking_budget_tokens: int | None = None, - enable_retry: bool = True, - retry_attempts: int = 3, - stream: Literal[False] = False, - return_call_response: Literal[False], - **extra_call_params: Any, -) -> Callable[[Callable[P, Awaitable[Any]]], AsyncStringCallable[P]]: ... - - -# Generic overload for sync functions (fallback) -@overload -def honcho_llm_call( - *, - provider: Providers | None = None, - model: str | None = None, - track_name: str | None = None, - response_model: type[BaseModel] | None = None, - json_mode: bool = False, - max_tokens: int | None = None, - reasoning_effort: Literal["low", "medium", "high", "minimal"] | None = None, - verbosity: Literal["low", "medium", "high"] | None = None, - thinking_budget_tokens: int | None = None, - enable_retry: bool = True, - retry_attempts: int = 3, - stream: bool = False, - **extra_call_params: Any, -) -> Callable[[Callable[P, Any]], Callable[P, Any]]: ... - - -def honcho_llm_call( - provider: Providers | None = None, - model: str | None = None, - track_name: str | None = None, - response_model: type[BaseModel] | None = None, - json_mode: bool = False, - max_tokens: int | None = None, - reasoning_effort: Literal["low", "medium", "high", "minimal"] | None = None, - verbosity: Literal["low", "medium", "high"] | None = None, - thinking_budget_tokens: int | None = None, - enable_retry: bool = True, - retry_attempts: int = 3, - stream: bool = False, - return_call_response: bool = False, # pyright: ignore - **extra_call_params: Any, -) -> Any: +class HonchoLLMCallResponse(BaseModel, Generic[T]): """ - Consolidated decorator for LLM calls that handles provider-specific configurations. - - This decorator automatically: - - Handles both sync and async functions seamlessly - - Applies retry logic with exponential backoff - - Adds AI tracking for Sentry - - Integrates with Langfuse for observability - - Builds provider-specific call parameters - - Handles client selection from the global clients dict + Response object for LLM calls. Args: - provider: The LLM provider to use (e.g., "anthropic", "google", "openai") - model: The model to use - track_name: Name for AI tracking (e.g., "Critical Analysis Call") - response_model: Optional Pydantic model for structured responses - json_mode: Whether to enable JSON mode (for providers that support it) - max_tokens: Maximum tokens for the response - reasoning_effort: Optional reasoning effort hint passed to OpenAI GPT-5 models only - verbosity: Optional verbosity hint passed to OpenAI GPT-5 models only - thinking_budget_tokens: Budget for thinking tokens (Anthropic only) - enable_retry: Whether to enable retry logic (default: True) - retry_attempts: Number of retry attempts (default: 3) - stream: Whether to enable streaming responses (default: False) - _return_call_response: Whether to return the full CallResponse object (default: False) - **extra_call_params: Additional provider-specific parameters - - Returns: - A decorator that returns: - - For async functions: Callable[P, Awaitable[T]] where T is Stream, response_model, CallResponse, or str - - For sync functions: Callable[P, T] where T is Stream, response_model, CallResponse, or str - - Note: Type annotations may be needed at the call site for proper type checking. - - Example (async function): - @honcho_llm_call( - provider=settings.DERIVER.PROVIDER, - model=settings.DERIVER.MODEL, - track_name="Critical Analysis Call", - response_model=ReasoningResponse, - json_mode=True, - max_tokens=settings.DERIVER.MAX_OUTPUT_TOKENS, - ) - async def analyze(context: str, query: str): - return prompt_template(context, query) - - Example (sync function): - @honcho_llm_call( - provider="openai", - model="gpt-4", - max_tokens=1000, - ) - def generate_summary(text: str) -> str: - return f"Summarize: {text}" - - # Call synchronously - result = generate_summary("Long text here...") + content: The response content. When a response_model is provided, this will be + the parsed object of that type. Otherwise, it will be a string. + output_tokens: Number of tokens generated in the response. + finish_reasons: List of finish reasons for the response. """ - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: - # Handle special case for custom provider - # Custom providers use OpenAI-compatible endpoints, so we resolve to "openai" for the provider name - # but keep the original "custom" for client lookup - resolved_provider = "openai" if provider == "custom" else provider + content: T + output_tokens: int + finish_reasons: list[str] - # Build provider-specific call params - call_params: dict[str, Any] = {} - if resolved_provider == "google": - # Google uses 'config' parameter - config: dict[str, Any] = {} - if max_tokens: - config["max_output_tokens"] = max_tokens +class HonchoLLMCallStreamChunk(BaseModel): + """ + A single chunk in a streaming LLM response. + Args: + content: The text content for this chunk. Empty for chunks that only contain metadata. + is_done: Whether this is the final chunk in the stream. + finish_reasons: List of finish reasons if the stream is complete. + """ + + content: str + is_done: bool = False + finish_reasons: list[str] = Field(default_factory=list) + + +@overload +async def honcho_llm_call( + provider: SupportedProviders, + model: str, + prompt: str, + max_tokens: int, + track_name: str | None = None, + *, + response_model: type[M], + json_mode: bool = False, + reasoning_effort: Literal["low", "medium", "high", "minimal"] + | None = None, # OpenAI only + verbosity: Literal["low", "medium", "high"] | None = None, # OpenAI only + thinking_budget_tokens: int | None = None, + enable_retry: bool = True, + retry_attempts: int = 3, + stream: Literal[False] = False, +) -> HonchoLLMCallResponse[M]: ... + + +@overload +async def honcho_llm_call( + provider: SupportedProviders, + model: str, + prompt: str, + max_tokens: int, + track_name: str | None = None, + response_model: None = None, + json_mode: bool = False, + reasoning_effort: Literal["low", "medium", "high", "minimal"] + | None = None, # OpenAI only + verbosity: Literal["low", "medium", "high"] | None = None, # OpenAI only + thinking_budget_tokens: int | None = None, + enable_retry: bool = True, + retry_attempts: int = 3, + stream: Literal[False] = False, +) -> HonchoLLMCallResponse[str]: ... + + +@overload +async def honcho_llm_call( + provider: SupportedProviders, + model: str, + prompt: str, + max_tokens: int, + track_name: str | None = None, + response_model: type[BaseModel] | None = None, + json_mode: bool = False, + reasoning_effort: Literal["low", "medium", "high", "minimal"] + | None = None, # OpenAI only + verbosity: Literal["low", "medium", "high"] | None = None, # OpenAI only + thinking_budget_tokens: int | None = None, + enable_retry: bool = True, + retry_attempts: int = 3, + stream: Literal[True] = ..., +) -> AsyncIterator[HonchoLLMCallStreamChunk]: ... + + +async def honcho_llm_call( + provider: SupportedProviders, + model: str, + prompt: str, + max_tokens: int, + track_name: str | None = None, + response_model: type[BaseModel] | None = None, + json_mode: bool = False, + reasoning_effort: Literal["low", "medium", "high", "minimal"] + | None = None, # OpenAI only + verbosity: Literal["low", "medium", "high"] | None = None, # OpenAI only + thinking_budget_tokens: int | None = None, + enable_retry: bool = True, + retry_attempts: int = 3, + stream: bool = False, +) -> HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk]: + client = CLIENTS.get(provider) + if not client: + raise ValueError(f"Missing client for {provider}") + + decorated = honcho_llm_call_inner + + # apply langfuse if enabled + if settings.LANGFUSE_PUBLIC_KEY: + decorated = with_langfuse(decorated) + + # apply tracking + if track_name: + decorated = ai_track(track_name)(decorated) + + # apply retry logic + if enable_retry: + decorated = retry( + stop=stop_after_attempt(retry_attempts), + wait=wait_exponential(multiplier=1, min=4, max=10), + )(decorated) + + if stream: + return await decorated( + provider, + model, + prompt, + max_tokens, + response_model, + json_mode, + reasoning_effort, + verbosity, + thinking_budget_tokens, + True, + ) + else: + return await decorated( + provider, + model, + prompt, + max_tokens, + response_model, + json_mode, + reasoning_effort, + verbosity, + thinking_budget_tokens, + False, + ) + + +@overload +async def honcho_llm_call_inner( + provider: SupportedProviders, + model: str, + prompt: str, + max_tokens: int, + response_model: type[M], + json_mode: bool = False, + reasoning_effort: Literal["low", "medium", "high", "minimal"] + | None = None, # OpenAI only + verbosity: Literal["low", "medium", "high"] | None = None, # OpenAI only + thinking_budget_tokens: int | None = None, # Anthropic only + stream: Literal[False] = False, +) -> HonchoLLMCallResponse[M]: ... + + +@overload +async def honcho_llm_call_inner( + provider: SupportedProviders, + model: str, + prompt: str, + max_tokens: int, + response_model: None = None, + json_mode: bool = False, + reasoning_effort: Literal["low", "medium", "high", "minimal"] + | None = None, # OpenAI only + verbosity: Literal["low", "medium", "high"] | None = None, # OpenAI only + thinking_budget_tokens: int | None = None, # Anthropic only + stream: Literal[False] = False, +) -> HonchoLLMCallResponse[str]: ... + + +@overload +async def honcho_llm_call_inner( + provider: SupportedProviders, + model: str, + prompt: str, + max_tokens: int, + response_model: type[BaseModel] | None = None, + json_mode: bool = False, + reasoning_effort: Literal["low", "medium", "high", "minimal"] + | None = None, # OpenAI only + verbosity: Literal["low", "medium", "high"] | None = None, # OpenAI only + thinking_budget_tokens: int | None = None, # Anthropic only + stream: Literal[True] = ..., +) -> AsyncIterator[HonchoLLMCallStreamChunk]: ... + + +async def honcho_llm_call_inner( + provider: SupportedProviders, + model: str, + prompt: str, + max_tokens: int, + response_model: type[BaseModel] | None = None, + json_mode: bool = False, + reasoning_effort: Literal["low", "medium", "high", "minimal"] + | None = None, # OpenAI only + verbosity: Literal["low", "medium", "high"] | None = None, # OpenAI only + thinking_budget_tokens: int | None = None, # Anthropic only + stream: bool = False, +) -> HonchoLLMCallResponse[Any] | AsyncIterator[HonchoLLMCallStreamChunk]: + # has already been validated by honcho_llm_call + client = CLIENTS[provider] + + params: dict[str, Any] = { + "model": model, + "max_tokens": max_tokens, + "messages": [{"role": "user", "content": prompt}], + "stream": stream, + } + + if stream: + # Return async generator for streaming responses + return handle_streaming_response( + client, + params, + json_mode, + thinking_budget_tokens, + response_model, + reasoning_effort, + verbosity, + ) + + # Remove stream parameter for non-streaming calls as some providers don't accept it + params.pop("stream", None) + + match client: + case AsyncAnthropic(): if response_model: - config["response_schema"] = response_model - + raise NotImplementedError( + "Response model is not supported for Anthropic" + ) + anthropic_params: dict[str, Any] = { + "model": params["model"], + "max_tokens": params["max_tokens"], + "messages": list(params["messages"]), + } if json_mode: - config["response_mime_type"] = "application/json" - - if config: - call_params["config"] = config - elif resolved_provider == "anthropic": - # Anthropic uses thinking params and max_tokens + anthropic_params["messages"].append( + {"role": "assistant", "content": "{"} + ) if thinking_budget_tokens: - call_params["thinking"] = { + anthropic_params["thinking"] = { "type": "enabled", "budget_tokens": thinking_budget_tokens, } - if max_tokens: - call_params["max_tokens"] = max_tokens - elif resolved_provider == "openai" and model and "gpt-5" in model: - call_params["max_completion_tokens"] = max_tokens - if reasoning_effort is not None: - call_params["reasoning_effort"] = reasoning_effort - if verbosity is not None: - call_params["verbosity"] = verbosity - else: - # Other providers just use max_tokens - if max_tokens: - call_params["max_tokens"] = max_tokens + anthropic_response: AnthropicMessage = await client.messages.create( # pyright: ignore + **anthropic_params + ) + # Extract text content from content blocks + text_blocks: list[str] = [] + for block in anthropic_response.content: # pyright: ignore + if isinstance(block, TextBlock): + text_blocks.append(block.text) - # Merge with any user-supplied provider call params - # Accept an explicit "extra_call_params" dict kwarg and merge its contents - # Do NOT forward the key itself into provider params. - # Also drop any wrapper-only flags. - user_extra_call_params: dict[str, Any] | None = extra_call_params.pop( - "extra_call_params", None - ) - extra_call_params.pop("return_call_response", None) - if isinstance(user_extra_call_params, dict): - call_params.update(user_extra_call_params) + # Safely extract usage and stop_reason + usage = anthropic_response.usage # pyright: ignore + stop_reason = anthropic_response.stop_reason # pyright: ignore - # Build kwargs for llm.call - llm_kwargs: dict[str, Any] = {} - if resolved_provider and provider: - llm_kwargs["provider"] = resolved_provider - llm_kwargs["client"] = clients[ - provider - ] # Use original provider for client lookup - if model: - llm_kwargs["model"] = model - if response_model: - # https://mirascope.com/docs/mirascope/learn/provider-specific/openai#response-models - if resolved_provider == "openai": - response_model.model_config = ResponseModelConfigDict(strict=True) - llm_kwargs["response_model"] = response_model - if json_mode: - llm_kwargs["json_mode"] = json_mode - if stream: - llm_kwargs["stream"] = stream - if call_params: - llm_kwargs["call_params"] = call_params + return HonchoLLMCallResponse( + content="\n".join(text_blocks), + output_tokens=usage.output_tokens if usage else 0, # pyright: ignore + finish_reasons=[stop_reason] if stop_reason else [], + ) + case AsyncOpenAI(): + openai_params: dict[str, Any] = { + "model": params["model"], + "messages": params["messages"], + } + if "gpt-5" in model: + openai_params["max_completion_tokens"] = params["max_tokens"] + if reasoning_effort: + openai_params["reasoning_effort"] = reasoning_effort + if verbosity: + openai_params["verbosity"] = verbosity + else: + openai_params["max_tokens"] = params["max_tokens"] + if json_mode: + openai_params["response_format"] = {"type": "json_object"} + if response_model: + openai_params["response_format"] = response_model + response: ChatCompletion = await client.chat.completions.parse( # pyright: ignore + **openai_params + ) + # Extract the parsed object for structured output + parsed_content = response.choices[0].message.parsed + if parsed_content is None: + raise ValueError("No parsed content in structured response") - # Apply decorators in order - decorated: Any = func + # Safely extract usage and finish_reason + usage = response.usage + finish_reason = response.choices[0].finish_reason - # Apply llm.call - decorated = llm.call(**llm_kwargs)(decorated) # pyright: ignore + return HonchoLLMCallResponse( + content=parsed_content, + output_tokens=usage.completion_tokens if usage else 0, + finish_reasons=[finish_reason] if finish_reason else [], + ) + else: + response: ChatCompletion = await client.chat.completions.create( # pyright: ignore + **openai_params + ) - # Apply langfuse if enabled - if settings.LANGFUSE_PUBLIC_KEY: - decorated = with_langfuse()(decorated) # pyright: ignore + # Safely extract usage and finish_reason + usage = response.usage # pyright: ignore + finish_reason = response.choices[0].finish_reason # pyright: ignore - # Apply AI tracking if name provided - if track_name: - decorated = ai_track(track_name)(decorated) + return HonchoLLMCallResponse( + content=response.choices[0].message.content or "", # pyright: ignore + output_tokens=usage.completion_tokens if usage else 0, # pyright: ignore + finish_reasons=[finish_reason] if finish_reason else [], + ) + case genai.Client(): + if response_model is None: + gemini_response: GenerateContentResponse = ( + client.models.generate_content( + model=model, + contents=prompt, + config={ + "response_mime_type": "application/json" + if json_mode + else None, + }, + ) + ) - # Apply retry logic if enabled - if enable_retry: - decorated = retry( # pyright: ignore - stop=stop_after_attempt(retry_attempts), - wait=wait_exponential(multiplier=1, min=4, max=10), - )(decorated) # pyright: ignore + # Safely extract response data + text_content = gemini_response.text if gemini_response.text else "" + token_count = ( + gemini_response.candidates[0].token_count or 0 + if gemini_response.candidates + else 0 + ) + finish_reason = ( + gemini_response.candidates[0].finish_reason.name + if gemini_response.candidates + and gemini_response.candidates[0].finish_reason + else "stop" + ) - return decorated # pyright: ignore + return HonchoLLMCallResponse( + content=text_content, + output_tokens=token_count, + finish_reasons=[finish_reason], + ) - return decorator + else: + gemini_response = client.models.generate_content( + model=model, + contents=prompt, + config={ + "response_mime_type": "application/json", + "response_schema": response_model, + }, + ) + + token_count = ( + gemini_response.candidates[0].token_count or 0 + if gemini_response.candidates + else 0 + ) + finish_reason = ( + gemini_response.candidates[0].finish_reason.name + if gemini_response.candidates + and gemini_response.candidates[0].finish_reason + else "stop" + ) + + return HonchoLLMCallResponse( + content=gemini_response.parsed, + output_tokens=token_count, + finish_reasons=[finish_reason], + ) + + case AsyncGroq(): + groq_params: dict[str, Any] = { + "model": params["model"], + "max_tokens": params["max_tokens"], + "messages": params["messages"], + } + + if response_model: + groq_params["response_format"] = response_model + elif json_mode: + groq_params["response_format"] = {"type": "json_object"} + + response: ChatCompletion = await client.chat.completions.create( # pyright: ignore + **groq_params + ) + if response.choices[0].message.content is None: # pyright: ignore + raise ValueError("No content in response") + + # Safely extract usage and finish_reason + usage = response.usage # pyright: ignore + finish_reason = response.choices[0].finish_reason # pyright: ignore + + return HonchoLLMCallResponse( + content=response.choices[0].message.content, # pyright: ignore + output_tokens=usage.completion_tokens if usage else 0, # pyright: ignore + finish_reasons=[finish_reason] if finish_reason else [], + ) + + +async def handle_streaming_response( + client: AsyncAnthropic | AsyncOpenAI | genai.Client | AsyncGroq, + params: dict[str, Any], + json_mode: bool, + thinking_budget_tokens: int | None, + response_model: type[BaseModel] | None = None, + reasoning_effort: Literal["low", "medium", "high", "minimal"] | None = None, + verbosity: Literal["low", "medium", "high"] | None = None, +) -> AsyncIterator[HonchoLLMCallStreamChunk]: + """ + Handle streaming responses for all supported providers. + + Args: + client: The LLM client instance + params: Request parameters including stream=True + json_mode: Whether to use JSON mode + thinking_budget_tokens: Anthropic thinking budget tokens + response_model: Pydantic model for structured output + reasoning_effort: OpenAI reasoning effort level (GPT-5 only) + verbosity: OpenAI verbosity level (GPT-5 only) + + Yields: + HonchoLLMCallStreamChunk: Individual chunks of the streaming response + """ + match client: + case AsyncAnthropic(): + if response_model: + raise NotImplementedError( + "Response model is not supported for Anthropic" + ) + anthropic_params: dict[str, Any] = { + "model": params["model"], + "max_tokens": params["max_tokens"], + "messages": list(params["messages"]), + } + if json_mode: + anthropic_params["messages"].append( + {"role": "assistant", "content": "{"} + ) + if thinking_budget_tokens: + anthropic_params["thinking"] = { + "type": "enabled", + "budget_tokens": thinking_budget_tokens, + } + async with client.messages.stream(**anthropic_params) as anthropic_stream: + async for chunk in anthropic_stream: + if ( + chunk.type == "content_block_delta" + and hasattr(chunk, "delta") + and hasattr(chunk.delta, "text") + ): + text_content = getattr(chunk.delta, "text", "") + yield HonchoLLMCallStreamChunk(content=text_content) + final_message = await anthropic_stream.get_final_message() + yield HonchoLLMCallStreamChunk( + content="", + is_done=True, + finish_reasons=[final_message.stop_reason] + if final_message.stop_reason + else [], + ) + + case AsyncOpenAI(): + openai_params: dict[str, Any] = { + "model": params["model"], + "messages": params["messages"], + "stream": True, + } + + model_name = params["model"] + if "gpt-5" in model_name: + openai_params["max_completion_tokens"] = params["max_tokens"] + if reasoning_effort: + openai_params["reasoning_effort"] = reasoning_effort + if verbosity: + openai_params["verbosity"] = verbosity + else: + openai_params["max_tokens"] = params["max_tokens"] + + if response_model: + openai_params["response_format"] = response_model + elif json_mode: + openai_params["response_format"] = {"type": "json_object"} + + openai_stream = await client.chat.completions.create(**openai_params) # pyright: ignore + async for chunk in openai_stream: # pyright: ignore + chunk = cast(ChatCompletionChunk, chunk) + if chunk.choices and chunk.choices[0].delta.content: + yield HonchoLLMCallStreamChunk( + content=chunk.choices[0].delta.content + ) + if chunk.choices and chunk.choices[0].finish_reason: + yield HonchoLLMCallStreamChunk( + content="", + is_done=True, + finish_reasons=[chunk.choices[0].finish_reason], + ) + + case genai.Client(): + prompt_text = params["messages"][0]["content"] if params["messages"] else "" + + if response_model is not None: + response_stream = await client.aio.models.generate_content_stream( + model=params["model"], + contents=prompt_text, + config={ + "response_mime_type": "application/json", + "response_schema": response_model, + }, + ) + else: + response_stream = await client.aio.models.generate_content_stream( + model=params["model"], + contents=prompt_text, + config={ + "response_mime_type": "application/json" if json_mode else None, + }, + ) + + final_chunk = None + async for chunk in response_stream: + if chunk.text: + yield HonchoLLMCallStreamChunk(content=chunk.text) + final_chunk = chunk + + finish_reason = "stop" # Default fallback + if ( + final_chunk + and hasattr(final_chunk, "candidates") + and final_chunk.candidates + and hasattr(final_chunk.candidates[0], "finish_reason") + and final_chunk.candidates[0].finish_reason + ): + finish_reason = final_chunk.candidates[0].finish_reason.name + + yield HonchoLLMCallStreamChunk( + content="", is_done=True, finish_reasons=[finish_reason] + ) + + case AsyncGroq(): + groq_params: dict[str, Any] = { + "model": params["model"], + "max_tokens": params["max_tokens"], + "messages": params["messages"], + "stream": True, + } + + if response_model: + groq_params["response_format"] = response_model + elif json_mode: + groq_params["response_format"] = {"type": "json_object"} + + groq_stream = await client.chat.completions.create(**groq_params) # pyright: ignore + async for chunk in groq_stream: # pyright: ignore + chunk = cast(ChatCompletionChunk, chunk) + if chunk.choices and chunk.choices[0].delta.content: + yield HonchoLLMCallStreamChunk( + content=chunk.choices[0].delta.content + ) + if chunk.choices and chunk.choices[0].finish_reason: + yield HonchoLLMCallStreamChunk( + content="", + is_done=True, + finish_reasons=[chunk.choices[0].finish_reason], + ) + + +def with_langfuse(func: Callable[..., Any]) -> Callable[..., Any]: + @wraps(func) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + lf.start_as_current_generation(name="LLM Call") + return await func(*args, **kwargs) + + return wrapper diff --git a/src/utils/embedding_store.py b/src/utils/embedding_store.py index a18025d3..08fd5bf4 100644 --- a/src/utils/embedding_store.py +++ b/src/utils/embedding_store.py @@ -4,7 +4,7 @@ import datetime import logging from typing import Any, Literal, overload -from langfuse.decorators import langfuse_context +from langfuse import get_client from openai.types import CreateEmbeddingResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -24,6 +24,8 @@ from src.utils.shared_models import ( logger = logging.getLogger(__name__) +lf = get_client() + class EmbeddingStore: """Embedding store specialized for observation-based reasoning with structured metadata.""" diff --git a/src/utils/logging.py b/src/utils/logging.py index 9d80f9a9..b9ceae32 100644 --- a/src/utils/logging.py +++ b/src/utils/logging.py @@ -33,7 +33,7 @@ def conditional_observe(func: Callable[..., Any]) -> Callable[..., Any]: """ if settings.LANGFUSE_PUBLIC_KEY: # Import here to avoid circular imports and only import when needed - from langfuse.decorators import observe # pyright: ignore + from langfuse import observe # pyright: ignore return observe()(func) else: diff --git a/src/utils/summarizer.py b/src/utils/summarizer.py index c2337337..9e10fa2a 100644 --- a/src/utils/summarizer.py +++ b/src/utils/summarizer.py @@ -2,8 +2,8 @@ import asyncio import logging import time from enum import Enum +from inspect import cleandoc as c -from mirascope import llm from sqlalchemy import update from sqlalchemy.ext.asyncio import AsyncSession from typing_extensions import TypedDict @@ -12,7 +12,7 @@ from src import schemas from src.config import settings from src.dependencies import tracked_db from src.exceptions import ResourceNotFoundException -from src.utils.clients import honcho_llm_call +from src.utils.clients import HonchoLLMCallResponse, honcho_llm_call from src.utils.formatting import utc_now_iso from src.utils.logging import accumulate_metric @@ -77,17 +77,11 @@ class SummaryType(Enum): LONG = "honcho_chat_summary_long" -@honcho_llm_call( - provider=settings.SUMMARY.PROVIDER, - model=settings.SUMMARY.MODEL, - max_tokens=settings.SUMMARY.MAX_TOKENS_SHORT, - return_call_response=True, -) async def create_short_summary( messages: list[models.Message], input_tokens: int, previous_summary: str | None = None, -): +) -> HonchoLLMCallResponse[str]: # input_tokens indicates how many tokens the message list + previous summary take up # we want to optimize short summaries to be smaller than the actual content being summarized # so we ask the agent to produce a word count roughly equal to either the input, or the max @@ -100,7 +94,7 @@ async def create_short_summary( else: previous_summary_text = "There is no previous summary -- the messages are the beginning of the conversation." - return f""" + prompt = c(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 (**Capture as many explicit facts as possible**) @@ -123,19 +117,20 @@ Return only the summary without any explanation or meta-commentary. Produce as thorough a summary as possible in {output_words} words or less. -""" +""") + + return await honcho_llm_call( + provider=settings.SUMMARY.PROVIDER, + model=settings.SUMMARY.MODEL, + prompt=prompt, + max_tokens=settings.SUMMARY.MAX_TOKENS_SHORT, + ) -@honcho_llm_call( - provider=settings.SUMMARY.PROVIDER, - model=settings.SUMMARY.MODEL, - max_tokens=settings.SUMMARY.MAX_TOKENS_LONG, - return_call_response=True, -) async def create_long_summary( messages: list[models.Message], previous_summary: str | None = None, -): +) -> HonchoLLMCallResponse[str]: # the word/token ratio is roughly 4:3 so we multiply by 0.75. # LLMs *seem* to respond better to getting asked for a word count but should workshop this. output_words = int(settings.SUMMARY.MAX_TOKENS_LONG * 0.75) @@ -145,7 +140,7 @@ async def create_long_summary( else: previous_summary_text = "There is no previous summary -- the messages are the beginning of the conversation." - return f""" + prompt = c(f""" You are a system that creates thorough, comprehensive summaries of conversations. Focus on capturing: 1. Key facts and information shared (**Capture as many explicit facts as possible**) @@ -170,7 +165,14 @@ Return only the summary without any explanation or meta-commentary. Produce as thorough a summary as possible in {output_words} words or less. -""" +""") + + return await honcho_llm_call( + provider=settings.SUMMARY.PROVIDER, + model=settings.SUMMARY.MODEL, + prompt=prompt, + max_tokens=settings.SUMMARY.MAX_TOKENS_LONG, + ) async def summarize_if_needed( @@ -345,7 +347,7 @@ async def _create_summary( A full summary of the conversation up to the last message """ - response: llm.CallResponse | None = None + response: HonchoLLMCallResponse[str] | None = None try: if summary_type == SummaryType.SHORT: response = await create_short_summary( @@ -355,11 +357,7 @@ async def _create_summary( response = await create_long_summary(messages, previous_summary_text) summary_text = response.content - summary_tokens = ( - response.usage.output_tokens - if response.usage - else len(response.content) // 4 - ) + summary_tokens = response.output_tokens # Detect potential issues with the summary if not summary_text.strip(): @@ -379,19 +377,10 @@ async def _create_summary( ) summary_tokens = 50 - accumulate_metric( - f"summary_{messages[-1].workspace_name}_{messages[-1].id}", - f"{summary_type.name}_summary_input", - response.usage.input_tokens if response and response.usage else "unknown", - "tokens", - ) - accumulate_metric( f"summary_{messages[-1].workspace_name}_{messages[-1].id}", f"{summary_type.name}_summary_size", - response.usage.output_tokens - if response and response.usage - else f"{summary_tokens} (est.)", + response.output_tokens if response else f"{summary_tokens} (est.)", "tokens", ) diff --git a/src/utils/types.py b/src/utils/types.py index d3e7aa4e..494f2225 100644 --- a/src/utils/types.py +++ b/src/utils/types.py @@ -1,5 +1,3 @@ from typing import Literal -from mirascope import Provider - -Providers = Provider | Literal["custom"] +SupportedProviders = Literal["anthropic", "openai", "google", "groq", "custom"] diff --git a/tests/bench/harness.py b/tests/bench/harness.py index 9b373408..c4fbfa75 100755 --- a/tests/bench/harness.py +++ b/tests/bench/harness.py @@ -328,6 +328,7 @@ class HonchoHarness: # "src.routers.", # "src.crud.", "google_genai.models", + "google.genai.models", ] try: diff --git a/tests/bench/peer_card_bench.py b/tests/bench/peer_card_bench.py index 9a46cec6..a2233d7d 100644 --- a/tests/bench/peer_card_bench.py +++ b/tests/bench/peer_card_bench.py @@ -141,24 +141,25 @@ def build_peer_card_caller( "openai" if candidate.provider == "custom" else candidate.provider ) - @honcho_llm_call( - provider=cast(Any, resolved_provider), - model=candidate.model, - track_name="Peer Card Call", - response_model=PeerCardQuery, - json_mode=True, - max_tokens=settings.DERIVER.PEER_CARD_MAX_OUTPUT_TOKENS, - reasoning_effort="minimal", - enable_retry=True, - retry_attempts=1, # unstructured output means we shouldn't need to retry, 1 just in case - ) async def call(old_peer_card: list[str] | None, new_observations: list[str]) -> Any: - """Return the prompt content for Mirascope to execute as a model call.""" - - return peer_card_prompt( + prompt = peer_card_prompt( old_peer_card=old_peer_card, new_observations=new_observations ) + response = await honcho_llm_call( + provider=cast(Any, resolved_provider), + model=candidate.model, + prompt=prompt, + max_tokens=settings.DERIVER.PEER_CARD_MAX_OUTPUT_TOKENS, + response_model=PeerCardQuery, + json_mode=True, + reasoning_effort="minimal", + enable_retry=True, + retry_attempts=3, + ) + + return response.content + return call @@ -323,7 +324,7 @@ async def run_benchmark(candidates: list[Candidate], cases: list[Case]) -> int: case.old_peer_card, case.new_observations ) new_card = card.card - if new_card is None: + if new_card is None or new_card == []: new_card = case.old_peer_card or [] judgment = await judge_response(anthropic, case, new_card) return case, {"card": card, "judgment": judgment} diff --git a/tests/conftest.py b/tests/conftest.py index 0a00dd78..480ccf7d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -257,8 +257,7 @@ async def sample_data( def mock_langfuse(): """Mock Langfuse decorator and context during tests""" with ( - patch("langfuse.decorators.observe") as mock_observe, - patch("langfuse.decorators.langfuse_context") as mock_context, + patch("langfuse.observe") as mock_observe, ): # Mock the decorator to just return the function def return_value(func: Callable[..., Any]): @@ -266,12 +265,6 @@ def mock_langfuse(): mock_observe.return_value = return_value - # Mock the context object - mock_context_obj = MagicMock() - mock_context_obj.update_current_observation = MagicMock() - mock_context_obj.update_current_trace = MagicMock() - mock_context.return_value = mock_context_obj - # Disable httpx logging during tests logging.getLogger("httpx").setLevel(logging.WARNING) @@ -309,8 +302,8 @@ def mock_openai_embeddings(): @pytest.fixture(autouse=True) -def mock_mirascope_functions(): - """Mock Mirascope LLM functions to avoid needing API keys during tests""" +def mock_llm_call_functions(): + """Mock LLM functions to avoid needing API keys during tests""" # Create mock responses for different function types with ( diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 00000000..f616c2dc --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1 @@ +# Test utilities package diff --git a/tests/utils/test_clients.py b/tests/utils/test_clients.py new file mode 100644 index 00000000..be582ebd --- /dev/null +++ b/tests/utils/test_clients.py @@ -0,0 +1,1046 @@ +""" +Comprehensive tests for src/utils/clients.py + +Tests cover: +- All supported LLM providers (Anthropic, OpenAI, Google/Gemini, Groq) +- Streaming and non-streaming responses +- Response models (structured output) +- Error handling and retries +- Provider-specific features +- Client initialization +- Langfuse integration +""" + +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from anthropic import AsyncAnthropic +from anthropic.types import TextBlock, Usage +from openai.types.chat import ChatCompletion, ChatCompletionChunk +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice +from openai.types.chat.chat_completion_chunk import ChoiceDelta +from openai.types.chat.chat_completion_message import ChatCompletionMessage +from openai.types.completion_usage import CompletionUsage +from pydantic import BaseModel, Field + +from src.utils.clients import ( + CLIENTS, + HonchoLLMCallResponse, + HonchoLLMCallStreamChunk, + handle_streaming_response, + honcho_llm_call, + honcho_llm_call_inner, + with_langfuse, +) + + +class SampleTestModel(BaseModel): + """Test Pydantic model for structured output""" + + name: str + age: int + active: bool = Field(default=True) + + +class TestLLMCallResponse: + """Tests for HonchoLLMCallResponse and HonchoLLMCallStreamChunk models""" + + def test_llm_call_response_creation(self): + """Test creating HonchoLLMCallResponse with string content""" + response = HonchoLLMCallResponse( + content="Hello world", output_tokens=10, finish_reasons=["stop"] + ) + assert response.content == "Hello world" + assert response.output_tokens == 10 + assert response.finish_reasons == ["stop"] + + def test_llm_call_response_with_model(self): + """Test creating HonchoLLMCallResponse with Pydantic model content""" + model = SampleTestModel(name="John", age=30) + response = HonchoLLMCallResponse[SampleTestModel]( + content=model, output_tokens=15, finish_reasons=["stop"] + ) + assert response.content.name == "John" + assert response.content.age == 30 + assert response.content.active is True + + def test_stream_chunk_creation(self): + """Test creating HonchoLLMCallStreamChunk""" + chunk = HonchoLLMCallStreamChunk(content="Hello") + assert chunk.content == "Hello" + assert chunk.is_done is False + assert chunk.finish_reasons == [] + + def test_stream_chunk_done(self): + """Test creating final HonchoLLMCallStreamChunk""" + chunk = HonchoLLMCallStreamChunk( + content="", is_done=True, finish_reasons=["stop"] + ) + assert chunk.content == "" + assert chunk.is_done is True + assert chunk.finish_reasons == ["stop"] + + def test_stream_chunk_default_finish_reasons(self): + """Test that finish_reasons defaults to empty list""" + chunk = HonchoLLMCallStreamChunk(content="test") + assert isinstance(chunk.finish_reasons, list) + assert chunk.finish_reasons == [] + + +class TestLangfuseIntegration: + """Tests for Langfuse integration""" + + @pytest.mark.asyncio + async def test_with_langfuse_decorator(self): + """Test Langfuse decorator functionality""" + + @with_langfuse + async def test_func(): + return "decorated" + + # Mock the langfuse client + with patch("src.utils.clients.lf") as mock_lf: + result = await test_func() + assert result == "decorated" + mock_lf.start_as_current_generation.assert_called_once_with(name="LLM Call") + + +@pytest.mark.asyncio +class TestAnthropicClient: + """Tests for Anthropic client functionality""" + + async def test_anthropic_basic_call(self): + """Test basic Anthropic API call""" + from anthropic import AsyncAnthropic + + mock_client = AsyncMock(spec=AsyncAnthropic) + mock_response = Mock() + mock_response.content = [TextBlock(text="Hello from Anthropic", type="text")] + mock_response.usage = Usage(input_tokens=10, output_tokens=5) + mock_response.stop_reason = "stop" + mock_client.messages.create = AsyncMock(return_value=mock_response) + + with patch.dict(CLIENTS, {"anthropic": mock_client}): + response = await honcho_llm_call_inner( + provider="anthropic", + model="claude-3-sonnet", + prompt="Hello", + max_tokens=100, + ) + + assert isinstance(response, HonchoLLMCallResponse) + assert response.content == "Hello from Anthropic" + assert response.output_tokens == 5 + assert response.finish_reasons == ["stop"] + + async def test_anthropic_multiple_text_blocks(self): + """Test Anthropic response with multiple text blocks""" + from anthropic import AsyncAnthropic + + mock_client = AsyncMock(spec=AsyncAnthropic) + mock_response = Mock() + mock_response.content = [ + TextBlock(text="First block", type="text"), + TextBlock(text="Second block", type="text"), + ] + mock_response.usage = Usage(input_tokens=10, output_tokens=8) + mock_response.stop_reason = "stop" + mock_client.messages.create = AsyncMock(return_value=mock_response) + + with patch.dict(CLIENTS, {"anthropic": mock_client}): + response = await honcho_llm_call_inner( + provider="anthropic", + model="claude-3-sonnet", + prompt="Hello", + max_tokens=100, + ) + + assert response.content == "First block\nSecond block" + assert response.output_tokens == 8 + + async def test_anthropic_json_mode(self): + """Test Anthropic with JSON mode""" + from anthropic import AsyncAnthropic + + mock_client = AsyncMock(spec=AsyncAnthropic) + mock_response = Mock() + mock_response.content = [TextBlock(text='{"result": "success"}', type="text")] + mock_response.usage = Usage(input_tokens=10, output_tokens=5) + mock_response.stop_reason = "stop" + mock_client.messages.create = AsyncMock(return_value=mock_response) + + with patch.dict(CLIENTS, {"anthropic": mock_client}): + _response = await honcho_llm_call_inner( + provider="anthropic", + model="claude-3-sonnet", + prompt="Generate JSON", + max_tokens=100, + json_mode=True, + ) + + # Verify assistant message was added for JSON mode + mock_client.messages.create.assert_called_once() + call_args = mock_client.messages.create.call_args + messages = call_args.kwargs["messages"] + assert any( + msg["role"] == "assistant" and msg["content"] == "{" for msg in messages + ) + + async def test_anthropic_thinking_budget(self): + """Test Anthropic with thinking budget tokens""" + from anthropic import AsyncAnthropic + + mock_client = AsyncMock(spec=AsyncAnthropic) + mock_response = Mock() + mock_response.content = [TextBlock(text="Thoughtful response", type="text")] + mock_response.usage = Usage(input_tokens=10, output_tokens=5) + mock_response.stop_reason = "stop" + mock_client.messages.create = AsyncMock(return_value=mock_response) + + with patch.dict(CLIENTS, {"anthropic": mock_client}): + _response = await honcho_llm_call_inner( + provider="anthropic", + model="claude-3-sonnet", + prompt="Think about this", + max_tokens=100, + thinking_budget_tokens=1000, + ) + + # Verify thinking parameter was passed + mock_client.messages.create.assert_called_once() + call_args = mock_client.messages.create.call_args + thinking_config = call_args.kwargs["thinking"] + assert thinking_config == {"type": "enabled", "budget_tokens": 1000} + + async def test_anthropic_response_model_not_supported(self): + """Test that Anthropic raises error for response models""" + mock_client = AsyncMock(spec=AsyncAnthropic) + + with ( + patch.dict(CLIENTS, {"anthropic": mock_client}), + pytest.raises( + NotImplementedError, + match="Response model is not supported for Anthropic", + ), + ): + await honcho_llm_call_inner( + provider="anthropic", + model="claude-3-sonnet", + prompt="Hello", + max_tokens=100, + response_model=SampleTestModel, + ) + + async def test_anthropic_streaming(self): + """Test Anthropic streaming response""" + from anthropic import AsyncAnthropic + + mock_client = AsyncMock(spec=AsyncAnthropic) + mock_stream = AsyncMock() + + # Mock streaming chunks + mock_chunks = [ + Mock(type="content_block_delta", delta=Mock(text="Hello")), + Mock(type="content_block_delta", delta=Mock(text=" world")), + ] + + # Set up the async context manager + mock_stream.__aenter__.return_value = mock_stream + mock_stream.__aexit__.return_value = None + + # Set up the async iterator (same as working test_streaming_call) + mock_stream.__aiter__.return_value = iter(mock_chunks) + + # Mock final message + mock_final_message = Mock(stop_reason="stop") + mock_stream.get_final_message.return_value = mock_final_message + + mock_client.messages.stream.return_value = mock_stream + + with patch.dict(CLIENTS, {"anthropic": mock_client}): + chunks: list[HonchoLLMCallStreamChunk] = [] + async for chunk in handle_streaming_response( + client=mock_client, + params={ + "model": "claude-3-sonnet", + "max_tokens": 100, + "messages": [{"role": "user", "content": "Hello"}], + }, + json_mode=False, + thinking_budget_tokens=None, + ): + chunks.append(chunk) + + assert len(chunks) == 3 # 2 content chunks + 1 final chunk + assert chunks[0].content == "Hello" + assert chunks[1].content == " world" + assert chunks[2].content == "" + assert chunks[2].is_done is True + assert chunks[2].finish_reasons == ["stop"] + + +@pytest.mark.asyncio +class TestOpenAIClient: + """Tests for OpenAI client functionality""" + + async def test_openai_basic_call(self): + """Test basic OpenAI API call""" + from openai import AsyncOpenAI + + mock_client = AsyncMock(spec=AsyncOpenAI) + mock_response = ChatCompletion( + id="test-id", + object="chat.completion", + created=1234567890, + model="gpt-4", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage( + role="assistant", content="Hello from OpenAI" + ), + finish_reason="stop", + ) + ], + usage=CompletionUsage( + prompt_tokens=10, completion_tokens=5, total_tokens=15 + ), + ) + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) + + with patch.dict(CLIENTS, {"openai": mock_client}): + response = await honcho_llm_call_inner( + provider="openai", model="gpt-4", prompt="Hello", max_tokens=100 + ) + + assert isinstance(response, HonchoLLMCallResponse) + assert response.content == "Hello from OpenAI" + assert response.output_tokens == 5 + assert response.finish_reasons == ["stop"] + + async def test_openai_gpt5_parameters(self): + """Test OpenAI GPT-5 specific parameters""" + from openai import AsyncOpenAI + + mock_client = AsyncMock(spec=AsyncOpenAI) + mock_response = ChatCompletion( + id="test-id", + object="chat.completion", + created=1234567890, + model="gpt-5-turbo", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage( + role="assistant", content="GPT-5 response" + ), + finish_reason="stop", + ) + ], + usage=CompletionUsage( + prompt_tokens=10, completion_tokens=5, total_tokens=15 + ), + ) + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) + + with patch.dict(CLIENTS, {"openai": mock_client}): + _response = await honcho_llm_call_inner( + provider="openai", + model="gpt-5-turbo", + prompt="Hello", + max_tokens=100, + reasoning_effort="high", + verbosity="medium", + ) + + # Verify GPT-5 specific parameters were used + mock_client.chat.completions.create.assert_called_once() + call_args = mock_client.chat.completions.create.call_args + kwargs = call_args.kwargs + assert "max_completion_tokens" in kwargs + assert kwargs["max_completion_tokens"] == 100 + assert kwargs["reasoning_effort"] == "high" + assert kwargs["verbosity"] == "medium" + + async def test_openai_json_mode(self): + """Test OpenAI with JSON mode""" + from openai import AsyncOpenAI + + mock_client = AsyncMock(spec=AsyncOpenAI) + mock_response = ChatCompletion( + id="test-id", + object="chat.completion", + created=1234567890, + model="gpt-4", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage( + role="assistant", content='{"result": "success"}' + ), + finish_reason="stop", + ) + ], + usage=CompletionUsage( + prompt_tokens=10, completion_tokens=5, total_tokens=15 + ), + ) + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) + + with patch.dict(CLIENTS, {"openai": mock_client}): + _response = await honcho_llm_call_inner( + provider="openai", + model="gpt-4", + prompt="Generate JSON", + max_tokens=100, + json_mode=True, + ) + + # Verify JSON mode was enabled + mock_client.chat.completions.create.assert_called_once() + call_args = mock_client.chat.completions.create.call_args + assert call_args.kwargs["response_format"] == {"type": "json_object"} + + async def test_openai_response_model(self): + """Test OpenAI with structured output (response model)""" + from openai import AsyncOpenAI + + mock_client = AsyncMock(spec=AsyncOpenAI) + + # Create a mock parsed object + mock_parsed = SampleTestModel(name="John", age=30) + + # Create a proper ChatCompletionMessage and add parsed attribute + message = ChatCompletionMessage(role="assistant", content="") + setattr(message, "parsed", mock_parsed) # noqa: B010 + + mock_response = ChatCompletion( + id="test-id", + object="chat.completion", + created=1234567890, + model="gpt-4", + choices=[ + Choice( + index=0, + message=message, + finish_reason="stop", + ) + ], + usage=CompletionUsage( + prompt_tokens=10, completion_tokens=15, total_tokens=25 + ), + ) + mock_client.chat.completions.parse = AsyncMock(return_value=mock_response) + + with patch.dict(CLIENTS, {"openai": mock_client}): + response = await honcho_llm_call_inner( + provider="openai", + model="gpt-4", + prompt="Generate a person", + max_tokens=100, + response_model=SampleTestModel, + ) + + assert isinstance(response, HonchoLLMCallResponse) + assert isinstance(response.content, SampleTestModel) + assert response.content.name == "John" + assert response.content.age == 30 + assert response.output_tokens == 15 + + # Verify parse was called instead of create + mock_client.chat.completions.parse.assert_called_once() + mock_client.chat.completions.create.assert_not_called() + + async def test_openai_streaming(self): + """Test OpenAI streaming response""" + from openai import AsyncOpenAI + + mock_client = AsyncMock(spec=AsyncOpenAI) + + # Create mock streaming chunks + mock_chunks = [ + ChatCompletionChunk( + id="test-id", + object="chat.completion.chunk", + created=1234567890, + model="gpt-4", + choices=[ + ChunkChoice( + index=0, delta=ChoiceDelta(content="Hello"), finish_reason=None + ) + ], + ), + ChatCompletionChunk( + id="test-id", + object="chat.completion.chunk", + created=1234567890, + model="gpt-4", + choices=[ + ChunkChoice( + index=0, delta=ChoiceDelta(content=" world"), finish_reason=None + ) + ], + ), + ChatCompletionChunk( + id="test-id", + object="chat.completion.chunk", + created=1234567890, + model="gpt-4", + choices=[ + ChunkChoice( + index=0, delta=ChoiceDelta(content=None), finish_reason="stop" + ) + ], + ), + ] + + # Create async iterator + async def async_chunk_iterator(): + for chunk in mock_chunks: + yield chunk + + # OpenAI's create method returns an awaitable that resolves to an async iterator + async def mock_create(**_kwargs: Any): + return async_chunk_iterator() + + mock_client.chat.completions.create = mock_create + + with patch.dict(CLIENTS, {"openai": mock_client}): + chunks: list[HonchoLLMCallStreamChunk] = [] + async for chunk in handle_streaming_response( + client=mock_client, + params={ + "model": "gpt-4", + "max_tokens": 100, + "messages": [{"role": "user", "content": "Hello"}], + }, + json_mode=False, + thinking_budget_tokens=None, + ): + chunks.append(chunk) + + assert len(chunks) == 3 + assert chunks[0].content == "Hello" + assert chunks[1].content == " world" + assert chunks[2].content == "" + assert chunks[2].is_done is True + assert chunks[2].finish_reasons == ["stop"] + + +@pytest.mark.asyncio +class TestGoogleClient: + """Tests for Google/Gemini client functionality""" + + async def test_google_basic_call(self): + """Test basic Google/Gemini API call""" + from google import genai + + mock_client = Mock(spec=genai.Client) + mock_response = Mock() + mock_response.text = "Hello from Gemini" + mock_finish_reason = Mock() + mock_finish_reason.name = "STOP" + mock_response.candidates = [ + Mock(token_count=5, finish_reason=mock_finish_reason) + ] + mock_client.models.generate_content.return_value = mock_response + + with patch.dict(CLIENTS, {"google": mock_client}): + response = await honcho_llm_call_inner( + provider="google", + model="gemini-1.5-pro", + prompt="Hello", + max_tokens=100, + ) + + assert isinstance(response, HonchoLLMCallResponse) + assert response.content == "Hello from Gemini" + assert response.output_tokens == 5 + assert response.finish_reasons == ["STOP"] + + async def test_google_json_mode(self): + """Test Google/Gemini with JSON mode""" + from google import genai + + mock_client = Mock(spec=genai.Client) + mock_response = Mock() + mock_response.text = '{"result": "success"}' + mock_finish_reason = Mock() + mock_finish_reason.name = "STOP" + mock_response.candidates = [ + Mock(token_count=10, finish_reason=mock_finish_reason) + ] + mock_client.models.generate_content.return_value = mock_response + + with patch.dict(CLIENTS, {"google": mock_client}): + _response = await honcho_llm_call_inner( + provider="google", + model="gemini-1.5-pro", + prompt="Generate JSON", + max_tokens=100, + json_mode=True, + ) + + # Verify JSON mode was set in config + mock_client.models.generate_content.assert_called_once() + call_args = mock_client.models.generate_content.call_args + assert ( + call_args.kwargs["config"]["response_mime_type"] == "application/json" + ) + + async def test_google_response_model(self): + """Test Google/Gemini with structured output""" + from google import genai + + mock_client = Mock(spec=genai.Client) + mock_response = Mock() + mock_parsed = SampleTestModel(name="Alice", age=25) + mock_response.parsed = mock_parsed + mock_finish_reason = Mock() + mock_finish_reason.name = "STOP" + mock_response.candidates = [ + Mock(token_count=15, finish_reason=mock_finish_reason) + ] + mock_client.models.generate_content.return_value = mock_response + + with patch.dict(CLIENTS, {"google": mock_client}): + response = await honcho_llm_call_inner( + provider="google", + model="gemini-1.5-pro", + prompt="Generate a person", + max_tokens=100, + response_model=SampleTestModel, + ) + + assert isinstance(response, HonchoLLMCallResponse) + assert isinstance(response.content, SampleTestModel) + assert response.content.name == "Alice" + assert response.content.age == 25 + + # Verify structured output config + mock_client.models.generate_content.assert_called_once() + call_args = mock_client.models.generate_content.call_args + config = call_args.kwargs["config"] + assert config["response_mime_type"] == "application/json" + assert config["response_schema"] == SampleTestModel + + async def test_google_streaming(self): + """Test Google/Gemini streaming response""" + from google import genai + + mock_client = Mock(spec=genai.Client) + + # Mock streaming chunks + mock_finish_reason = Mock() + mock_finish_reason.name = "STOP" + mock_chunks = [ + Mock(text="Hello"), + Mock(text=" world"), + Mock(text="", candidates=[Mock(finish_reason=mock_finish_reason)]), + ] + + # Create async iterator for the chunks + async def async_chunk_iterator(): + for chunk in mock_chunks: + yield chunk + + # Mock the aio.models.generate_content_stream method to return an awaitable async iterator + mock_aio = Mock() + mock_aio.models.generate_content_stream = AsyncMock( + return_value=async_chunk_iterator() + ) + mock_client.aio = mock_aio + + with patch.dict(CLIENTS, {"google": mock_client}): + chunks: list[HonchoLLMCallStreamChunk] = [] + async for chunk in handle_streaming_response( + client=mock_client, + params={ + "model": "gemini-1.5-pro", + "max_tokens": 100, + "messages": [{"role": "user", "content": "Hello"}], + }, + json_mode=False, + thinking_budget_tokens=None, + ): + chunks.append(chunk) + + assert len(chunks) == 3 + assert chunks[0].content == "Hello" + assert chunks[1].content == " world" + assert chunks[2].content == "" + assert chunks[2].is_done is True + assert chunks[2].finish_reasons == ["STOP"] + + async def test_google_no_candidates_fallback(self): + """Test Google/Gemini fallback when no candidates""" + from google import genai + + mock_client = Mock(spec=genai.Client) + mock_response = Mock() + mock_response.text = "Response text" + mock_response.candidates = [] # Empty candidates + mock_client.models.generate_content.return_value = mock_response + + with patch.dict(CLIENTS, {"google": mock_client}): + response = await honcho_llm_call_inner( + provider="google", + model="gemini-1.5-pro", + prompt="Hello", + max_tokens=100, + ) + + assert response.content == "Response text" + assert response.output_tokens == 0 # Fallback value + assert response.finish_reasons == ["stop"] # Default fallback + + +@pytest.mark.asyncio +class TestGroqClient: + """Tests for Groq client functionality""" + + async def test_groq_basic_call(self): + """Test basic Groq API call""" + from groq import AsyncGroq + + mock_client = AsyncMock(spec=AsyncGroq) + mock_response = ChatCompletion( + id="test-id", + object="chat.completion", + created=1234567890, + model="llama-3.1-70b", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage( + role="assistant", content="Hello from Groq" + ), + finish_reason="stop", + ) + ], + usage=CompletionUsage( + prompt_tokens=10, completion_tokens=8, total_tokens=18 + ), + ) + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) + + with patch.dict(CLIENTS, {"groq": mock_client}): + response = await honcho_llm_call_inner( + provider="groq", model="llama-3.1-70b", prompt="Hello", max_tokens=100 + ) + + assert isinstance(response, HonchoLLMCallResponse) + assert response.content == "Hello from Groq" + assert response.output_tokens == 8 + assert response.finish_reasons == ["stop"] + + async def test_groq_json_mode(self): + """Test Groq with JSON mode""" + from groq import AsyncGroq + + mock_client = AsyncMock(spec=AsyncGroq) + mock_response = ChatCompletion( + id="test-id", + object="chat.completion", + created=1234567890, + model="llama-3.1-70b", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage( + role="assistant", content='{"success": true}' + ), + finish_reason="stop", + ) + ], + usage=CompletionUsage( + prompt_tokens=10, completion_tokens=5, total_tokens=15 + ), + ) + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) + + with patch.dict(CLIENTS, {"groq": mock_client}): + _response = await honcho_llm_call_inner( + provider="groq", + model="llama-3.1-70b", + prompt="Generate JSON", + max_tokens=100, + json_mode=True, + ) + + # Verify JSON mode was set + mock_client.chat.completions.create.assert_called_once() + call_args = mock_client.chat.completions.create.call_args + assert call_args.kwargs["response_format"] == {"type": "json_object"} + + async def test_groq_response_model(self): + """Test Groq with response model (structured output)""" + from groq import AsyncGroq + + mock_client = AsyncMock(spec=AsyncGroq) + mock_response = ChatCompletion( + id="test-id", + object="chat.completion", + created=1234567890, + model="llama-3.1-70b", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage(role="assistant", content="Bob"), + finish_reason="stop", + ) + ], + usage=CompletionUsage( + prompt_tokens=10, completion_tokens=12, total_tokens=22 + ), + ) + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) + + with patch.dict(CLIENTS, {"groq": mock_client}): + _response = await honcho_llm_call_inner( + provider="groq", + model="llama-3.1-70b", + prompt="Generate a person", + max_tokens=100, + response_model=SampleTestModel, + ) + + # Verify response_format was set to the model + mock_client.chat.completions.create.assert_called_once() + call_args = mock_client.chat.completions.create.call_args + assert call_args.kwargs["response_format"] == SampleTestModel + + async def test_groq_no_content_error(self): + """Test Groq error handling when no content in response""" + from groq import AsyncGroq + + mock_client = AsyncMock(spec=AsyncGroq) + mock_response = ChatCompletion( + id="test-id", + object="chat.completion", + created=1234567890, + model="llama-3.1-70b", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage(role="assistant", content=None), + finish_reason="stop", + ) + ], + usage=CompletionUsage( + prompt_tokens=10, completion_tokens=0, total_tokens=10 + ), + ) + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) + + with ( + patch.dict(CLIENTS, {"groq": mock_client}), + pytest.raises(ValueError, match="No content in response"), + ): + await honcho_llm_call_inner( + provider="groq", + model="llama-3.1-70b", + prompt="Hello", + max_tokens=100, + ) + + async def test_groq_streaming(self): + """Test Groq streaming response""" + from groq import AsyncGroq + + mock_client = AsyncMock(spec=AsyncGroq) + + # Create mock streaming chunks + mock_chunks = [ + ChatCompletionChunk( + id="test-id", + object="chat.completion.chunk", + created=1234567890, + model="llama-3.1-70b", + choices=[ + ChunkChoice( + index=0, delta=ChoiceDelta(content="Hello"), finish_reason=None + ) + ], + ), + ChatCompletionChunk( + id="test-id", + object="chat.completion.chunk", + created=1234567890, + model="llama-3.1-70b", + choices=[ + ChunkChoice( + index=0, + delta=ChoiceDelta(content=" from Groq"), + finish_reason=None, + ) + ], + ), + ChatCompletionChunk( + id="test-id", + object="chat.completion.chunk", + created=1234567890, + model="llama-3.1-70b", + choices=[ + ChunkChoice( + index=0, delta=ChoiceDelta(content=None), finish_reason="stop" + ) + ], + ), + ] + + # Create async iterator + async def async_chunk_iterator(): + for chunk in mock_chunks: + yield chunk + + # Mock the create method to return the async generator when awaited + mock_client.chat.completions.create = AsyncMock( + return_value=async_chunk_iterator() + ) + + with patch.dict(CLIENTS, {"groq": mock_client}): + chunks: list[HonchoLLMCallStreamChunk] = [] + async for chunk in handle_streaming_response( + client=mock_client, + params={ + "model": "llama-3.1-70b", + "max_tokens": 100, + "messages": [{"role": "user", "content": "Hello"}], + }, + json_mode=False, + thinking_budget_tokens=None, + ): + chunks.append(chunk) + + assert len(chunks) == 3 + assert chunks[0].content == "Hello" + assert chunks[1].content == " from Groq" + assert chunks[2].content == "" + assert chunks[2].is_done is True + assert chunks[2].finish_reasons == ["stop"] + + +@pytest.mark.asyncio +class TestMainLLMCallFunction: + """Tests for the main honcho_llm_call function""" + + async def test_streaming_call(self): + """Test streaming LLM call""" + from anthropic import AsyncAnthropic + + mock_client = AsyncMock(spec=AsyncAnthropic) + mock_stream = AsyncMock() + + # Mock streaming chunks + mock_chunks = [ + Mock(type="content_block_delta", delta=Mock(text="Stream")), + Mock(type="content_block_delta", delta=Mock(text=" test")), + ] + mock_stream.__aenter__.return_value = mock_stream + mock_stream.__aiter__.return_value = iter(mock_chunks) + + # Mock final message + mock_final_message = Mock(stop_reason="stop") + mock_stream.get_final_message.return_value = mock_final_message + + mock_client.messages.stream.return_value = mock_stream + + with patch.dict(CLIENTS, {"anthropic": mock_client}): + chunks: list[HonchoLLMCallStreamChunk] = [] + async for chunk in await honcho_llm_call( + provider="anthropic", + model="claude-3-sonnet", + prompt="Hello", + max_tokens=100, + stream=True, + enable_retry=False, # Disable retry for simpler testing + ): + chunks.append(chunk) + + assert len(chunks) == 3 # 2 content + 1 final + assert chunks[0].content == "Stream" + assert chunks[1].content == " test" + assert chunks[2].is_done is True + + async def test_retry_disabled(self): + """Test that retry can be disabled""" + from anthropic import AsyncAnthropic + + mock_client = AsyncMock(spec=AsyncAnthropic) + mock_response = Mock() + mock_response.content = [TextBlock(text="No retry response", type="text")] + mock_response.usage = Usage(input_tokens=5, output_tokens=5) + mock_response.stop_reason = "stop" + mock_client.messages.create = AsyncMock(return_value=mock_response) + + with patch.dict(CLIENTS, {"anthropic": mock_client}): + response = await honcho_llm_call( + provider="anthropic", + model="claude-3-sonnet", + prompt="Hello", + max_tokens=100, + enable_retry=False, + ) + + assert response.content == "No retry response" + + +class TestEdgeCases: + """Tests for edge cases and boundary conditions""" + + def test_stream_chunk_with_no_finish_reasons(self): + """Test stream chunk creation without finish reasons""" + chunk = HonchoLLMCallStreamChunk(content="test") + # Should use default_factory for empty list + assert chunk.finish_reasons == [] + # Modifying the list shouldn't affect other instances + chunk.finish_reasons.append("stop") + + new_chunk = HonchoLLMCallStreamChunk(content="test2") + assert new_chunk.finish_reasons == [] # Should still be empty + + +# Test fixtures and utilities +@pytest.fixture +def sample_test_model(): + """Fixture providing a sample SampleTestModel instance""" + return SampleTestModel(name="Test User", age=25, active=True) + + +@pytest.fixture +def mock_anthropic_client(): + """Fixture providing a mocked Anthropic client""" + mock_client = AsyncMock() + mock_response = Mock() + mock_response.content = [TextBlock(text="Mocked Anthropic response", type="text")] + mock_response.usage = Usage(input_tokens=10, output_tokens=5) + mock_response.stop_reason = "stop" + mock_client.messages.create.return_value = mock_response + return mock_client + + +@pytest.fixture +def mock_openai_client(): + """Fixture providing a mocked OpenAI client""" + mock_client = AsyncMock() + mock_response = ChatCompletion( + id="test-id", + object="chat.completion", + created=1234567890, + model="gpt-4", + choices=[ + Choice( + index=0, + message=ChatCompletionMessage( + role="assistant", content="Mocked OpenAI response" + ), + finish_reason="stop", + ) + ], + usage=CompletionUsage(prompt_tokens=10, completion_tokens=5, total_tokens=15), + ) + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) + return mock_client diff --git a/uv.lock b/uv.lock index b87fec0e..8155929f 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.13'", @@ -439,15 +439,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, ] -[[package]] -name = "docstring-parser" -version = "0.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, -] - [[package]] name = "email-validator" version = "2.2.0" @@ -558,59 +549,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, ] -[[package]] -name = "google-ai-generativelanguage" -version = "0.6.15" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/11/d1/48fe5d7a43d278e9f6b5ada810b0a3530bbeac7ed7fcbcd366f932f05316/google_ai_generativelanguage-0.6.15.tar.gz", hash = "sha256:8f6d9dc4c12b065fe2d0289026171acea5183ebf2d0b11cefe12f3821e159ec3", size = 1375443, upload-time = "2025-01-13T21:50:47.459Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7c/a3/67b8a6ff5001a1d8864922f2d6488dc2a14367ceb651bc3f09a947f2f306/google_ai_generativelanguage-0.6.15-py3-none-any.whl", hash = "sha256:5a03ef86377aa184ffef3662ca28f19eeee158733e45d7947982eb953c6ebb6c", size = 1327356, upload-time = "2025-01-13T21:50:44.174Z" }, -] - -[[package]] -name = "google-api-core" -version = "2.25.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "googleapis-common-protos" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/21/e9d043e88222317afdbdb567165fdbc3b0aad90064c7e0c9eb0ad9955ad8/google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8", size = 165443, upload-time = "2025-06-12T20:52:20.439Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/4b/ead00905132820b623732b175d66354e9d3e69fcf2a5dcdab780664e7896/google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7", size = 160807, upload-time = "2025-06-12T20:52:19.334Z" }, -] - -[package.optional-dependencies] -grpc = [ - { name = "grpcio" }, - { name = "grpcio-status" }, -] - -[[package]] -name = "google-api-python-client" -version = "2.178.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, - { name = "google-auth-httplib2" }, - { name = "httplib2" }, - { name = "uritemplate" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/99/98/916385a87d145a27661b630c480fadf9db32bb1ad9fb1b13e8dbcbe2af70/google_api_python_client-2.178.0.tar.gz", hash = "sha256:99cba921eb471bb5973b780c653ac54d96eef8a42f1b7375b7ab98f257a4414c", size = 13282628, upload-time = "2025-08-06T14:04:51.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/29/34/8ae31410a2d3f28b16b7135931133caf759d3aa0653f8397e344acec5a88/google_api_python_client-2.178.0-py3-none-any.whl", hash = "sha256:f420adcd050150ff1baefa817e96e1ffa16872744f53471cd34096612e580c34", size = 13809959, upload-time = "2025-08-06T14:04:47.94Z" }, -] - [[package]] name = "google-auth" version = "2.40.3" @@ -625,22 +563,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/63/b19553b658a1692443c62bd07e5868adaa0ad746a0751ba62c59568cd45b/google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca", size = 216137, upload-time = "2025-06-04T18:04:55.573Z" }, ] -[[package]] -name = "google-auth-httplib2" -version = "0.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "httplib2" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/56/be/217a598a818567b28e859ff087f347475c807a5649296fb5a817c58dacef/google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05", size = 10842, upload-time = "2023-12-12T17:40:30.722Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/8a/fe34d2f3f9470a27b01c9e76226965863f153d5fbe276f83608562e49c04/google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d", size = 9253, upload-time = "2023-12-12T17:40:13.055Z" }, -] - [[package]] name = "google-genai" -version = "1.28.0" +version = "1.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -652,27 +577,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/23/f1/039bb08df4670e204c55b5da0b2fa5228dff3346bda01389a86b300f6f58/google_genai-1.28.0.tar.gz", hash = "sha256:e93053c02e616842679ba5ecce5b99db8c0ca6310623c55ff6245b5b1d293138", size = 221029, upload-time = "2025-07-30T21:39:57.002Z" } +sdist = { url = "https://files.pythonhosted.org/packages/03/ab/e6cdd8fa957c647ef00c4da7c59d0e734354bd49ed8d98c860732d8e1944/google_genai-1.32.0.tar.gz", hash = "sha256:349da3f5ff0e981066bd508585fcdd308d28fc4646f318c8f6d1aa6041f4c7e3", size = 240802, upload-time = "2025-08-27T22:16:32.781Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/ea/b704df3b348d3ae3572b0db5b52438fa426900b0830cff664107abfdba69/google_genai-1.28.0-py3-none-any.whl", hash = "sha256:7fd506799005cc87d3c5704a2eb5a2cb020d45b4d216a802e606700308f7f2f3", size = 219384, upload-time = "2025-07-30T21:39:55.652Z" }, -] - -[[package]] -name = "google-generativeai" -version = "0.8.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-ai-generativelanguage" }, - { name = "google-api-core" }, - { name = "google-api-python-client" }, - { name = "google-auth" }, - { name = "protobuf" }, - { name = "pydantic" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/40/c42ff9ded9f09ec9392879a8e6538a00b2dc185e834a3392917626255419/google_generativeai-0.8.5-py3-none-any.whl", hash = "sha256:22b420817fb263f8ed520b33285f45976d5b21e904da32b80d4fd20c055123a2", size = 155427, upload-time = "2025-04-17T00:40:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/59/55/be09472f7a656af1208196d2ef9a3d2710f3cbcf695f51acbcbe28b9472b/google_genai-1.32.0-py3-none-any.whl", hash = "sha256:c0c4b1d45adf3aa99501050dd73da2f0dea09374002231052d81a6765d15e7f6", size = 241680, upload-time = "2025-08-27T22:16:31.409Z" }, ] [[package]] @@ -755,68 +662,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/f8/14672d69a91495f43462c5490067eeafc30346e81bda1a62848e897f9bc3/groq-0.31.0-py3-none-any.whl", hash = "sha256:5e3c7ec9728b7cccf913da982a9b5ebb46dc18a070b35e12a3d6a1e12d6b0f7f", size = 131365, upload-time = "2025-08-05T23:13:59.768Z" }, ] -[[package]] -name = "grpcio" -version = "1.74.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/38/b4/35feb8f7cab7239c5b94bd2db71abb3d6adb5f335ad8f131abb6060840b6/grpcio-1.74.0.tar.gz", hash = "sha256:80d1f4fbb35b0742d3e3d3bb654b7381cd5f015f8497279a1e9c21ba623e01b1", size = 12756048, upload-time = "2025-07-24T18:54:23.039Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/54/68e51a90797ad7afc5b0a7881426c337f6a9168ebab73c3210b76aa7c90d/grpcio-1.74.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:85bd5cdf4ed7b2d6438871adf6afff9af7096486fcf51818a81b77ef4dd30907", size = 5481935, upload-time = "2025-07-24T18:52:43.756Z" }, - { url = "https://files.pythonhosted.org/packages/32/2a/af817c7e9843929e93e54d09c9aee2555c2e8d81b93102a9426b36e91833/grpcio-1.74.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:68c8ebcca945efff9d86d8d6d7bfb0841cf0071024417e2d7f45c5e46b5b08eb", size = 10986796, upload-time = "2025-07-24T18:52:47.219Z" }, - { url = "https://files.pythonhosted.org/packages/d5/94/d67756638d7bb07750b07d0826c68e414124574b53840ba1ff777abcd388/grpcio-1.74.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:e154d230dc1bbbd78ad2fdc3039fa50ad7ffcf438e4eb2fa30bce223a70c7486", size = 5983663, upload-time = "2025-07-24T18:52:49.463Z" }, - { url = "https://files.pythonhosted.org/packages/35/f5/c5e4853bf42148fea8532d49e919426585b73eafcf379a712934652a8de9/grpcio-1.74.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8978003816c7b9eabe217f88c78bc26adc8f9304bf6a594b02e5a49b2ef9c11", size = 6653765, upload-time = "2025-07-24T18:52:51.094Z" }, - { url = "https://files.pythonhosted.org/packages/fd/75/a1991dd64b331d199935e096cc9daa3415ee5ccbe9f909aa48eded7bba34/grpcio-1.74.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3d7bd6e3929fd2ea7fbc3f562e4987229ead70c9ae5f01501a46701e08f1ad9", size = 6215172, upload-time = "2025-07-24T18:52:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/01/a4/7cef3dbb3b073d0ce34fd507efc44ac4c9442a0ef9fba4fb3f5c551efef5/grpcio-1.74.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:136b53c91ac1d02c8c24201bfdeb56f8b3ac3278668cbb8e0ba49c88069e1bdc", size = 6329142, upload-time = "2025-07-24T18:52:54.927Z" }, - { url = "https://files.pythonhosted.org/packages/bf/d3/587920f882b46e835ad96014087054655312400e2f1f1446419e5179a383/grpcio-1.74.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fe0f540750a13fd8e5da4b3eaba91a785eea8dca5ccd2bc2ffe978caa403090e", size = 7018632, upload-time = "2025-07-24T18:52:56.523Z" }, - { url = "https://files.pythonhosted.org/packages/1f/95/c70a3b15a0bc83334b507e3d2ae20ee8fa38d419b8758a4d838f5c2a7d32/grpcio-1.74.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4e4181bfc24413d1e3a37a0b7889bea68d973d4b45dd2bc68bb766c140718f82", size = 6509641, upload-time = "2025-07-24T18:52:58.495Z" }, - { url = "https://files.pythonhosted.org/packages/4b/06/2e7042d06247d668ae69ea6998eca33f475fd4e2855f94dcb2aa5daef334/grpcio-1.74.0-cp310-cp310-win32.whl", hash = "sha256:1733969040989f7acc3d94c22f55b4a9501a30f6aaacdbccfaba0a3ffb255ab7", size = 3817478, upload-time = "2025-07-24T18:53:00.128Z" }, - { url = "https://files.pythonhosted.org/packages/93/20/e02b9dcca3ee91124060b65bbf5b8e1af80b3b76a30f694b44b964ab4d71/grpcio-1.74.0-cp310-cp310-win_amd64.whl", hash = "sha256:9e912d3c993a29df6c627459af58975b2e5c897d93287939b9d5065f000249b5", size = 4493971, upload-time = "2025-07-24T18:53:02.068Z" }, - { url = "https://files.pythonhosted.org/packages/e7/77/b2f06db9f240a5abeddd23a0e49eae2b6ac54d85f0e5267784ce02269c3b/grpcio-1.74.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:69e1a8180868a2576f02356565f16635b99088da7df3d45aaa7e24e73a054e31", size = 5487368, upload-time = "2025-07-24T18:53:03.548Z" }, - { url = "https://files.pythonhosted.org/packages/48/99/0ac8678a819c28d9a370a663007581744a9f2a844e32f0fa95e1ddda5b9e/grpcio-1.74.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8efe72fde5500f47aca1ef59495cb59c885afe04ac89dd11d810f2de87d935d4", size = 10999804, upload-time = "2025-07-24T18:53:05.095Z" }, - { url = "https://files.pythonhosted.org/packages/45/c6/a2d586300d9e14ad72e8dc211c7aecb45fe9846a51e558c5bca0c9102c7f/grpcio-1.74.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:a8f0302f9ac4e9923f98d8e243939a6fb627cd048f5cd38595c97e38020dffce", size = 5987667, upload-time = "2025-07-24T18:53:07.157Z" }, - { url = "https://files.pythonhosted.org/packages/c9/57/5f338bf56a7f22584e68d669632e521f0de460bb3749d54533fc3d0fca4f/grpcio-1.74.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f609a39f62a6f6f05c7512746798282546358a37ea93c1fcbadf8b2fed162e3", size = 6655612, upload-time = "2025-07-24T18:53:09.244Z" }, - { url = "https://files.pythonhosted.org/packages/82/ea/a4820c4c44c8b35b1903a6c72a5bdccec92d0840cf5c858c498c66786ba5/grpcio-1.74.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c98e0b7434a7fa4e3e63f250456eaef52499fba5ae661c58cc5b5477d11e7182", size = 6219544, upload-time = "2025-07-24T18:53:11.221Z" }, - { url = "https://files.pythonhosted.org/packages/a4/17/0537630a921365928f5abb6d14c79ba4dcb3e662e0dbeede8af4138d9dcf/grpcio-1.74.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:662456c4513e298db6d7bd9c3b8df6f75f8752f0ba01fb653e252ed4a59b5a5d", size = 6334863, upload-time = "2025-07-24T18:53:12.925Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a6/85ca6cb9af3f13e1320d0a806658dca432ff88149d5972df1f7b51e87127/grpcio-1.74.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:3d14e3c4d65e19d8430a4e28ceb71ace4728776fd6c3ce34016947474479683f", size = 7019320, upload-time = "2025-07-24T18:53:15.002Z" }, - { url = "https://files.pythonhosted.org/packages/4f/a7/fe2beab970a1e25d2eff108b3cf4f7d9a53c185106377a3d1989216eba45/grpcio-1.74.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bf949792cee20d2078323a9b02bacbbae002b9e3b9e2433f2741c15bdeba1c4", size = 6514228, upload-time = "2025-07-24T18:53:16.999Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c2/2f9c945c8a248cebc3ccda1b7a1bf1775b9d7d59e444dbb18c0014e23da6/grpcio-1.74.0-cp311-cp311-win32.whl", hash = "sha256:55b453812fa7c7ce2f5c88be3018fb4a490519b6ce80788d5913f3f9d7da8c7b", size = 3817216, upload-time = "2025-07-24T18:53:20.564Z" }, - { url = "https://files.pythonhosted.org/packages/ff/d1/a9cf9c94b55becda2199299a12b9feef0c79946b0d9d34c989de6d12d05d/grpcio-1.74.0-cp311-cp311-win_amd64.whl", hash = "sha256:86ad489db097141a907c559988c29718719aa3e13370d40e20506f11b4de0d11", size = 4495380, upload-time = "2025-07-24T18:53:22.058Z" }, - { url = "https://files.pythonhosted.org/packages/4c/5d/e504d5d5c4469823504f65687d6c8fb97b7f7bf0b34873b7598f1df24630/grpcio-1.74.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:8533e6e9c5bd630ca98062e3a1326249e6ada07d05acf191a77bc33f8948f3d8", size = 5445551, upload-time = "2025-07-24T18:53:23.641Z" }, - { url = "https://files.pythonhosted.org/packages/43/01/730e37056f96f2f6ce9f17999af1556df62ee8dab7fa48bceeaab5fd3008/grpcio-1.74.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:2918948864fec2a11721d91568effffbe0a02b23ecd57f281391d986847982f6", size = 10979810, upload-time = "2025-07-24T18:53:25.349Z" }, - { url = "https://files.pythonhosted.org/packages/79/3d/09fd100473ea5c47083889ca47ffd356576173ec134312f6aa0e13111dee/grpcio-1.74.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:60d2d48b0580e70d2e1954d0d19fa3c2e60dd7cbed826aca104fff518310d1c5", size = 5941946, upload-time = "2025-07-24T18:53:27.387Z" }, - { url = "https://files.pythonhosted.org/packages/8a/99/12d2cca0a63c874c6d3d195629dcd85cdf5d6f98a30d8db44271f8a97b93/grpcio-1.74.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3601274bc0523f6dc07666c0e01682c94472402ac2fd1226fd96e079863bfa49", size = 6621763, upload-time = "2025-07-24T18:53:29.193Z" }, - { url = "https://files.pythonhosted.org/packages/9d/2c/930b0e7a2f1029bbc193443c7bc4dc2a46fedb0203c8793dcd97081f1520/grpcio-1.74.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:176d60a5168d7948539def20b2a3adcce67d72454d9ae05969a2e73f3a0feee7", size = 6180664, upload-time = "2025-07-24T18:53:30.823Z" }, - { url = "https://files.pythonhosted.org/packages/db/d5/ff8a2442180ad0867717e670f5ec42bfd8d38b92158ad6bcd864e6d4b1ed/grpcio-1.74.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e759f9e8bc908aaae0412642afe5416c9f983a80499448fcc7fab8692ae044c3", size = 6301083, upload-time = "2025-07-24T18:53:32.454Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ba/b361d390451a37ca118e4ec7dccec690422e05bc85fba2ec72b06cefec9f/grpcio-1.74.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:9e7c4389771855a92934b2846bd807fc25a3dfa820fd912fe6bd8136026b2707", size = 6994132, upload-time = "2025-07-24T18:53:34.506Z" }, - { url = "https://files.pythonhosted.org/packages/3b/0c/3a5fa47d2437a44ced74141795ac0251bbddeae74bf81df3447edd767d27/grpcio-1.74.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cce634b10aeab37010449124814b05a62fb5f18928ca878f1bf4750d1f0c815b", size = 6489616, upload-time = "2025-07-24T18:53:36.217Z" }, - { url = "https://files.pythonhosted.org/packages/ae/95/ab64703b436d99dc5217228babc76047d60e9ad14df129e307b5fec81fd0/grpcio-1.74.0-cp312-cp312-win32.whl", hash = "sha256:885912559974df35d92219e2dc98f51a16a48395f37b92865ad45186f294096c", size = 3807083, upload-time = "2025-07-24T18:53:37.911Z" }, - { url = "https://files.pythonhosted.org/packages/84/59/900aa2445891fc47a33f7d2f76e00ca5d6ae6584b20d19af9c06fa09bf9a/grpcio-1.74.0-cp312-cp312-win_amd64.whl", hash = "sha256:42f8fee287427b94be63d916c90399ed310ed10aadbf9e2e5538b3e497d269bc", size = 4490123, upload-time = "2025-07-24T18:53:39.528Z" }, - { url = "https://files.pythonhosted.org/packages/d4/d8/1004a5f468715221450e66b051c839c2ce9a985aa3ee427422061fcbb6aa/grpcio-1.74.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:2bc2d7d8d184e2362b53905cb1708c84cb16354771c04b490485fa07ce3a1d89", size = 5449488, upload-time = "2025-07-24T18:53:41.174Z" }, - { url = "https://files.pythonhosted.org/packages/94/0e/33731a03f63740d7743dced423846c831d8e6da808fcd02821a4416df7fa/grpcio-1.74.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c14e803037e572c177ba54a3e090d6eb12efd795d49327c5ee2b3bddb836bf01", size = 10974059, upload-time = "2025-07-24T18:53:43.066Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c6/3d2c14d87771a421205bdca991467cfe473ee4c6a1231c1ede5248c62ab8/grpcio-1.74.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:f6ec94f0e50eb8fa1744a731088b966427575e40c2944a980049798b127a687e", size = 5945647, upload-time = "2025-07-24T18:53:45.269Z" }, - { url = "https://files.pythonhosted.org/packages/c5/83/5a354c8aaff58594eef7fffebae41a0f8995a6258bbc6809b800c33d4c13/grpcio-1.74.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:566b9395b90cc3d0d0c6404bc8572c7c18786ede549cdb540ae27b58afe0fb91", size = 6626101, upload-time = "2025-07-24T18:53:47.015Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ca/4fdc7bf59bf6994aa45cbd4ef1055cd65e2884de6113dbd49f75498ddb08/grpcio-1.74.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1ea6176d7dfd5b941ea01c2ec34de9531ba494d541fe2057c904e601879f249", size = 6182562, upload-time = "2025-07-24T18:53:48.967Z" }, - { url = "https://files.pythonhosted.org/packages/fd/48/2869e5b2c1922583686f7ae674937986807c2f676d08be70d0a541316270/grpcio-1.74.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:64229c1e9cea079420527fa8ac45d80fc1e8d3f94deaa35643c381fa8d98f362", size = 6303425, upload-time = "2025-07-24T18:53:50.847Z" }, - { url = "https://files.pythonhosted.org/packages/a6/0e/bac93147b9a164f759497bc6913e74af1cb632c733c7af62c0336782bd38/grpcio-1.74.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:0f87bddd6e27fc776aacf7ebfec367b6d49cad0455123951e4488ea99d9b9b8f", size = 6996533, upload-time = "2025-07-24T18:53:52.747Z" }, - { url = "https://files.pythonhosted.org/packages/84/35/9f6b2503c1fd86d068b46818bbd7329db26a87cdd8c01e0d1a9abea1104c/grpcio-1.74.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3b03d8f2a07f0fea8c8f74deb59f8352b770e3900d143b3d1475effcb08eec20", size = 6491489, upload-time = "2025-07-24T18:53:55.06Z" }, - { url = "https://files.pythonhosted.org/packages/75/33/a04e99be2a82c4cbc4039eb3a76f6c3632932b9d5d295221389d10ac9ca7/grpcio-1.74.0-cp313-cp313-win32.whl", hash = "sha256:b6a73b2ba83e663b2480a90b82fdae6a7aa6427f62bf43b29912c0cfd1aa2bfa", size = 3805811, upload-time = "2025-07-24T18:53:56.798Z" }, - { url = "https://files.pythonhosted.org/packages/34/80/de3eb55eb581815342d097214bed4c59e806b05f1b3110df03b2280d6dfd/grpcio-1.74.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd3c71aeee838299c5887230b8a1822795325ddfea635edd82954c1eaa831e24", size = 4489214, upload-time = "2025-07-24T18:53:59.771Z" }, -] - -[[package]] -name = "grpcio-status" -version = "1.71.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "grpcio" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fd/d1/b6e9877fedae3add1afdeae1f89d1927d296da9cf977eca0eb08fb8a460e/grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50", size = 13677, upload-time = "2025-06-28T04:24:05.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/67/58/317b0134129b556a93a3b0afe00ee675b5657f0155509e22fcb853bafe2d/grpcio_status-1.71.2-py3-none-any.whl", hash = "sha256:803c98cb6a8b7dc6dbb785b1111aed739f241ab5e9da0bba96888aa74704cfd3", size = 14424, upload-time = "2025-06-28T04:23:42.136Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -834,10 +679,11 @@ dependencies = [ { name = "alembic" }, { name = "fastapi", extra = ["standard"] }, { name = "fastapi-pagination" }, - { name = "google-generativeai" }, + { name = "google-genai" }, { name = "greenlet" }, + { name = "groq" }, { name = "httpx" }, - { name = "mirascope", extra = ["anthropic", "google", "groq", "langfuse"] }, + { name = "langfuse" }, { name = "nanoid" }, { name = "openai" }, { name = "pdfplumber" }, @@ -850,6 +696,7 @@ dependencies = [ { name = "rich" }, { name = "sentry-sdk", extra = ["anthropic", "fastapi", "sqlalchemy"] }, { name = "sqlalchemy" }, + { name = "tenacity" }, { name = "tiktoken" }, { name = "typing-extensions" }, ] @@ -874,10 +721,11 @@ requires-dist = [ { name = "alembic", specifier = ">=1.14.0" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.111.0" }, { name = "fastapi-pagination", specifier = ">=0.12.24" }, - { name = "google-generativeai", specifier = ">=0.8.5" }, + { name = "google-genai", specifier = ">=1.32.0" }, { name = "greenlet", specifier = ">=3.0.3" }, + { name = "groq", specifier = ">=0.31.0" }, { name = "httpx", specifier = ">=0.27.0" }, - { name = "mirascope", extras = ["anthropic", "google", "groq", "langfuse"], specifier = ">=1.25.5" }, + { name = "langfuse", specifier = ">=3.3.2" }, { name = "nanoid", specifier = ">=2.0.0" }, { name = "openai", specifier = ">=1.99.7" }, { name = "pdfplumber", specifier = ">=0.11.7" }, @@ -890,6 +738,7 @@ requires-dist = [ { name = "rich", specifier = ">=13.7.1" }, { name = "sentry-sdk", extras = ["anthropic", "fastapi", "sqlalchemy"], specifier = ">=2.3.1" }, { name = "sqlalchemy", specifier = ">=2.0.30" }, + { name = "tenacity", specifier = ">=9.1.2" }, { name = "tiktoken", specifier = ">=0.9.0" }, { name = "typing-extensions", specifier = ">=4.11.0" }, ] @@ -964,18 +813,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] -[[package]] -name = "httplib2" -version = "0.22.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyparsing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/ad/2371116b22d616c194aa25ec410c9c6c37f23599dcd590502b74db197584/httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81", size = 351116, upload-time = "2023-03-21T22:29:37.214Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/6c/d2fbdaaa5959339d53ba38e94c123e4e84b8fbc4b84beb0e70d7c1608486/httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc", size = 96854, upload-time = "2023-03-21T22:29:35.683Z" }, -] - [[package]] name = "httptools" version = "0.6.4" @@ -1045,6 +882,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -1157,21 +1006,22 @@ wheels = [ [[package]] name = "langfuse" -version = "2.60.9" +version = "3.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, { name = "backoff" }, { name = "httpx" }, - { name = "idna" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, { name = "packaging" }, { name = "pydantic" }, { name = "requests" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/1a/2443e3715767f1bf9d8cf32d74ac59cfb60e1d9b84e99df13fd656639eb3/langfuse-2.60.9.tar.gz", hash = "sha256:040753346d7df4a0be6967dfc7efe3de313fee362524fe2f801867fcbbca3c98", size = 152684, upload-time = "2025-06-29T09:39:27.628Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/91/b92566f4fd73b607136bcd3ed011af98a571dc4c0f60b95fffb768d3cb5c/langfuse-3.3.2.tar.gz", hash = "sha256:4b029fed675b2b631b96da157459c285930420139667f25a6ee71ed3b3c5a71c", size = 164001, upload-time = "2025-08-27T15:55:11.716Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/50/3aa93fc284ba5f81dcdd00b6414caee338fd45d77fa4959c3e4f838cebc6/langfuse-2.60.9-py3-none-any.whl", hash = "sha256:e4291a66bc579c66d7652da5603ca7f0409536700d7b812e396780b5d9a0685d", size = 275543, upload-time = "2025-06-29T09:39:26.234Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ed/c4e914c5362740a40b12eaa8f90205cc05af81bacf983f3e89bf26b1c3cf/langfuse-3.3.2-py3-none-any.whl", hash = "sha256:0a5871501720c362183fbf2970c895fb0d46f4ed121a5f95a1874f9af081cfdd", size = 317629, upload-time = "2025-08-27T15:55:09.435Z" }, ] [[package]] @@ -1265,37 +1115,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "mirascope" -version = "1.25.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docstring-parser" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/72/bf/7b2441d8740fe82fdd201bcdefacff300ac9f61245169d74d2ebfa607a25/mirascope-1.25.5.tar.gz", hash = "sha256:bcc2734e4f83d6a8d66c7fb1660a12285418ea8f4d52e8f341b8e47ee54c6667", size = 627330, upload-time = "2025-08-05T22:06:41.809Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/2a/b5d57fcdaa7253cbfb6a2b3100d1e08a2133cf9f44964f8774541d09c36e/mirascope-1.25.5-py3-none-any.whl", hash = "sha256:b7f383ff6f4eeecd80f172fae51718103a473f8213859249014a65d30959226a", size = 373303, upload-time = "2025-08-05T22:06:40.077Z" }, -] - -[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" @@ -1497,6 +1316,88 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/2d/a41c49550a69374111647be22b587e3311a6fc31fc7370e14448e78018bc/openai-1.99.7-py3-none-any.whl", hash = "sha256:ef4165cc4f8872dd4a967d109f12b0b9c98a1e20ae05940c28701729c2883891", size = 786809, upload-time = "2025-08-11T15:13:08.537Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/d2/c782c88b8afbf961d6972428821c302bd1e9e7bc361352172f0ca31296e2/opentelemetry_api-1.36.0.tar.gz", hash = "sha256:9a72572b9c416d004d492cbc6e61962c0501eaf945ece9b5a0f56597d8348aa0", size = 64780, upload-time = "2025-07-29T15:12:06.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/ee/6b08dde0a022c463b88f55ae81149584b125a42183407dc1045c486cc870/opentelemetry_api-1.36.0-py3-none-any.whl", hash = "sha256:02f20bcacf666e1333b6b1f04e647dc1d5111f86b8e510238fcc56d7762cda8c", size = 65564, upload-time = "2025-07-29T15:11:47.998Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/da/7747e57eb341c59886052d733072bc878424bf20f1d8cf203d508bbece5b/opentelemetry_exporter_otlp_proto_common-1.36.0.tar.gz", hash = "sha256:6c496ccbcbe26b04653cecadd92f73659b814c6e3579af157d8716e5f9f25cbf", size = 20302, upload-time = "2025-07-29T15:12:07.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/ed/22290dca7db78eb32e0101738366b5bbda00d0407f00feffb9bf8c3fdf87/opentelemetry_exporter_otlp_proto_common-1.36.0-py3-none-any.whl", hash = "sha256:0fc002a6ed63eac235ada9aa7056e5492e9a71728214a61745f6ad04b923f840", size = 18349, upload-time = "2025-07-29T15:11:51.327Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/85/6632e7e5700ba1ce5b8a065315f92c1e6d787ccc4fb2bdab15139eaefc82/opentelemetry_exporter_otlp_proto_http-1.36.0.tar.gz", hash = "sha256:dd3637f72f774b9fc9608ab1ac479f8b44d09b6fb5b2f3df68a24ad1da7d356e", size = 16213, upload-time = "2025-07-29T15:12:08.932Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/41/a680d38b34f8f5ddbd78ed9f0042e1cc712d58ec7531924d71cb1e6c629d/opentelemetry_exporter_otlp_proto_http-1.36.0-py3-none-any.whl", hash = "sha256:3d769f68e2267e7abe4527f70deb6f598f40be3ea34c6adc35789bea94a32902", size = 18752, upload-time = "2025-07-29T15:11:53.164Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/02/f6556142301d136e3b7e95ab8ea6a5d9dc28d879a99f3dd673b5f97dca06/opentelemetry_proto-1.36.0.tar.gz", hash = "sha256:0f10b3c72f74c91e0764a5ec88fd8f1c368ea5d9c64639fb455e2854ef87dd2f", size = 46152, upload-time = "2025-07-29T15:12:15.717Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/57/3361e06136225be8180e879199caea520f38026f8071366241ac458beb8d/opentelemetry_proto-1.36.0-py3-none-any.whl", hash = "sha256:151b3bf73a09f94afc658497cf77d45a565606f62ce0c17acb08cd9937ca206e", size = 72537, upload-time = "2025-07-29T15:12:02.243Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.36.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/85/8567a966b85a2d3f971c4d42f781c305b2b91c043724fa08fd37d158e9dc/opentelemetry_sdk-1.36.0.tar.gz", hash = "sha256:19c8c81599f51b71670661ff7495c905d8fdf6976e41622d5245b791b06fa581", size = 162557, upload-time = "2025-07-29T15:12:16.76Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/59/7bed362ad1137ba5886dac8439e84cd2df6d087be7c09574ece47ae9b22c/opentelemetry_sdk-1.36.0-py3-none-any.whl", hash = "sha256:19fe048b42e98c5c1ffe85b569b7073576ad4ce0bcb6e9b4c6a39e890a6c45fb", size = 119995, upload-time = "2025-07-29T15:12:03.181Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.57b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/31/67dfa252ee88476a29200b0255bda8dfc2cf07b56ad66dc9a6221f7dc787/opentelemetry_semantic_conventions-0.57b0.tar.gz", hash = "sha256:609a4a79c7891b4620d64c7aac6898f872d790d75f22019913a660756f27ff32", size = 124225, upload-time = "2025-07-29T15:12:17.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/75/7d591371c6c39c73de5ce5da5a2cc7b72d1d1cd3f8f4638f553c01c37b11/opentelemetry_semantic_conventions-0.57b0-py3-none-any.whl", hash = "sha256:757f7e76293294f124c827e514c2a3144f191ef175b069ce8d1211e1e38e9e78", size = 201627, upload-time = "2025-07-29T15:12:04.174Z" }, +] + [[package]] name = "packaging" version = "24.2" @@ -1639,18 +1540,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, ] -[[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, upload-time = "2025-03-10T15:54:38.843Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66", size = 50163, upload-time = "2025-03-10T15:54:37.335Z" }, -] - [[package]] name = "protobuf" version = "5.29.5" @@ -1927,15 +1816,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, ] -[[package]] -name = "pyparsing" -version = "3.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608, upload-time = "2025-03-25T05:01:28.114Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload-time = "2025-03-25T05:01:24.908Z" }, -] - [[package]] name = "pypdfium2" version = "4.30.0" @@ -2438,11 +2318,11 @@ wheels = [ [[package]] name = "tenacity" -version = "8.5.0" +version = "9.1.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/4d/6a19536c50b849338fcbe9290d562b52cbdcf30d8963d3588a68a4107df1/tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78", size = 47309, upload-time = "2024-07-05T07:25:31.836Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165, upload-time = "2024-07-05T07:25:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" }, ] [[package]] @@ -2577,15 +2457,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, ] -[[package]] -name = "uritemplate" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, -] - [[package]] name = "urllib3" version = "2.5.0" @@ -2827,64 +2698,78 @@ wheels = [ [[package]] name = "wrapt" -version = "1.17.2" +version = "1.17.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531, upload-time = "2025-01-14T10:35:45.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/d1/1daec934997e8b160040c78d7b31789f19b122110a75eca3d4e8da0049e1/wrapt-1.17.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3d57c572081fed831ad2d26fd430d565b76aa277ed1d30ff4d40670b1c0dd984", size = 53307, upload-time = "2025-01-14T10:33:13.616Z" }, - { url = "https://files.pythonhosted.org/packages/1b/7b/13369d42651b809389c1a7153baa01d9700430576c81a2f5c5e460df0ed9/wrapt-1.17.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5e251054542ae57ac7f3fba5d10bfff615b6c2fb09abeb37d2f1463f841ae22", size = 38486, upload-time = "2025-01-14T10:33:15.947Z" }, - { url = "https://files.pythonhosted.org/packages/62/bf/e0105016f907c30b4bd9e377867c48c34dc9c6c0c104556c9c9126bd89ed/wrapt-1.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80dd7db6a7cb57ffbc279c4394246414ec99537ae81ffd702443335a61dbf3a7", size = 38777, upload-time = "2025-01-14T10:33:17.462Z" }, - { url = "https://files.pythonhosted.org/packages/27/70/0f6e0679845cbf8b165e027d43402a55494779295c4b08414097b258ac87/wrapt-1.17.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a6e821770cf99cc586d33833b2ff32faebdbe886bd6322395606cf55153246c", size = 83314, upload-time = "2025-01-14T10:33:21.282Z" }, - { url = "https://files.pythonhosted.org/packages/0f/77/0576d841bf84af8579124a93d216f55d6f74374e4445264cb378a6ed33eb/wrapt-1.17.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b60fb58b90c6d63779cb0c0c54eeb38941bae3ecf7a73c764c52c88c2dcb9d72", size = 74947, upload-time = "2025-01-14T10:33:24.414Z" }, - { url = "https://files.pythonhosted.org/packages/90/ec/00759565518f268ed707dcc40f7eeec38637d46b098a1f5143bff488fe97/wrapt-1.17.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b870b5df5b71d8c3359d21be8f0d6c485fa0ebdb6477dda51a1ea54a9b558061", size = 82778, upload-time = "2025-01-14T10:33:26.152Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5a/7cffd26b1c607b0b0c8a9ca9d75757ad7620c9c0a9b4a25d3f8a1480fafc/wrapt-1.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4011d137b9955791f9084749cba9a367c68d50ab8d11d64c50ba1688c9b457f2", size = 81716, upload-time = "2025-01-14T10:33:27.372Z" }, - { url = "https://files.pythonhosted.org/packages/7e/09/dccf68fa98e862df7e6a60a61d43d644b7d095a5fc36dbb591bbd4a1c7b2/wrapt-1.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1473400e5b2733e58b396a04eb7f35f541e1fb976d0c0724d0223dd607e0f74c", size = 74548, upload-time = "2025-01-14T10:33:28.52Z" }, - { url = "https://files.pythonhosted.org/packages/b7/8e/067021fa3c8814952c5e228d916963c1115b983e21393289de15128e867e/wrapt-1.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3cedbfa9c940fdad3e6e941db7138e26ce8aad38ab5fe9dcfadfed9db7a54e62", size = 81334, upload-time = "2025-01-14T10:33:29.643Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0d/9d4b5219ae4393f718699ca1c05f5ebc0c40d076f7e65fd48f5f693294fb/wrapt-1.17.2-cp310-cp310-win32.whl", hash = "sha256:582530701bff1dec6779efa00c516496968edd851fba224fbd86e46cc6b73563", size = 36427, upload-time = "2025-01-14T10:33:30.832Z" }, - { url = "https://files.pythonhosted.org/packages/72/6a/c5a83e8f61aec1e1aeef939807602fb880e5872371e95df2137142f5c58e/wrapt-1.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:58705da316756681ad3c9c73fd15499aa4d8c69f9fd38dc8a35e06c12468582f", size = 38774, upload-time = "2025-01-14T10:33:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/a2aab2cbc7a665efab072344a8949a71081eed1d2f451f7f7d2b966594a2/wrapt-1.17.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ff04ef6eec3eee8a5efef2401495967a916feaa353643defcc03fc74fe213b58", size = 53308, upload-time = "2025-01-14T10:33:33.992Z" }, - { url = "https://files.pythonhosted.org/packages/50/ff/149aba8365fdacef52b31a258c4dc1c57c79759c335eff0b3316a2664a64/wrapt-1.17.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4db983e7bca53819efdbd64590ee96c9213894272c776966ca6306b73e4affda", size = 38488, upload-time = "2025-01-14T10:33:35.264Z" }, - { url = "https://files.pythonhosted.org/packages/65/46/5a917ce85b5c3b490d35c02bf71aedaa9f2f63f2d15d9949cc4ba56e8ba9/wrapt-1.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9abc77a4ce4c6f2a3168ff34b1da9b0f311a8f1cfd694ec96b0603dff1c79438", size = 38776, upload-time = "2025-01-14T10:33:38.28Z" }, - { url = "https://files.pythonhosted.org/packages/ca/74/336c918d2915a4943501c77566db41d1bd6e9f4dbc317f356b9a244dfe83/wrapt-1.17.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b929ac182f5ace000d459c59c2c9c33047e20e935f8e39371fa6e3b85d56f4a", size = 83776, upload-time = "2025-01-14T10:33:40.678Z" }, - { url = "https://files.pythonhosted.org/packages/09/99/c0c844a5ccde0fe5761d4305485297f91d67cf2a1a824c5f282e661ec7ff/wrapt-1.17.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f09b286faeff3c750a879d336fb6d8713206fc97af3adc14def0cdd349df6000", size = 75420, upload-time = "2025-01-14T10:33:41.868Z" }, - { url = "https://files.pythonhosted.org/packages/b4/b0/9fc566b0fe08b282c850063591a756057c3247b2362b9286429ec5bf1721/wrapt-1.17.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a7ed2d9d039bd41e889f6fb9364554052ca21ce823580f6a07c4ec245c1f5d6", size = 83199, upload-time = "2025-01-14T10:33:43.598Z" }, - { url = "https://files.pythonhosted.org/packages/9d/4b/71996e62d543b0a0bd95dda485219856def3347e3e9380cc0d6cf10cfb2f/wrapt-1.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:129a150f5c445165ff941fc02ee27df65940fcb8a22a61828b1853c98763a64b", size = 82307, upload-time = "2025-01-14T10:33:48.499Z" }, - { url = "https://files.pythonhosted.org/packages/39/35/0282c0d8789c0dc9bcc738911776c762a701f95cfe113fb8f0b40e45c2b9/wrapt-1.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fb5699e4464afe5c7e65fa51d4f99e0b2eadcc176e4aa33600a3df7801d6662", size = 75025, upload-time = "2025-01-14T10:33:51.191Z" }, - { url = "https://files.pythonhosted.org/packages/4f/6d/90c9fd2c3c6fee181feecb620d95105370198b6b98a0770cba090441a828/wrapt-1.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a2bce789a5ea90e51a02dfcc39e31b7f1e662bc3317979aa7e5538e3a034f72", size = 81879, upload-time = "2025-01-14T10:33:52.328Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fa/9fb6e594f2ce03ef03eddbdb5f4f90acb1452221a5351116c7c4708ac865/wrapt-1.17.2-cp311-cp311-win32.whl", hash = "sha256:4afd5814270fdf6380616b321fd31435a462019d834f83c8611a0ce7484c7317", size = 36419, upload-time = "2025-01-14T10:33:53.551Z" }, - { url = "https://files.pythonhosted.org/packages/47/f8/fb1773491a253cbc123c5d5dc15c86041f746ed30416535f2a8df1f4a392/wrapt-1.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:acc130bc0375999da18e3d19e5a86403667ac0c4042a094fefb7eec8ebac7cf3", size = 38773, upload-time = "2025-01-14T10:33:56.323Z" }, - { url = "https://files.pythonhosted.org/packages/a1/bd/ab55f849fd1f9a58ed7ea47f5559ff09741b25f00c191231f9f059c83949/wrapt-1.17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d5e2439eecc762cd85e7bd37161d4714aa03a33c5ba884e26c81559817ca0925", size = 53799, upload-time = "2025-01-14T10:33:57.4Z" }, - { url = "https://files.pythonhosted.org/packages/53/18/75ddc64c3f63988f5a1d7e10fb204ffe5762bc663f8023f18ecaf31a332e/wrapt-1.17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fc7cb4c1c744f8c05cd5f9438a3caa6ab94ce8344e952d7c45a8ed59dd88392", size = 38821, upload-time = "2025-01-14T10:33:59.334Z" }, - { url = "https://files.pythonhosted.org/packages/48/2a/97928387d6ed1c1ebbfd4efc4133a0633546bec8481a2dd5ec961313a1c7/wrapt-1.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8fdbdb757d5390f7c675e558fd3186d590973244fab0c5fe63d373ade3e99d40", size = 38919, upload-time = "2025-01-14T10:34:04.093Z" }, - { url = "https://files.pythonhosted.org/packages/73/54/3bfe5a1febbbccb7a2f77de47b989c0b85ed3a6a41614b104204a788c20e/wrapt-1.17.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bb1d0dbf99411f3d871deb6faa9aabb9d4e744d67dcaaa05399af89d847a91d", size = 88721, upload-time = "2025-01-14T10:34:07.163Z" }, - { url = "https://files.pythonhosted.org/packages/25/cb/7262bc1b0300b4b64af50c2720ef958c2c1917525238d661c3e9a2b71b7b/wrapt-1.17.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d18a4865f46b8579d44e4fe1e2bcbc6472ad83d98e22a26c963d46e4c125ef0b", size = 80899, upload-time = "2025-01-14T10:34:09.82Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5a/04cde32b07a7431d4ed0553a76fdb7a61270e78c5fd5a603e190ac389f14/wrapt-1.17.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc570b5f14a79734437cb7b0500376b6b791153314986074486e0b0fa8d71d98", size = 89222, upload-time = "2025-01-14T10:34:11.258Z" }, - { url = "https://files.pythonhosted.org/packages/09/28/2e45a4f4771fcfb109e244d5dbe54259e970362a311b67a965555ba65026/wrapt-1.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6d9187b01bebc3875bac9b087948a2bccefe464a7d8f627cf6e48b1bbae30f82", size = 86707, upload-time = "2025-01-14T10:34:12.49Z" }, - { url = "https://files.pythonhosted.org/packages/c6/d2/dcb56bf5f32fcd4bd9aacc77b50a539abdd5b6536872413fd3f428b21bed/wrapt-1.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9e8659775f1adf02eb1e6f109751268e493c73716ca5761f8acb695e52a756ae", size = 79685, upload-time = "2025-01-14T10:34:15.043Z" }, - { url = "https://files.pythonhosted.org/packages/80/4e/eb8b353e36711347893f502ce91c770b0b0929f8f0bed2670a6856e667a9/wrapt-1.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e8b2816ebef96d83657b56306152a93909a83f23994f4b30ad4573b00bd11bb9", size = 87567, upload-time = "2025-01-14T10:34:16.563Z" }, - { url = "https://files.pythonhosted.org/packages/17/27/4fe749a54e7fae6e7146f1c7d914d28ef599dacd4416566c055564080fe2/wrapt-1.17.2-cp312-cp312-win32.whl", hash = "sha256:468090021f391fe0056ad3e807e3d9034e0fd01adcd3bdfba977b6fdf4213ea9", size = 36672, upload-time = "2025-01-14T10:34:17.727Z" }, - { url = "https://files.pythonhosted.org/packages/15/06/1dbf478ea45c03e78a6a8c4be4fdc3c3bddea5c8de8a93bc971415e47f0f/wrapt-1.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:ec89ed91f2fa8e3f52ae53cd3cf640d6feff92ba90d62236a81e4e563ac0e991", size = 38865, upload-time = "2025-01-14T10:34:19.577Z" }, - { url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800, upload-time = "2025-01-14T10:34:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824, upload-time = "2025-01-14T10:34:22.999Z" }, - { url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920, upload-time = "2025-01-14T10:34:25.386Z" }, - { url = "https://files.pythonhosted.org/packages/3b/24/11c4510de906d77e0cfb5197f1b1445d4fec42c9a39ea853d482698ac681/wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", size = 88690, upload-time = "2025-01-14T10:34:28.058Z" }, - { url = "https://files.pythonhosted.org/packages/71/d7/cfcf842291267bf455b3e266c0c29dcb675b5540ee8b50ba1699abf3af45/wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", size = 80861, upload-time = "2025-01-14T10:34:29.167Z" }, - { url = "https://files.pythonhosted.org/packages/d5/66/5d973e9f3e7370fd686fb47a9af3319418ed925c27d72ce16b791231576d/wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", size = 89174, upload-time = "2025-01-14T10:34:31.702Z" }, - { url = "https://files.pythonhosted.org/packages/a7/d3/8e17bb70f6ae25dabc1aaf990f86824e4fd98ee9cadf197054e068500d27/wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", size = 86721, upload-time = "2025-01-14T10:34:32.91Z" }, - { url = "https://files.pythonhosted.org/packages/6f/54/f170dfb278fe1c30d0ff864513cff526d624ab8de3254b20abb9cffedc24/wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", size = 79763, upload-time = "2025-01-14T10:34:34.903Z" }, - { url = "https://files.pythonhosted.org/packages/4a/98/de07243751f1c4a9b15c76019250210dd3486ce098c3d80d5f729cba029c/wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", size = 87585, upload-time = "2025-01-14T10:34:36.13Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f0/13925f4bd6548013038cdeb11ee2cbd4e37c30f8bfd5db9e5a2a370d6e20/wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", size = 36676, upload-time = "2025-01-14T10:34:37.962Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ae/743f16ef8c2e3628df3ddfd652b7d4c555d12c84b53f3d8218498f4ade9b/wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", size = 38871, upload-time = "2025-01-14T10:34:39.13Z" }, - { url = "https://files.pythonhosted.org/packages/3d/bc/30f903f891a82d402ffb5fda27ec1d621cc97cb74c16fea0b6141f1d4e87/wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", size = 56312, upload-time = "2025-01-14T10:34:40.604Z" }, - { url = "https://files.pythonhosted.org/packages/8a/04/c97273eb491b5f1c918857cd26f314b74fc9b29224521f5b83f872253725/wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", size = 40062, upload-time = "2025-01-14T10:34:45.011Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ca/3b7afa1eae3a9e7fefe499db9b96813f41828b9fdb016ee836c4c379dadb/wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", size = 40155, upload-time = "2025-01-14T10:34:47.25Z" }, - { url = "https://files.pythonhosted.org/packages/89/be/7c1baed43290775cb9030c774bc53c860db140397047cc49aedaf0a15477/wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", size = 113471, upload-time = "2025-01-14T10:34:50.934Z" }, - { url = "https://files.pythonhosted.org/packages/32/98/4ed894cf012b6d6aae5f5cc974006bdeb92f0241775addad3f8cd6ab71c8/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", size = 101208, upload-time = "2025-01-14T10:34:52.297Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fd/0c30f2301ca94e655e5e057012e83284ce8c545df7661a78d8bfca2fac7a/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", size = 109339, upload-time = "2025-01-14T10:34:53.489Z" }, - { url = "https://files.pythonhosted.org/packages/75/56/05d000de894c4cfcb84bcd6b1df6214297b8089a7bd324c21a4765e49b14/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", size = 110232, upload-time = "2025-01-14T10:34:55.327Z" }, - { url = "https://files.pythonhosted.org/packages/53/f8/c3f6b2cf9b9277fb0813418e1503e68414cd036b3b099c823379c9575e6d/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", size = 100476, upload-time = "2025-01-14T10:34:58.055Z" }, - { 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, upload-time = "2025-01-14T10:34:59.3Z" }, - { url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986, upload-time = "2025-01-14T10:35:00.498Z" }, - { url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750, upload-time = "2025-01-14T10:35:03.378Z" }, - { url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594, upload-time = "2025-01-14T10:35:44.018Z" }, + { url = "https://files.pythonhosted.org/packages/3f/23/bb82321b86411eb51e5a5db3fb8f8032fd30bd7c2d74bfe936136b2fa1d6/wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04", size = 53482, upload-time = "2025-08-12T05:51:44.467Z" }, + { url = "https://files.pythonhosted.org/packages/45/69/f3c47642b79485a30a59c63f6d739ed779fb4cc8323205d047d741d55220/wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2", size = 38676, upload-time = "2025-08-12T05:51:32.636Z" }, + { url = "https://files.pythonhosted.org/packages/d1/71/e7e7f5670c1eafd9e990438e69d8fb46fa91a50785332e06b560c869454f/wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c", size = 38957, upload-time = "2025-08-12T05:51:54.655Z" }, + { url = "https://files.pythonhosted.org/packages/de/17/9f8f86755c191d6779d7ddead1a53c7a8aa18bccb7cea8e7e72dfa6a8a09/wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775", size = 81975, upload-time = "2025-08-12T05:52:30.109Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/dd576273491f9f43dd09fce517f6c2ce6eb4fe21681726068db0d0467096/wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd", size = 83149, upload-time = "2025-08-12T05:52:09.316Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c4/5eb4ce0d4814521fee7aa806264bf7a114e748ad05110441cd5b8a5c744b/wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05", size = 82209, upload-time = "2025-08-12T05:52:10.331Z" }, + { url = "https://files.pythonhosted.org/packages/31/4b/819e9e0eb5c8dc86f60dfc42aa4e2c0d6c3db8732bce93cc752e604bb5f5/wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418", size = 81551, upload-time = "2025-08-12T05:52:31.137Z" }, + { url = "https://files.pythonhosted.org/packages/f8/83/ed6baf89ba3a56694700139698cf703aac9f0f9eb03dab92f57551bd5385/wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390", size = 36464, upload-time = "2025-08-12T05:53:01.204Z" }, + { url = "https://files.pythonhosted.org/packages/2f/90/ee61d36862340ad7e9d15a02529df6b948676b9a5829fd5e16640156627d/wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6", size = 38748, upload-time = "2025-08-12T05:53:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c3/cefe0bd330d389c9983ced15d326f45373f4073c9f4a8c2f99b50bfea329/wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18", size = 36810, upload-time = "2025-08-12T05:52:51.906Z" }, + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, ]