diff --git a/agents-core/pyproject.toml b/agents-core/pyproject.toml index 254c2398f..4bfc1954f 100644 --- a/agents-core/pyproject.toml +++ b/agents-core/pyproject.toml @@ -71,6 +71,7 @@ decart = ["vision-agents-plugins-decart"] twilio = ["vision-agents-plugins-twilio"] turbopuffer = ["vision-agents-plugins-turbopuffer"] mistral = ["vision-agents-plugins-mistral"] +redis = ["redis[hiredis]>=5.0.0"] all-plugins = [ "vision-agents-plugins-anthropic", diff --git a/agents-core/vision_agents/core/__init__.py b/agents-core/vision_agents/core/__init__.py index a5bd009ef..3f8ee6ae0 100644 --- a/agents-core/vision_agents/core/__init__.py +++ b/agents-core/vision_agents/core/__init__.py @@ -1,13 +1,33 @@ from vision_agents.core.agents import Agent from vision_agents.core.agents.agent_launcher import AgentLauncher, AgentSession +from vision_agents.core.agents.session_registry import ( + InMemorySessionKVStore, + SessionInfo, + SessionKVStore, + SessionRegistry, +) from vision_agents.core.edge.types import User from vision_agents.core.runner import Runner, ServeOptions __all__ = [ "Agent", - "User", "AgentLauncher", "AgentSession", + "InMemorySessionKVStore", "Runner", "ServeOptions", + "SessionInfo", + "SessionKVStore", + "SessionRegistry", + "User", ] + +try: + from vision_agents.core.agents.session_registry import RedisSessionKVStore + + __all__ += ["RedisSessionKVStore"] +except ModuleNotFoundError as exc: + # Only swallow a missing `redis` package; re-raise anything else + # so real import errors in redis_store.py surface immediately. + if not exc.name or not exc.name.startswith("redis"): + raise diff --git a/agents-core/vision_agents/core/agents/__init__.py b/agents-core/vision_agents/core/agents/__init__.py index c29fef8d0..feeb7ea58 100644 --- a/agents-core/vision_agents/core/agents/__init__.py +++ b/agents-core/vision_agents/core/agents/__init__.py @@ -8,10 +8,18 @@ from .conversation import Conversation as Conversation from .agent_launcher import AgentLauncher as AgentLauncher from .agent_types import AgentOptions as AgentOptions +from .session_registry import InMemorySessionKVStore as InMemorySessionKVStore +from .session_registry import SessionInfo as SessionInfo +from .session_registry import SessionKVStore as SessionKVStore +from .session_registry import SessionRegistry as SessionRegistry __all__ = [ "Agent", - "Conversation", "AgentLauncher", "AgentOptions", + "Conversation", + "InMemorySessionKVStore", + "SessionInfo", + "SessionKVStore", + "SessionRegistry", ] diff --git a/agents-core/vision_agents/core/agents/agent_launcher.py b/agents-core/vision_agents/core/agents/agent_launcher.py index af4890c24..e081a48ef 100644 --- a/agents-core/vision_agents/core/agents/agent_launcher.py +++ b/agents-core/vision_agents/core/agents/agent_launcher.py @@ -1,5 +1,6 @@ import asyncio import logging +import re from dataclasses import dataclass from datetime import datetime, timezone from functools import partial @@ -14,13 +15,20 @@ from vision_agents.core.utils.utils import await_or_run, cancel_and_wait from vision_agents.core.warmup import Warmable, WarmupCache -from .exceptions import MaxConcurrentSessionsExceeded, MaxSessionsPerCallExceeded +from .exceptions import ( + InvalidCallId, + MaxConcurrentSessionsExceeded, + MaxSessionsPerCallExceeded, +) +from .session_registry import SessionInfo, SessionRegistry if TYPE_CHECKING: from .agents import Agent logger = logging.getLogger(__name__) +_VALID_CALL_ID = re.compile(r"^[a-z0-9_-]+$") + @dataclass class AgentSession: @@ -36,7 +44,6 @@ class AgentSession: call_id: str started_at: datetime task: asyncio.Task - created_by: Optional[Any] = None @property def finished(self) -> bool: @@ -76,11 +83,24 @@ def idle_for(self) -> float: # TODO: Rename to `AgentManager`. class AgentLauncher: - """ - Agent launcher that handles warmup and lifecycle management. + """Agent launcher that handles warmup and lifecycle management. The launcher ensures all components (LLM, TTS, STT, turn detection) are warmed up before the agent is launched. + + Public methods fall into two categories: + + **Local** — operate only on this node's in-memory session map + (``self._sessions``). These accept a bare ``session_id``: + ``get_session``, ``close_session``, ``launch``. + + **Registry** — read from / write to shared storage visible to all + nodes. These require ``(call_id, session_id)`` so the registry can + scope every operation to the owning call: + ``get_session_info``, ``request_close_session``. + + ``start_session`` touches both: it creates a local ``AgentSession`` + *and* registers it in the shared registry. """ def __init__( @@ -91,7 +111,8 @@ def __init__( max_concurrent_sessions: Optional[int] = None, max_sessions_per_call: Optional[int] = None, max_session_duration_seconds: Optional[float] = None, - cleanup_interval: float = 5.0, + maintenance_interval: float = 5.0, + registry: "SessionRegistry | None" = None, ): """ Initialize the agent launcher. @@ -108,8 +129,11 @@ def __init__( Default is None (unlimited). max_session_duration_seconds: Maximum duration in seconds for a session before it is automatically closed. Default is None (unlimited). - cleanup_interval: Interval in seconds between cleanup checks for idle + maintenance_interval: Interval in seconds between cleanup checks for idle or expired sessions. Default is 5.0 seconds. + registry: Optional SessionRegistry for multi-node session management. + When provided, sessions are registered in shared storage and heartbeats + are sent on every cleanup interval. """ self._create_agent = create_agent self._join_call = join_call @@ -134,15 +158,18 @@ def __init__( raise ValueError("agent_idle_timeout must be >= 0") self._agent_idle_timeout = agent_idle_timeout - if cleanup_interval <= 0: - raise ValueError("cleanup_interval must be > 0") - self._cleanup_interval: float = cleanup_interval + if maintenance_interval <= 0: + raise ValueError("maintenance_interval must be > 0") + self._maintenance_interval: float = maintenance_interval + + self._registry = registry or SessionRegistry() self._running = False - self._cleanup_task: Optional[asyncio.Task] = None + self._maintenance_task: Optional[asyncio.Task] = None self._warmed_up: bool = False self._sessions: dict[str, AgentSession] = {} - self._calls: dict[str, set[str]] = {} + # A set to keep the references for AgentSession cleanup tasks + self._session_cleanup_tasks: set[asyncio.Task] = set() async def start(self) -> None: """ @@ -159,7 +186,8 @@ async def start(self) -> None: logger.debug("Starting AgentLauncher") self._running = True await self.warmup() - self._cleanup_task = asyncio.create_task(self._cleanup_idle_sessions()) + await self._registry.start() + self._maintenance_task = asyncio.create_task(self._maintenance_loop()) logger.debug("AgentLauncher started") async def stop(self) -> None: @@ -171,8 +199,8 @@ async def stop(self) -> None: """ logger.debug("Stopping AgentLauncher") self._running = False - if self._cleanup_task: - await cancel_and_wait(self._cleanup_task) + if self._maintenance_task: + await cancel_and_wait(self._maintenance_task) coros = [cancel_and_wait(s.task) for s in self._sessions.values()] for result in asyncio.as_completed(coros): @@ -181,6 +209,11 @@ async def stop(self) -> None: except Exception as exc: logger.error(f"Failed to cancel the agent task: {exc}") + if self._session_cleanup_tasks: + await asyncio.gather(*self._session_cleanup_tasks, return_exceptions=True) + self._session_cleanup_tasks.clear() + + await self._registry.stop() logger.debug("AgentLauncher stopped") async def warmup(self) -> None: @@ -221,6 +254,10 @@ def ready(self) -> bool: """Return True if the launcher is warmed up and running.""" return self.warmed_up and self.running + @property + def registry(self) -> SessionRegistry: + return self._registry + async def launch(self, **kwargs) -> "Agent": """ Launch the agent. @@ -239,11 +276,10 @@ async def start_session( self, call_id: str, call_type: str = "default", - created_by: Optional[Any] = None, video_track_override_path: Optional[str] = None, ) -> AgentSession: """ - Start a new agent session for a call. + Start a new agent session for a call on this node. Creates a new agent, joins the specified call, and returns an AgentSession object to track the session. @@ -251,7 +287,6 @@ async def start_session( Args: call_id: Unique identifier for the call to join. call_type: Type of call. Default is "default". - created_by: Optional metadata about who/what created this session. video_track_override_path: Optional path to a video file to use instead of a live video track. @@ -259,11 +294,17 @@ async def start_session( An AgentSession object representing the new session. Raises: + InvalidCallId: If the call_id contains invalid characters. MaxConcurrentSessionsExceeded: If the maximum number of concurrent sessions has been reached. MaxSessionsPerCallExceeded: If the maximum number of sessions for this call_id has been reached. """ + if not _VALID_CALL_ID.fullmatch(call_id): + raise InvalidCallId( + f"Invalid call_id {call_id!r}: must contain only a-z, 0-9, _ and -" + ) + async with self._start_lock: if ( self._max_concurrent_sessions @@ -273,14 +314,12 @@ async def start_session( f"Reached maximum concurrent sessions of {self._max_concurrent_sessions}" ) - call_sessions_total = len(self._calls.get(call_id, set())) - if ( - self._max_sessions_per_call - and call_sessions_total >= self._max_sessions_per_call - ): - raise MaxSessionsPerCallExceeded( - f"Reached maximum sessions per call of {self._max_sessions_per_call}" - ) + if self._max_sessions_per_call: + call_sessions = await self._registry.get_for_call(call_id) + if len(call_sessions) >= self._max_sessions_per_call: + raise MaxSessionsPerCallExceeded( + f"Reached maximum sessions per call of {self._max_sessions_per_call}" + ) agent: "Agent" = await self.launch() if video_track_override_path: @@ -296,27 +335,40 @@ async def start_session( def _finalizer(session_id_: str, call_id_: str, *_): session_ = self._sessions.pop(session_id_, None) if session_ is not None: - call_sessions = self._calls.get(call_id_, set()) - if call_sessions: - call_sessions.discard(session_id_) + try: + t = asyncio.create_task( + self._remove_from_registry_safe(call_id_, session_id_) + ) + self._session_cleanup_tasks.add(t) + t.add_done_callback(self._session_cleanup_tasks.discard) + except RuntimeError: + logger.warning( + "Event loop is shutting down; cannot remove session %s from registry", + session_id_, + ) - task.add_done_callback(partial(_finalizer, agent.id, call_id)) session = AgentSession( agent=agent, task=task, started_at=datetime.now(timezone.utc), call_id=call_id, - created_by=created_by, ) self._sessions[agent.id] = session - self._calls.setdefault(call_id, set()).add(agent.id) + try: + await self._registry.register(call_id, session.id) + except Exception: + logger.exception(f"Failed to register session with id {session.id}") + await self.close_session(session.id) + raise + task.add_done_callback(partial(_finalizer, agent.id, call_id)) logger.info(f"Started agent session with id {session.id}") return session async def close_session(self, session_id: str, wait: bool = False) -> bool: - """ - Close session with id `session_id`. - Returns `True` if session was found and closed, `False` otherwise. + """Close a session running on this node (local + registry cleanup). + + Removes the session from the local map, cancels the agent task, + and deletes the corresponding registry entry. Args: session_id: session id @@ -324,35 +376,58 @@ async def close_session(self, session_id: str, wait: bool = False) -> bool: Otherwise, just cancel the task and return. Returns: - `True` if session was found and closed, `False` otherwise. + True if session was found and closed, False otherwise. """ session = self._sessions.pop(session_id, None) if session is None: - # The session is either closed or doesn't exist, exit early return False - call_sessions = self._calls.get(session.call_id) - if call_sessions: - call_sessions.discard(session.id) logger.info(f"Closing agent session with id {session.id}") if wait: await cancel_and_wait(session.task) else: session.task.cancel() + await self._registry.remove(session.call_id, session_id) return True def get_session(self, session_id: str) -> Optional[AgentSession]: - """ - Get a session by its ID. + """Get a session running on this node by its ID (local lookup only). Args: session_id: The session ID to look up. Returns: - The AgentSession if found, None otherwise. + The AgentSession if found on this node, None otherwise. """ return self._sessions.get(session_id) + async def request_close_session(self, call_id: str, session_id: str) -> None: + """Request closure of a session via the shared registry (any node). + + Sets a close flag so the owning node picks it up on its next + maintenance cycle. + + Args: + call_id: The call the session belongs to. + session_id: The session to close. + """ + await self._registry.request_close(call_id, session_id) + + async def get_session_info( + self, call_id: str, session_id: str + ) -> SessionInfo | None: + """Look up session info from the shared registry (any node).""" + return await self._registry.get(call_id, session_id) + + async def _remove_from_registry_safe(self, call_id: str, session_id: str) -> None: + """ + Remove a session from the shared registry (any node) logging the exception. + """ + try: + await self._registry.remove(call_id, session_id) + except Exception: + logger.exception("Failed to remove session %s from registry", session_id) + async def _warmup_agent(self, agent: "Agent") -> None: """ Go over the Agent's dependencies and trigger `.warmup()` on them. @@ -396,52 +471,72 @@ async def _warmup_agent(self, agent: "Agent") -> None: if warmup_tasks: await asyncio.gather(*warmup_tasks) - async def _cleanup_idle_sessions(self) -> None: - if not self._agent_idle_timeout and not self._max_session_duration_seconds: - return - max_session_duration_seconds = self._max_session_duration_seconds or float( - "inf" - ) - + async def _maintenance_loop(self) -> None: while self._running: - # Collect idle agents first to close them all at once - to_close = [] - for session in self._sessions.values(): - agent = session.agent - on_call_for = agent.on_call_for() - idle_for = agent.idle_for() - if 0 < self._agent_idle_timeout <= idle_for: - logger.info( - f'Closing session "{session.id}" with ' - f'user_id "{agent.agent_user.id}" after being ' - f"idle for {round(idle_for, 2)}s " - f"(idle timeout is {self._agent_idle_timeout}s)" - ) - to_close.append(agent) - elif on_call_for >= max_session_duration_seconds: - logger.info( - f'Closing session "{session.id}" with user_id "{agent.agent_user.id}" ' - f"after reaching the maximum session " - f"duration of {max_session_duration_seconds}s" - ) - to_close.append(agent) - - if to_close: - coros = [ - asyncio.shield(self.close_session(s.id, wait=False)) - for s in to_close - ] - result = await asyncio.shield( - asyncio.gather(*coros, return_exceptions=True) + await self._close_expired_sessions() + await self._refresh_active_sessions() + await asyncio.sleep(self._maintenance_interval) + + async def _close_expired_sessions(self) -> None: + """Close sessions that are idle, expired, or flagged for closure.""" + max_session_duration = self._max_session_duration_seconds or float("inf") + to_close: list["Agent"] = [] + + # Close the sessions that are either idle or exceeded max duration + for session in self._sessions.values(): + agent = session.agent + on_call_for = agent.on_call_for() + idle_for = agent.idle_for() + if 0 < self._agent_idle_timeout <= idle_for: + logger.info( + f'Closing session "{session.id}" with ' + f'user_id "{agent.agent_user.id}"; reason="exceeded idle timeout of {self._agent_idle_timeout}s"' ) - for agent, r in zip(to_close, result): - if isinstance(r, Exception): - logger.error( - f"Failed to close agent with user_id {agent.agent_user.id}", - exc_info=r, - ) + to_close.append(agent) + elif on_call_for >= max_session_duration: + logger.info( + f'Closing session "{session.id}" with user_id "{agent.agent_user.id}"; reason="exceeded maximum session duration of {max_session_duration}s"' + ) + to_close.append(agent) + + # Get the sessions requested to be deleted excluding already deleted + # or scheduled to be deleted + try: + sessions_map = {sid: s.call_id for sid, s in self._sessions.items()} + flagged = [ + session_ + for session_id in await self._registry.get_close_requests(sessions_map) + if (session_ := self._sessions.get(session_id)) is not None + and session_.agent not in to_close + ] + for session in flagged: + logger.info( + "Closing session %s due to registry close request", session.id + ) + to_close.append(session.agent) + except Exception: + logger.exception("Failed to check registry close requests") + + if to_close: + coros = [ + asyncio.shield(self.close_session(s.id, wait=False)) for s in to_close + ] + result = await asyncio.shield( + asyncio.gather(*coros, return_exceptions=True) + ) + for agent, r in zip(to_close, result): + if isinstance(r, Exception): + logger.exception( + f"Failed to close agent with user_id {agent.agent_user.id}", + ) - await asyncio.sleep(self._cleanup_interval) + async def _refresh_active_sessions(self) -> None: + """Send heartbeats for all active sessions to the registry.""" + try: + sessions_map = {sid: s.call_id for sid, s in self._sessions.items()} + await self._registry.refresh(sessions_map) + except Exception: + logger.exception("Failed to refresh sessions at the registry") async def __aenter__(self) -> "AgentLauncher": """Enter the async context manager, starting the launcher.""" diff --git a/agents-core/vision_agents/core/agents/exceptions.py b/agents-core/vision_agents/core/agents/exceptions.py index eb727dce3..d6550b610 100644 --- a/agents-core/vision_agents/core/agents/exceptions.py +++ b/agents-core/vision_agents/core/agents/exceptions.py @@ -6,3 +6,7 @@ class MaxConcurrentSessionsExceeded(SessionLimitExceeded): ... class MaxSessionsPerCallExceeded(SessionLimitExceeded): ... + + +class InvalidCallId(Exception): + pass diff --git a/agents-core/vision_agents/core/agents/session_registry/__init__.py b/agents-core/vision_agents/core/agents/session_registry/__init__.py new file mode 100644 index 000000000..e596005fc --- /dev/null +++ b/agents-core/vision_agents/core/agents/session_registry/__init__.py @@ -0,0 +1,21 @@ +from .in_memory_store import InMemorySessionKVStore as InMemorySessionKVStore +from .registry import SessionRegistry as SessionRegistry +from .store import SessionKVStore as SessionKVStore +from .types import SessionInfo as SessionInfo + +__all__ = [ + "InMemorySessionKVStore", + "SessionInfo", + "SessionKVStore", + "SessionRegistry", +] + +try: + from .redis_store import RedisSessionKVStore as RedisSessionKVStore + + __all__ += ["RedisSessionKVStore"] +except ModuleNotFoundError as exc: + # Only swallow a missing `redis` package; re-raise anything else + # so real import errors in redis_store.py surface immediately. + if not exc.name or not exc.name.startswith("redis"): + raise diff --git a/agents-core/vision_agents/core/agents/session_registry/in_memory_store.py b/agents-core/vision_agents/core/agents/session_registry/in_memory_store.py new file mode 100644 index 000000000..00ebc3727 --- /dev/null +++ b/agents-core/vision_agents/core/agents/session_registry/in_memory_store.py @@ -0,0 +1,125 @@ +import asyncio +import logging +import time + +from vision_agents.core.utils.utils import cancel_and_wait + +from .store import SessionKVStore + +logger = logging.getLogger(__name__) + + +class InMemorySessionKVStore(SessionKVStore): + """In-memory TTL key-value store. Single-node only. + + Useful for development, testing, and single-node deployments. + For multi-node, swap to a Redis or other networked backend. + + Expired keys are cleaned up both lazily (on access) and periodically + (via a background task). + """ + + def __init__(self, *, cleanup_interval: float = 60.0) -> None: + """Initialize the in-memory store. + + Args: + cleanup_interval: Seconds between periodic expired-key sweeps. + """ + self._data: dict[str, tuple[bytes, float]] = {} + if cleanup_interval <= 0: + raise ValueError("cleanup_interval must be > 0") + self._cleanup_interval = cleanup_interval + self._cleanup_task: asyncio.Task[None] | None = None + + async def start(self) -> None: + """Start the background cleanup task.""" + if self._cleanup_task is not None: + await cancel_and_wait(self._cleanup_task) + self._cleanup_task = asyncio.create_task(self._cleanup_loop()) + + async def close(self) -> None: + """Cancel the cleanup task and clear all data.""" + if self._cleanup_task is not None: + await cancel_and_wait(self._cleanup_task) + self._cleanup_task = None + self._data.clear() + + async def set( + self, key: str, value: bytes, ttl: float, *, only_if_exists: bool = False + ) -> None: + if only_if_exists: + entry = self._data.get(key) + if entry is None: + return + _, expires_at = entry + if time.monotonic() >= expires_at: + return + self._data[key] = (value, time.monotonic() + ttl) + + async def mset(self, items: list[tuple[str, bytes, float]]) -> None: + now = time.monotonic() + for key, value, ttl in items: + self._data[key] = (value, now + ttl) + + async def expire(self, *keys: str, ttl: float) -> None: + now = time.monotonic() + for key in keys: + entry = self._data.get(key) + if entry is None: + continue + _, expires_at = entry + if now >= expires_at: + del self._data[key] + continue + self._data[key] = (entry[0], now + ttl) + + async def get(self, key: str) -> bytes | None: + entry = self._data.get(key) + if entry is None: + return None + value, expires_at = entry + if time.monotonic() >= expires_at: + del self._data[key] + return None + return value + + async def mget(self, keys: list[str]) -> list[bytes | None]: + now = time.monotonic() + results: list[bytes | None] = [] + for key in keys: + entry = self._data.get(key) + if entry is None: + results.append(None) + elif now >= entry[1]: + del self._data[key] + results.append(None) + else: + results.append(entry[0]) + return results + + async def keys(self, prefix: str) -> list[str]: + now = time.monotonic() + result: list[str] = [] + expired: list[str] = [] + for key, (_, expires_at) in self._data.items(): + if not key.startswith(prefix): + continue + if now >= expires_at: + expired.append(key) + else: + result.append(key) + for key in expired: + del self._data[key] + return result + + async def delete(self, keys: list[str]) -> None: + for key in keys: + self._data.pop(key, None) + + async def _cleanup_loop(self) -> None: + while True: + await asyncio.sleep(self._cleanup_interval) + now = time.monotonic() + expired = [k for k, (_, exp) in self._data.items() if now >= exp] + for key in expired: + del self._data[key] diff --git a/agents-core/vision_agents/core/agents/session_registry/redis_store.py b/agents-core/vision_agents/core/agents/session_registry/redis_store.py new file mode 100644 index 000000000..f33c80510 --- /dev/null +++ b/agents-core/vision_agents/core/agents/session_registry/redis_store.py @@ -0,0 +1,119 @@ +import inspect +import logging + +import redis.asyncio as redis + +from .store import SessionKVStore + +logger = logging.getLogger(__name__) + + +class RedisSessionKVStore(SessionKVStore): + """Redis-backed TTL key-value store. + + Suitable for multi-node deployments where session state must be + shared across processes or machines. + + Args: + client: An existing ``redis.asyncio.Redis`` client. Caller owns + the lifecycle. + url: A Redis connection URL (e.g. ``redis://localhost:6379/0``). + The store creates and owns the client. + key_prefix: Prefix prepended to every key for namespacing. + """ + + def __init__( + self, + *, + client: redis.Redis | None = None, + url: str | None = None, + key_prefix: str = "vision_agents:", + ) -> None: + if client is not None and url is not None: + raise ValueError("Provide either a Redis client or a URL, not both") + + self._redis: redis.Redis + if client is not None: + self._owns_client = False + self._redis = client + elif url is not None: + self._owns_client = True + self._redis = redis.from_url(url) + else: + raise ValueError("Provide either a Redis client or a URL") + + self._key_prefix = key_prefix + + def _prefixed(self, key: str) -> str: + return f"{self._key_prefix}{key}" + + def _strip_prefix(self, key: str) -> str: + return key[len(self._key_prefix) :] + + async def start(self) -> None: + """Open the Redis connection and verify it with a PING.""" + # Handle non-specific Union return type here + ping = self._redis.ping() + if inspect.iscoroutine(ping): + await ping + + connection_kwargs = self._redis.connection_pool.connection_kwargs + host = connection_kwargs.get("host", "unknown") + port = connection_kwargs.get("port", 6379) + logger.info("RedisSessionKVStore connected to %s:%s", host, port) + + async def close(self) -> None: + """Close the Redis connection if this store owns it.""" + if self._owns_client: + await self._redis.aclose() + + async def set( + self, key: str, value: bytes, ttl: float, *, only_if_exists: bool = False + ) -> None: + """Store a value via SET with PX (millisecond TTL).""" + await self._redis.set( + self._prefixed(key), value, px=int(ttl * 1000), xx=only_if_exists + ) + + async def mset(self, items: list[tuple[str, bytes, float]]) -> None: + """Atomically store multiple values via a MULTI/EXEC pipeline.""" + async with self._redis.pipeline() as pipe: + for key, value, ttl in items: + pipe.set(self._prefixed(key), value, px=int(ttl * 1000)) + await pipe.execute() + + async def expire(self, *keys: str, ttl: float) -> None: + """Refresh TTL on one or more keys via a transactional PEXPIRE pipeline.""" + if not keys: + return + async with self._redis.pipeline() as pipe: + for key in keys: + pipe.pexpire(self._prefixed(key), int(ttl * 1000)) + await pipe.execute() + + async def get(self, key: str) -> bytes | None: + """Retrieve a value by key via GET.""" + return await self._redis.get(self._prefixed(key)) + + async def mget(self, keys: list[str]) -> list[bytes | None]: + """Retrieve multiple values by key via MGET, preserving order.""" + if not keys: + return [] + prefixed = [self._prefixed(k) for k in keys] + return await self._redis.mget(prefixed) + + async def keys(self, prefix: str) -> list[str]: + """Return all keys matching a prefix via SCAN (non-blocking).""" + pattern = f"{self._prefixed(prefix)}*" + result: list[str] = [] + async for key in self._redis.scan_iter(match=pattern, count=100): + decoded = key.decode() if isinstance(key, bytes) else key + result.append(self._strip_prefix(decoded)) + return result + + async def delete(self, keys: list[str]) -> None: + """Delete one or more keys via DEL. Missing keys are ignored.""" + if not keys: + return + prefixed = [self._prefixed(k) for k in keys] + await self._redis.delete(*prefixed) diff --git a/agents-core/vision_agents/core/agents/session_registry/registry.py b/agents-core/vision_agents/core/agents/session_registry/registry.py new file mode 100644 index 000000000..e643306ae --- /dev/null +++ b/agents-core/vision_agents/core/agents/session_registry/registry.py @@ -0,0 +1,153 @@ +import json +import logging +import time +from dataclasses import asdict +from typing import Self +from uuid import uuid4 + +from .in_memory_store import InMemorySessionKVStore +from .store import SessionKVStore +from .types import SessionInfo + +logger = logging.getLogger(__name__) + + +class SessionRegistry: + """Stateless facade over shared storage for multi-node session management. + + The registry handles serialization, key naming, and TTL management. + It holds no session state — the caller (AgentLauncher) owns all session + tracking and drives refreshes. + + When no storage backend is provided, an :class:`InMemorySessionKVStore` + is used by default (suitable for single-node / development). + """ + + def __init__( + self, + store: SessionKVStore | None = None, + *, + node_id: str | None = None, + ttl: float = 30.0, + ) -> None: + self._store = store or InMemorySessionKVStore() + self._node_id = node_id or str(uuid4()) + if ttl <= 0: + raise ValueError("ttl must be > 0") + + self._ttl = ttl + + @property + def node_id(self) -> str: + return self._node_id + + async def start(self) -> None: + """Initialize the storage backend.""" + await self._store.start() + + async def stop(self) -> None: + """Close the storage backend.""" + await self._store.close() + + async def register(self, call_id: str, session_id: str) -> None: + """Write a new session record to storage.""" + now = time.time() + info = SessionInfo( + session_id=session_id, + call_id=call_id, + node_id=self._node_id, + started_at=now, + metrics_updated_at=now, + ) + await self._store.set( + self._session_key(call_id, session_id), + json.dumps(asdict(info)).encode(), + self._ttl, + ) + + async def remove(self, call_id: str, session_id: str) -> None: + """Delete all storage keys for a session.""" + await self._store.delete( + [ + self._session_key(call_id, session_id), + self._close_key(call_id, session_id), + ] + ) + + async def update_metrics( + self, call_id: str, session_id: str, metrics: dict[str, int | float | None] + ) -> None: + """Push updated metrics for a session into storage.""" + key = self._session_key(call_id, session_id) + raw = await self._store.get(key) + if raw is None: + return + data = json.loads(raw) + data["metrics"] = metrics + data["metrics_updated_at"] = time.time() + await self._store.set( + key, json.dumps(data).encode(), self._ttl, only_if_exists=True + ) + + async def refresh(self, sessions: dict[str, str]) -> None: + """Refresh TTLs for the given sessions. + + Args: + sessions: mapping of session_id to call_id. + """ + if not sessions: + return + keys = [ + self._session_key(call_id, session_id) + for session_id, call_id in sessions.items() + ] + await self._store.expire(*keys, ttl=self._ttl) + + async def get_close_requests(self, sessions: dict[str, str]) -> list[str]: + """Return session IDs that have a pending close request. + + Args: + sessions: mapping of session_id to call_id. + """ + if not sessions: + return [] + session_ids = list(sessions.keys()) + keys = [self._close_key(sessions[sid], sid) for sid in session_ids] + values = await self._store.mget(keys) + return [sid for sid, val in zip(session_ids, values) if val is not None] + + async def request_close(self, call_id: str, session_id: str) -> None: + """Set a close flag for a session (async close from any node).""" + await self._store.set(self._close_key(call_id, session_id), b"", self._ttl) + + async def get(self, call_id: str, session_id: str) -> SessionInfo | None: + """Look up a session by ID from shared storage.""" + raw = await self._store.get(self._session_key(call_id, session_id)) + if raw is None: + return None + return SessionInfo.from_dict(json.loads(raw)) + + async def get_for_call(self, call_id: str) -> list[SessionInfo]: + """Return all sessions for a given call across all nodes.""" + keys = await self._store.keys(f"sessions/{call_id}/") + if not keys: + return [] + values = await self._store.mget(keys) + return [ + SessionInfo.from_dict(json.loads(raw)) for raw in values if raw is not None + ] + + @staticmethod + def _session_key(call_id: str, session_id: str) -> str: + return f"sessions/{call_id}/{session_id}" + + @staticmethod + def _close_key(call_id: str, session_id: str) -> str: + return f"close_requests/{call_id}/{session_id}" + + async def __aenter__(self) -> Self: + await self.start() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.stop() diff --git a/agents-core/vision_agents/core/agents/session_registry/store.py b/agents-core/vision_agents/core/agents/session_registry/store.py new file mode 100644 index 000000000..0f2c6bf7e --- /dev/null +++ b/agents-core/vision_agents/core/agents/session_registry/store.py @@ -0,0 +1,136 @@ +import abc + + +class SessionKVStore(abc.ABC): + """ + Abstract TTL key-value storage backend for the SessionRegistry. + + The storage layer is a generic key-value store that works with bytes. + It knows nothing about nodes or sessions — the SessionRegistry owns + the key scheme and all serialization. + + Implementations should use TTL-based key expiry. Records that are not + refreshed within the TTL period are considered expired and may be + garbage-collected by the backend. + + Key conventions (managed by SessionRegistry): + - ``sessions/{call_id}/{session_id}`` → JSON-serialized SessionInfo + - ``close_requests/{call_id}/{session_id}`` → empty bytes (close flag) + """ + + async def start(self) -> None: + """ + Initialize the storage backend (open connections, etc.). + + Default implementation is a no-op. + """ + + async def close(self) -> None: + """ + Close any connections held by this storage backend. + + Default implementation is a no-op. + """ + + async def __aenter__(self) -> "SessionKVStore": + await self.start() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + await self.close() + + @abc.abstractmethod + async def set( + self, key: str, value: bytes, ttl: float, *, only_if_exists: bool = False + ) -> None: + """ + Store a value with a TTL. + + If the key already exists, the value and TTL are overwritten (upsert). + The record should expire after ``ttl`` seconds if not refreshed. + + Args: + key: The key to store. + value: The value as bytes. + ttl: Time-to-live in seconds. + only_if_exists: When True, the write is silently skipped if the + key does not already exist. + """ + ... + + @abc.abstractmethod + async def mset(self, items: list[tuple[str, bytes, float]]) -> None: + """ + Store multiple values with TTLs. + + Each item is a ``(key, value, ttl)`` tuple. Semantics per key are + the same as :meth:`set`. + + Args: + items: A list of (key, value, ttl) tuples. + """ + ... + + @abc.abstractmethod + async def expire(self, *keys: str, ttl: float) -> None: + """ + Refresh the TTL on one or more existing keys without changing their values. + + Keys that do not exist are silently ignored. + + Args: + *keys: One or more keys to update. + ttl: New time-to-live in seconds. + """ + ... + + @abc.abstractmethod + async def get(self, key: str) -> bytes | None: + """ + Retrieve a value by key. + + Returns: + The value as bytes, or None if the key does not exist or has expired. + """ + ... + + @abc.abstractmethod + async def mget(self, keys: list[str]) -> list[bytes | None]: + """ + Retrieve multiple values by key. + + Returns a list of values in the same order as the input keys. + Missing or expired keys are returned as None. + + Args: + keys: The keys to retrieve. + + Returns: + A list of values (or None) in the same order as the input keys. + """ + ... + + @abc.abstractmethod + async def keys(self, prefix: str) -> list[str]: + """ + Return all non-expired keys that start with ``prefix``. + + Args: + prefix: The key prefix to match. + + Returns: + A list of matching key strings. + """ + ... + + @abc.abstractmethod + async def delete(self, keys: list[str]) -> None: + """ + Delete one or more keys. + + Keys that do not exist are silently ignored. + + Args: + keys: The keys to delete. + """ + ... diff --git a/agents-core/vision_agents/core/agents/session_registry/types.py b/agents-core/vision_agents/core/agents/session_registry/types.py new file mode 100644 index 000000000..540abc760 --- /dev/null +++ b/agents-core/vision_agents/core/agents/session_registry/types.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field, fields +from typing import Any, Self + + +@dataclass +class SessionInfo: + """Represents a session registered in the session registry.""" + + session_id: str + call_id: str + node_id: str + started_at: float + metrics_updated_at: float + metrics: dict[str, int | float | None] = field(default_factory=dict) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + """Construct from a dict, silently ignoring unknown keys.""" + known = {f.name for f in fields(cls)} + return cls(**{k: v for k, v in data.items() if k in known}) diff --git a/agents-core/vision_agents/core/runner/http/api.py b/agents-core/vision_agents/core/runner/http/api.py index ffe01777b..406fe0fa6 100644 --- a/agents-core/vision_agents/core/runner/http/api.py +++ b/agents-core/vision_agents/core/runner/http/api.py @@ -1,22 +1,22 @@ import logging from contextlib import asynccontextmanager from datetime import datetime, timezone -from typing import Any, Optional from fastapi import APIRouter, Depends, FastAPI, HTTPException, status from fastapi.responses import Response from vision_agents.core import AgentLauncher -from vision_agents.core.agents.agent_launcher import AgentSession -from vision_agents.core.agents.exceptions import SessionLimitExceeded +from vision_agents.core.agents.exceptions import ( + InvalidCallId, + MaxConcurrentSessionsExceeded, + MaxSessionsPerCallExceeded, +) from .dependencies import ( can_close_session, can_start_session, can_view_metrics, can_view_session, - get_current_user, get_launcher, - get_session, ) from .models import ( GetAgentSessionMetricsResponse, @@ -46,7 +46,7 @@ async def lifespan(app: FastAPI): @router.post( - "/sessions", + "/calls/{call_id}/sessions", response_model=StartSessionResponse, status_code=status.HTTP_201_CREATED, summary="Join call with an agent", @@ -56,12 +56,33 @@ async def lifespan(app: FastAPI): "description": "Session created successfully", "model": StartSessionResponse, }, + 400: { + "description": "Invalid call_id", + "content": { + "application/json": { + "example": { + "detail": "Invalid call_id 'bad!id': must contain only a-z, 0-9, _ and -", + } + } + }, + }, 429: { "description": "Session limits exceeded", "content": { "application/json": { - "example": { - "detail": "Reached maximum concurrent sessions of X", + "examples": { + "concurrent": { + "summary": "Max concurrent sessions exceeded", + "value": { + "detail": "Reached maximum number of concurrent sessions", + }, + }, + "per_call": { + "summary": "Max sessions per call exceeded", + "value": { + "detail": "Reached maximum number of sessions for this call", + }, + }, } } }, @@ -70,23 +91,36 @@ async def lifespan(app: FastAPI): dependencies=[Depends(can_start_session)], ) async def start_session( + call_id: str, request: StartSessionRequest, launcher: AgentLauncher = Depends(get_launcher), - user: Any = Depends(get_current_user), ) -> StartSessionResponse: """Start an agent and join a call.""" try: session = await launcher.start_session( - call_id=request.call_id, call_type=request.call_type, created_by=user + call_id=call_id, call_type=request.call_type ) - except SessionLimitExceeded as e: - raise HTTPException(status_code=429, detail=str(e)) from e + except InvalidCallId as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid call_id: must contain only a-z, 0-9, _ and -", + ) from e + except MaxConcurrentSessionsExceeded as e: + raise HTTPException( + status_code=429, + detail="Reached maximum number of concurrent sessions", + ) from e + except MaxSessionsPerCallExceeded as e: + raise HTTPException( + status_code=429, + detail="Reached maximum number of sessions for this call", + ) from e except Exception as e: logger.exception("Failed to start agent") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Failed to start agent: {str(e)}", + detail="Failed to start agent", ) from e return StartSessionResponse( @@ -96,140 +130,126 @@ async def start_session( ) +async def _close_session(launcher: AgentLauncher, call_id: str, session_id: str): + info = await launcher.get_session_info(call_id, session_id) + if info is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Session with id '{session_id}' not found", + ) + await launcher.request_close_session(call_id, session_id) + + @router.delete( - "/sessions/{session_id}", - summary="Close the agent session and remove it from call", + "/calls/{call_id}/sessions/{session_id}", + summary="Request closure of an agent session", dependencies=[Depends(can_close_session)], ) async def close_session( + call_id: str, session_id: str, launcher: AgentLauncher = Depends(get_launcher), ) -> Response: - """ - Stop an agent and remove it from a call. - """ - - closed = await launcher.close_session(session_id) - if not closed: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Session with id '{session_id}' not found", - ) + """Request closure of an agent session. - return Response(status_code=204) + Sets a close flag in the registry. The owning node will close the + session on its next maintenance cycle. + """ + await _close_session(launcher, call_id, session_id) + return Response(status_code=202) @router.post( - "/sessions/{session_id}/close", - summary="Close the agent session via sendBeacon (POST alternative to DELETE).", - description="Alternative endpoint for agent leave via sendBeacon. " - "sendBeacon only supports POST requests.", + "/calls/{call_id}/sessions/{session_id}/close", + summary="Request closure of an agent session (sendBeacon alternative)", + description="Alternative endpoint for requesting session closure via the " + "browser sendBeacon API, which only supports POST requests.", dependencies=[Depends(can_close_session)], ) async def close_session_beacon( + call_id: str, session_id: str, launcher: AgentLauncher = Depends(get_launcher), ) -> Response: - """ - Stop an agent via sendBeacon (POST alternative to DELETE). - """ - - closed = await launcher.close_session(session_id) - if not closed: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Session with id '{session_id}' not found", - ) + """Request closure of an agent session via sendBeacon. - return Response(status_code=200) + Sets a close flag in the registry. The owning node will close the + session on its next maintenance cycle. + """ + await _close_session(launcher, call_id, session_id) + return Response(status_code=202) @router.get( - "/sessions/{session_id}", + "/calls/{call_id}/sessions/{session_id}", response_model=GetAgentSessionResponse, summary="Get info about a running agent session", dependencies=[Depends(can_view_session)], ) async def get_session_info( + call_id: str, session_id: str, - session: Optional[AgentSession] = Depends(get_session), + launcher: AgentLauncher = Depends(get_launcher), ) -> GetAgentSessionResponse: - """ - Get info about a running agent session. - """ + """Get info about a running agent session.""" - if session is None: + info = await launcher.get_session_info(call_id, session_id) + if info is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session with id '{session_id}' not found", ) - response = GetAgentSessionResponse( - session_id=session.id, - call_id=session.call_id, - session_started_at=session.started_at, + return GetAgentSessionResponse( + session_id=info.session_id, + call_id=info.call_id, + session_started_at=datetime.fromtimestamp(info.started_at, tz=timezone.utc), ) - return response @router.get( - "/sessions/{session_id}/metrics", + "/calls/{call_id}/sessions/{session_id}/metrics", response_model=GetAgentSessionMetricsResponse, - summary="Get info about a running agent session", + summary="Get metrics for a running agent session", dependencies=[Depends(can_view_metrics)], ) async def get_session_metrics( + call_id: str, session_id: str, - session: Optional[AgentSession] = Depends(get_session), + launcher: AgentLauncher = Depends(get_launcher), ) -> GetAgentSessionMetricsResponse: - """ - Get metrics for the running agent session. - """ + """Get metrics for a running agent session from the registry.""" - if session is None: + info = await launcher.get_session_info(call_id, session_id) + if info is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Session with id '{session_id}' not found", ) - metrics_dict = session.agent.metrics.to_dict( - fields=[ - "llm_latency_ms__avg", - "llm_time_to_first_token_ms__avg", - "llm_input_tokens__total", - "llm_output_tokens__total", - "stt_latency_ms__avg", - "tts_latency_ms__avg", - "realtime_audio_input_duration_ms__total", - "realtime_audio_output_duration_ms__total", - ] - ) - response = GetAgentSessionMetricsResponse( - session_id=session.id, - call_id=session.call_id, - session_started_at=session.started_at, - metrics_generated_at=datetime.now(timezone.utc), - metrics=metrics_dict, + return GetAgentSessionMetricsResponse( + session_id=info.session_id, + call_id=info.call_id, + session_started_at=datetime.fromtimestamp(info.started_at, tz=timezone.utc), + metrics_generated_at=datetime.fromtimestamp( + info.metrics_updated_at, tz=timezone.utc + ), + metrics=info.metrics, ) - return response @router.get("/health") async def health() -> Response: - """ - Check if the server is alive. - """ + """Check if the server is alive.""" return Response(status_code=200) @router.get("/ready") async def ready(launcher: AgentLauncher = Depends(get_launcher)) -> Response: - """ - Check if the server is ready to spawn new agents. - """ + """Check if the server is ready to spawn new agents.""" if launcher.ready: return Response(status_code=200) else: raise HTTPException( - status_code=400, detail="Server is not ready to accept requests" + status_code=503, detail="Server is not ready to accept requests" ) diff --git a/agents-core/vision_agents/core/runner/http/dependencies.py b/agents-core/vision_agents/core/runner/http/dependencies.py index db05bfb0b..1348da669 100644 --- a/agents-core/vision_agents/core/runner/http/dependencies.py +++ b/agents-core/vision_agents/core/runner/http/dependencies.py @@ -1,39 +1,25 @@ -from typing import Any, Optional - -from fastapi import Depends, Request -from vision_agents.core import AgentLauncher, AgentSession +from fastapi import Request +from vision_agents.core import AgentLauncher from .options import ServeOptions -def get_current_user() -> Any: - return None - - -def can_start_session(): ... +def can_start_session(call_id: str): ... -def can_close_session(): ... +def can_close_session(call_id: str): ... -def can_view_session(): ... +def can_view_session(call_id: str): ... -def can_view_metrics(): ... +def can_view_metrics(call_id: str): ... def get_launcher(request: Request) -> AgentLauncher: - """ - Get an agent launcher from the FastAPI app - """ + """Get an agent launcher from the FastAPI app.""" return request.app.state.launcher def get_options(request: Request) -> ServeOptions: return request.app.state.options - - -def get_session( - session_id: str, launcher: AgentLauncher = Depends(get_launcher) -) -> Optional[AgentSession]: - return launcher.get_session(session_id=session_id) diff --git a/agents-core/vision_agents/core/runner/http/models.py b/agents-core/vision_agents/core/runner/http/models.py index 779d36d2c..50abcf570 100644 --- a/agents-core/vision_agents/core/runner/http/models.py +++ b/agents-core/vision_agents/core/runner/http/models.py @@ -8,7 +8,6 @@ class StartSessionRequest(BaseModel): """Request body for joining a call.""" - call_id: str = Field(..., description="Unique identifier of the call to join") call_type: str = Field(default="default", description="Type of the call to join") diff --git a/agents-core/vision_agents/core/runner/http/options.py b/agents-core/vision_agents/core/runner/http/options.py index 99a221944..d1ea46f47 100644 --- a/agents-core/vision_agents/core/runner/http/options.py +++ b/agents-core/vision_agents/core/runner/http/options.py @@ -8,13 +8,8 @@ def allow_all() -> Any: return None -def get_user_noop() -> Any: - return None - - class ServeOptions(pydantic_settings.BaseSettings): - """ - A collection of configuration options for the "serve" mode. + """A collection of configuration options for the "serve" mode. Args: fast_api: an optional instance of FastAPI. @@ -28,19 +23,17 @@ class ServeOptions(pydantic_settings.BaseSettings): cors_allow_credentials: CORS allow credentials. can_start_session: a callable to verify if the user can start a new session. - It can request FastAPI dependencies via Depends(). + Receives ``call_id`` from the URL path. Can request additional + FastAPI dependencies via Depends(). can_close_session: a callable to verify if the user can close a given session. - It can request FastAPI dependencies via Depends(). + Receives ``call_id`` from the URL path. Can request additional + FastAPI dependencies via Depends(). can_view_session: a callable to verify if the user can view a session. - It can request FastAPI dependencies via Depends(). + Receives ``call_id`` from the URL path. Can request additional + FastAPI dependencies via Depends(). can_view_metrics: a callable to verify if the user can view metrics for the session. - It can request FastAPI dependencies via Depends(). - get_current_user: a callable to configure how the current user is determined during requests. - The current user will be stored in `AgentSession.created_by` field for the new sessions, - and it can be used to verify who created the session. - The implementation of callable itself is completely arbitrary and depends on the use case. - The callable can request FastAPI dependencies via Depends(), too. - + Receives ``call_id`` from the URL path. Can request additional + FastAPI dependencies via Depends(). """ fast_api: Optional[FastAPI] = None @@ -52,4 +45,3 @@ class ServeOptions(pydantic_settings.BaseSettings): can_close_session: Callable = allow_all can_view_session: Callable = allow_all can_view_metrics: Callable = allow_all - get_current_user: Callable = get_user_noop diff --git a/agents-core/vision_agents/core/runner/runner.py b/agents-core/vision_agents/core/runner/runner.py index 687563cc9..4f0092662 100644 --- a/agents-core/vision_agents/core/runner/runner.py +++ b/agents-core/vision_agents/core/runner/runner.py @@ -21,7 +21,6 @@ can_start_session, can_view_metrics, can_view_session, - get_current_user, ) from .http.options import ServeOptions @@ -224,7 +223,6 @@ def _create_fastapi_app(self, options: ServeOptions) -> FastAPI: app.dependency_overrides[can_close_session] = options.can_close_session app.dependency_overrides[can_view_session] = options.can_view_session app.dependency_overrides[can_view_metrics] = options.can_view_metrics - app.dependency_overrides[get_current_user] = options.get_current_user app.include_router(router) app.add_middleware( CORSMiddleware, diff --git a/examples/08_agent_server_example/README.md b/examples/08_agent_server_example/README.md index 12970361e..41bc66f3a 100644 --- a/examples/08_agent_server_example/README.md +++ b/examples/08_agent_server_example/README.md @@ -110,8 +110,8 @@ async def verify_api_key(x_api_key: str = Header(...)): raise HTTPException(status_code=401, detail="Invalid API key") -# Custom permission check -async def can_start(x_api_key: str = Header(...)): +# Custom permission check — call_id comes from the URL path +async def can_start(call_id: str, x_api_key: str = Header(...)): await verify_api_key(x_api_key) @@ -129,7 +129,6 @@ runner = Runner( can_close_session=can_start, can_view_session=can_start, can_view_metrics=can_start, - get_current_user=verify_api_key, ), ) ``` @@ -143,15 +142,14 @@ runner = Runner( | `cors_allow_methods` | `("*",)` | Allowed CORS methods | | `cors_allow_headers` | `("*",)` | Allowed CORS headers | | `cors_allow_credentials` | `True` | Allow CORS credentials | -| `can_start_session` | allow all | Permission check for starting sessions | -| `can_close_session` | allow all | Permission check for closing sessions | -| `can_view_session` | allow all | Permission check for viewing sessions | -| `can_view_metrics` | allow all | Permission check for viewing metrics | -| `get_current_user` | no-op | Callable to determine current user | +| `can_start_session` | allow all | Permission check for starting sessions. Receives `call_id` from URL path. | +| `can_close_session` | allow all | Permission check for closing sessions. Receives `call_id` from URL path. | +| `can_view_session` | allow all | Permission check for viewing sessions. Receives `call_id` from URL path. | +| `can_view_metrics` | allow all | Permission check for viewing metrics. Receives `call_id` from URL path. | ### Permission Callbacks & Authentication -The `can_start_session`, `can_close_session`, `can_view_session`, `can_view_metrics`, and `get_current_user` callbacks +The `can_start_session`, `can_close_session`, `can_view_session`, and `can_view_metrics` callbacks are **standard FastAPI dependencies**. This means they have access to the full power of FastAPI's dependency injection @@ -163,75 +161,27 @@ system: - **Automatic validation**: Use Pydantic models for type-safe parameter extraction - **Raise HTTP exceptions**: Return `401`, `403`, or any status code to deny access -**Example: JWT Authentication with Database Lookup** +**Example: JWT Authentication** ```python from fastapi import Depends, Header, HTTPException -from myapp.auth import decode_jwt, get_user_by_id -from myapp.database import get_db -from sqlalchemy.ext.asyncio import AsyncSession +from myapp.auth import decode_jwt -async def get_current_user( - authorization: str = Header(...), - db: AsyncSession = Depends(get_db), -): - """Resolve the current user from JWT token.""" +async def verify_token(authorization: str = Header(...)): + """Verify a JWT token from the Authorization header.""" if not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="Invalid authorization header") token = authorization.split(" ")[1] payload = decode_jwt(token) # Raises if invalid + return payload - user = await get_user_by_id(db, payload["user_id"]) - if not user: - raise HTTPException(status_code=401, detail="User not found") - return user - - -async def can_start_session( - user=Depends(get_current_user), - db: AsyncSession = Depends(get_db), -): - """Check if user has permission to start agent sessions.""" - if not user.has_permission("agents:start"): +async def can_start_session(call_id: str, token_payload=Depends(verify_token)): + """Check if the caller has permission to start agent sessions.""" + if "agents:start" not in token_payload.get("permissions", []): raise HTTPException(status_code=403, detail="Insufficient permissions") - - # Check rate limits, quotas, etc. - if await user.exceeded_session_quota(db): - raise HTTPException(status_code=429, detail="Session quota exceeded") - -``` - -**How `get_current_user` Works** - -The value returned by `get_current_user` is stored in `AgentSession.created_by`. This allows you to: - -- Track which user started each session -- Implement user-specific session limits -- Audit session creation - -```python -from typing import Optional - -from fastapi import Depends, HTTPException - -from vision_agents.core import AgentSession -from vision_agents.core.runner.http.dependencies import get_session - - -# In your permission callbacks, you can access the session creator -async def can_close_session( - session_id: str, - current_user=Depends(get_current_user), - session: Optional[AgentSession] = Depends(get_session), -): - """Only allow users to close their own sessions.""" - if session and session.created_by != current_user.id: - raise HTTPException( - status_code=403, detail="Cannot close another user's session" - ) ``` ### API Reference @@ -242,14 +192,14 @@ The underlying API is built with `FastAPI` which provides a Swagger UI on http:/ #### Start a Session -**POST** `/sessions` +**POST** `/calls/{call_id}/sessions` Start a new agent and have it join a call. ```bash -curl -X POST http://localhost:8000/sessions \ +curl -X POST http://localhost:8000/calls/my-call-123/sessions \ -H "Content-Type: application/json" \ - -d '{"call_id": "my-call-123", "call_type": "default"}' + -d '{"call_type": "default"}' ``` **Response:** @@ -258,45 +208,44 @@ curl -X POST http://localhost:8000/sessions \ { "session_id": "agent-uuid", "call_id": "my-call-123", - "config": {}, "session_started_at": "2024-01-15T10:30:00Z" } ``` #### Close a Session -**DELETE** `/sessions/{session_id}` +**DELETE** `/calls/{call_id}/sessions/{session_id}` Stop an agent and remove it from a call. ```bash -curl -X DELETE http://localhost:8000/sessions/agent-uuid +curl -X DELETE http://localhost:8000/calls/my-call-123/sessions/agent-uuid ``` #### Close via sendBeacon -**POST** `/sessions/{session_id}/close` +**POST** `/calls/{call_id}/sessions/{session_id}/close` Alternative endpoint for closing sessions via browser's `sendBeacon()` API. #### Get Session Info -**GET** `/sessions/{session_id}` +**GET** `/calls/{call_id}/sessions/{session_id}` Get information about a running agent session. ```bash -curl http://localhost:8000/sessions/agent-uuid +curl http://localhost:8000/calls/my-call-123/sessions/agent-uuid ``` #### Get Session Metrics -**GET** `/sessions/{session_id}/metrics` +**GET** `/calls/{call_id}/sessions/{session_id}/metrics` Get real-time metrics for a running session. ```bash -curl http://localhost:8000/sessions/agent-uuid/metrics +curl http://localhost:8000/calls/my-call-123/sessions/agent-uuid/metrics ``` **Response:** diff --git a/pyproject.toml b/pyproject.toml index dfbafd7f2..780eacd87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,8 @@ dev = [ "toml>=0.10.2", "asgi-lifespan>=2.1.0", "mistralai[realtime]>=1.12.0", + "testcontainers[redis]>=4.0.0", + "redis[hiredis]>=5.0.0", ] [tool.mypy] diff --git a/tests/test_agents/test_agent_launcher.py b/tests/test_agents/test_agent_launcher.py index 9dfa44006..e361482ad 100644 --- a/tests/test_agents/test_agent_launcher.py +++ b/tests/test_agents/test_agent_launcher.py @@ -1,13 +1,25 @@ import asyncio +from collections.abc import AsyncIterator from typing import Any from unittest.mock import MagicMock, patch import pytest +import redis.asyncio as redis +from testcontainers.redis import RedisContainer from vision_agents.core import Agent, AgentLauncher, User from vision_agents.core.agents.exceptions import ( + InvalidCallId, MaxConcurrentSessionsExceeded, MaxSessionsPerCallExceeded, ) +from vision_agents.core.agents.session_registry import SessionRegistry +from vision_agents.core.agents.session_registry.in_memory_store import ( + InMemorySessionKVStore, +) +from vision_agents.core.agents.session_registry.redis_store import ( + RedisSessionKVStore, +) +from vision_agents.core.agents.session_registry.store import SessionKVStore from vision_agents.core.events import EventManager from vision_agents.core.llm import LLM from vision_agents.core.llm.llm import LLMResponseEvent @@ -49,6 +61,34 @@ async def join_call_noop(agent: Agent, call_type: str, call_id: str, **kwargs) - await asyncio.sleep(10) +@pytest.fixture(scope="module") +def redis_url(): + with RedisContainer() as container: + host = container.get_container_host_ip() + port = container.get_exposed_port(6379) + yield f"redis://{host}:{port}/0" + + +@pytest.fixture(params=["memory", "redis"]) +async def storage(request, redis_url) -> AsyncIterator[SessionKVStore]: + if request.param == "memory": + store = InMemorySessionKVStore() + await store.start() + yield store + await store.close() + else: + client = redis.from_url(redis_url) + store = RedisSessionKVStore(client=client, key_prefix="test:") + await store.start() + try: + yield store + finally: + keys = await store.keys("") + if keys: + await store.delete(keys) + await client.aclose() + + class TestAgentLauncher: async def test_warmup(self, stream_edge_mock): llm = DummyLLM() @@ -99,7 +139,7 @@ async def create_agent(**kwargs) -> Agent: create_agent=create_agent, join_call=join_call_noop, agent_idle_timeout=1.0, - cleanup_interval=0.5, + maintenance_interval=0.5, ) with patch.object(Agent, "idle_for", return_value=10): # Start the launcher internals @@ -133,7 +173,7 @@ async def create_agent(**kwargs) -> Agent: create_agent=create_agent, join_call=join_call_noop, agent_idle_timeout=0, - cleanup_interval=0.5, + maintenance_interval=0.5, ) with patch.object(Agent, "idle_for", return_value=idle_for): # Start the launcher internals @@ -164,7 +204,7 @@ async def create_agent(**kwargs) -> Agent: create_agent=create_agent, join_call=join_call_noop, agent_idle_timeout=1.0, - cleanup_interval=0.5, + maintenance_interval=0.5, ) with patch.object(Agent, "idle_for", return_value=0): # Start the launcher internals @@ -204,7 +244,6 @@ async def join_call( assert session.call_id assert session.agent assert session.started_at - assert session.created_by is None assert not session.finished assert launcher.get_session(session_id=session.id) @@ -411,6 +450,27 @@ async def create_agent(**kwargs) -> Agent: max_sessions_per_call=-1, ) + @pytest.mark.parametrize( + "call_id", + ["UPPER", "has space", "a/b", "", "café", "call@id", "a.b"], + ) + async def test_invalid_call_id_rejected(self, stream_edge_mock, call_id): + async def create_agent(**kwargs) -> Agent: + return Agent( + llm=DummyLLM(), + tts=DummyTTS(), + edge=stream_edge_mock, + agent_user=User(name="test"), + ) + + launcher = AgentLauncher( + create_agent=create_agent, + join_call=join_call_noop, + ) + async with launcher: + with pytest.raises(InvalidCallId): + await launcher.start_session(call_id=call_id) + async def test_max_concurrent_agents_exceeded(self, stream_edge_mock): async def create_agent(**kwargs) -> Agent: return Agent( @@ -527,6 +587,8 @@ async def join_call(*args, **kwargs): await launcher.start_session(call_id="same_call") await session1.wait() + # Yield so the finalizer's fire-and-forget registry.remove() completes + await asyncio.sleep(0) # Can create a new session when the previous one ends session3 = await launcher.start_session(call_id="same_call") assert session3 is not None @@ -622,7 +684,7 @@ async def create_agent(**kwargs) -> Agent: join_call=join_call_noop, max_session_duration_seconds=1.0, agent_idle_timeout=0, # Disable idle timeout - cleanup_interval=0.5, + maintenance_interval=0.5, ) with patch.object(Agent, "on_call_for", return_value=10): async with launcher: @@ -654,7 +716,7 @@ async def create_agent(**kwargs) -> Agent: join_call=join_call_noop, max_session_duration_seconds=None, agent_idle_timeout=10, - cleanup_interval=0.5, + maintenance_interval=0.5, ) with patch.object(Agent, "on_call_for", return_value=10): async with launcher: @@ -666,3 +728,101 @@ async def create_agent(**kwargs) -> Agent: # The agents must NOT be closed because max_session_duration_seconds=None assert not session1.finished assert not session2.finished + + +class TestAgentLauncherWithStorage: + """Tests that exercise AgentLauncher with both in-memory and Redis storage.""" + + async def test_start_session(self, stream_edge_mock, storage): + async def create_agent(**kwargs) -> Agent: + return Agent( + llm=DummyLLM(), + tts=DummyTTS(), + edge=stream_edge_mock, + agent_user=User(name="test"), + ) + + registry = SessionRegistry(store=storage) + launcher = AgentLauncher( + create_agent=create_agent, + join_call=join_call_noop, + registry=registry, + ) + async with launcher: + session = await launcher.start_session(call_id="test", call_type="default") + assert session.id + assert session.call_id == "test" + assert not session.finished + assert launcher.get_session(session.id) is not None + + info = await launcher.get_session_info("test", session.id) + assert info is not None + assert info.session_id == session.id + assert info.call_id == "test" + + async def test_close_session(self, stream_edge_mock, storage): + async def create_agent(**kwargs) -> Agent: + return Agent( + llm=DummyLLM(), + tts=DummyTTS(), + edge=stream_edge_mock, + agent_user=User(name="test"), + ) + + registry = SessionRegistry(store=storage) + launcher = AgentLauncher( + create_agent=create_agent, + join_call=join_call_noop, + registry=registry, + ) + async with launcher: + session = await launcher.start_session(call_id="test") + assert await launcher.close_session(session.id, wait=True) + assert session.finished + assert launcher.get_session(session.id) is None + assert await launcher.get_session_info("test", session.id) is None + + async def test_request_close_session(self, stream_edge_mock, storage): + async def create_agent(**kwargs) -> Agent: + return Agent( + llm=DummyLLM(), + tts=DummyTTS(), + edge=stream_edge_mock, + agent_user=User(name="test"), + ) + + registry = SessionRegistry(store=storage) + launcher = AgentLauncher( + create_agent=create_agent, + join_call=join_call_noop, + maintenance_interval=0.5, + registry=registry, + ) + async with launcher: + session = await launcher.start_session(call_id="call") + assert not session.finished + + await launcher.request_close_session("call", session.id) + # Let the maintenance task to run + await asyncio.sleep(1.5) + + assert session.finished + assert launcher.get_session(session.id) is None + + async def test_get_session_info_not_found(self, stream_edge_mock, storage): + async def create_agent(**kwargs) -> Agent: + return Agent( + llm=DummyLLM(), + tts=DummyTTS(), + edge=stream_edge_mock, + agent_user=User(name="test"), + ) + + registry = SessionRegistry(store=storage) + launcher = AgentLauncher( + create_agent=create_agent, + join_call=join_call_noop, + registry=registry, + ) + async with launcher: + assert await launcher.get_session_info("any-call", "nonexistent") is None diff --git a/tests/test_agents/test_runner.py b/tests/test_agents/test_runner.py index 815a516fa..9a60d9332 100644 --- a/tests/test_agents/test_runner.py +++ b/tests/test_agents/test_runner.py @@ -5,7 +5,7 @@ import pytest from asgi_lifespan import LifespanManager -from fastapi import FastAPI, Header, HTTPException, Response +from fastapi import FastAPI, HTTPException, Response from httpx import ASGITransport, AsyncClient from vision_agents.core import Agent, AgentLauncher, Runner, ServeOptions, User from vision_agents.core.events import EventManager @@ -106,7 +106,7 @@ async def test_start_session_success( async with test_client_factory(runner) as client: resp = await client.post( - "/sessions", json={"call_id": "test", "call_type": "default"} + "/calls/test/sessions", json={"call_type": "default"} ) assert resp.status_code == 201 resp_json = resp.json() @@ -116,47 +116,40 @@ async def test_start_session_success( assert resp_json["session_started_at"] assert agent_launcher.get_session(session_id) - async def test_start_session_current_user_stored( + async def test_start_session_no_permissions_fail( self, agent_launcher, test_client_factory ) -> None: - def get_current_user(user_id=Header()) -> User: - return User(id=user_id) + def can_start(call_id: str): + raise HTTPException(status_code=403) - opts = ServeOptions( - get_current_user=get_current_user, - ) + opts = ServeOptions(can_start_session=can_start) runner = Runner(launcher=agent_launcher, serve_options=opts) async with test_client_factory(runner) as client: resp = await client.post( - "/sessions", - json={"call_id": "test", "call_type": "default"}, - headers={"User-Id": "123"}, + "/calls/test/sessions", + json={"call_type": "default"}, ) - assert resp.status_code == 201 - resp_json = resp.json() - assert resp_json["call_id"] == "test" - session_id = resp_json["session_id"] - assert session_id - assert resp_json["session_started_at"] - session = agent_launcher.get_session(session_id) - assert session.created_by == User(id="123") + assert resp.status_code == 403 - async def test_start_session_no_permissions_fail( + async def test_start_session_permission_receives_call_id( self, agent_launcher, test_client_factory ) -> None: - def can_start(): - raise HTTPException(status_code=403) + received_call_ids: list[str] = [] + + def can_start(call_id: str): + received_call_ids.append(call_id) opts = ServeOptions(can_start_session=can_start) runner = Runner(launcher=agent_launcher, serve_options=opts) async with test_client_factory(runner) as client: resp = await client.post( - "/sessions", - json={"call_id": "test", "call_type": "default"}, + "/calls/my-call-123/sessions", + json={"call_type": "default"}, ) - assert resp.status_code == 403 + assert resp.status_code == 201 + assert received_call_ids == ["my-call-123"] async def test_close_session_success( self, agent_launcher, test_client_factory @@ -165,31 +158,27 @@ async def test_close_session_success( async with test_client_factory(runner) as client: resp = await client.post( - "/sessions", json={"call_id": "test", "call_type": "default"} + "/calls/test/sessions", json={"call_type": "default"} ) assert resp.status_code == 201 - resp_json = resp.json() - session_id = resp_json["session_id"] - - assert agent_launcher.get_session(session_id) + session_id = resp.json()["session_id"] - resp = await client.delete(f"/sessions/{session_id}") - assert resp.status_code == 204 - assert agent_launcher.get_session(session_id) is None + resp = await client.delete(f"/calls/test/sessions/{session_id}") + assert resp.status_code == 202 - async def test_close_session_doesnt_exist_fails( + async def test_close_session_not_found( self, agent_launcher, test_client_factory ) -> None: runner = Runner(launcher=agent_launcher) async with test_client_factory(runner) as client: - resp = await client.delete("/sessions/some-id") + resp = await client.delete("/calls/test/sessions/some-id") assert resp.status_code == 404 async def test_close_session_no_permissions_fail( self, agent_launcher, test_client_factory ) -> None: - def can_close(): + def can_close(call_id: str): raise HTTPException(status_code=403) runner = Runner( @@ -198,18 +187,8 @@ def can_close(): ) async with test_client_factory(runner) as client: - resp = await client.post( - "/sessions", json={"call_id": "test", "call_type": "default"} - ) - assert resp.status_code == 201 - resp_json = resp.json() - session_id = resp_json["session_id"] - - assert agent_launcher.get_session(session_id) - - resp = await client.delete(f"/sessions/{session_id}") + resp = await client.delete("/calls/test/sessions/some-id") assert resp.status_code == 403 - assert agent_launcher.get_session(session_id) async def test_close_session_beacon_success( self, agent_launcher, test_client_factory @@ -218,31 +197,27 @@ async def test_close_session_beacon_success( async with test_client_factory(runner) as client: resp = await client.post( - "/sessions", json={"call_id": "test", "call_type": "default"} + "/calls/test/sessions", json={"call_type": "default"} ) assert resp.status_code == 201 - resp_json = resp.json() - session_id = resp_json["session_id"] + session_id = resp.json()["session_id"] - assert agent_launcher.get_session(session_id) - - resp = await client.post(f"/sessions/{session_id}/close") - assert resp.status_code == 200 - assert agent_launcher.get_session(session_id) is None + resp = await client.post(f"/calls/test/sessions/{session_id}/close") + assert resp.status_code == 202 - async def test_close_session_beacon_doesnt_exist_fails( + async def test_close_session_beacon_not_found( self, agent_launcher, test_client_factory ) -> None: runner = Runner(launcher=agent_launcher) async with test_client_factory(runner) as client: - resp = await client.post("/sessions/some-id/close") + resp = await client.post("/calls/test/sessions/some-id/close") assert resp.status_code == 404 async def test_close_session_beacon_no_permissions_fail( self, agent_launcher, test_client_factory ) -> None: - def can_close(): + def can_close(call_id: str): raise HTTPException(status_code=403) runner = Runner( @@ -251,18 +226,8 @@ def can_close(): ) async with test_client_factory(runner) as client: - resp = await client.post( - "/sessions", json={"call_id": "test", "call_type": "default"} - ) - assert resp.status_code == 201 - resp_json = resp.json() - session_id = resp_json["session_id"] - - assert agent_launcher.get_session(session_id) - - resp = await client.post(f"/sessions/{session_id}/close") + resp = await client.post("/calls/test/sessions/some-id/close") assert resp.status_code == 403 - assert agent_launcher.get_session(session_id) async def test_get_session_success( self, agent_launcher, test_client_factory @@ -271,7 +236,7 @@ async def test_get_session_success( async with test_client_factory(runner) as client: resp = await client.post( - "/sessions", json={"call_id": "test", "call_type": "default"} + "/calls/test/sessions", json={"call_type": "default"} ) assert resp.status_code == 201 resp_json = resp.json() @@ -279,7 +244,7 @@ async def test_get_session_success( assert agent_launcher.get_session(session_id) - resp = await client.get(f"/sessions/{session_id}") + resp = await client.get(f"/calls/test/sessions/{session_id}") assert resp.status_code == 200 resp_json = resp.json() assert resp_json["session_id"] == session_id @@ -289,7 +254,7 @@ async def test_get_session_success( async def test_get_session_no_permissions_fail( self, agent_launcher, test_client_factory ) -> None: - def can_view(): + def can_view(call_id: str): raise HTTPException(status_code=403) runner = Runner( @@ -299,7 +264,7 @@ def can_view(): async with test_client_factory(runner) as client: resp = await client.post( - "/sessions", json={"call_id": "test", "call_type": "default"} + "/calls/test/sessions", json={"call_type": "default"} ) assert resp.status_code == 201 resp_json = resp.json() @@ -307,7 +272,7 @@ def can_view(): assert agent_launcher.get_session(session_id) - resp = await client.get(f"/sessions/{session_id}") + resp = await client.get(f"/calls/test/sessions/{session_id}") assert resp.status_code == 403 async def test_get_session_doesnt_exist_404( @@ -316,7 +281,7 @@ async def test_get_session_doesnt_exist_404( runner = Runner(launcher=agent_launcher) async with test_client_factory(runner) as client: - resp = await client.get("/sessions/123123") + resp = await client.get("/calls/test/sessions/123123") assert resp.status_code == 404 async def test_get_session_metrics_success( @@ -326,7 +291,7 @@ async def test_get_session_metrics_success( async with test_client_factory(runner) as client: resp = await client.post( - "/sessions", json={"call_id": "test", "call_type": "default"} + "/calls/test/sessions", json={"call_type": "default"} ) assert resp.status_code == 201 resp_json = resp.json() @@ -341,7 +306,11 @@ async def test_get_session_metrics_success( session.agent.metrics.llm_input_tokens__total.inc(250) session.agent.metrics.llm_output_tokens__total.inc(250) - resp = await client.get(f"/sessions/{session_id}/metrics") + await agent_launcher.registry.update_metrics( + "test", session_id, session.agent.metrics.to_dict() + ) + + resp = await client.get(f"/calls/test/sessions/{session_id}/metrics") assert resp.status_code == 200 resp_json = resp.json() assert resp_json["session_id"] == session_id @@ -362,23 +331,23 @@ async def test_get_session_metrics_doesnt_exist_404( runner = Runner(launcher=agent_launcher) async with test_client_factory(runner) as client: - resp = await client.get("/sessions/123123/metrics") + resp = await client.get("/calls/test/sessions/123123/metrics") assert resp.status_code == 404 async def test_get_session_metrics_no_permissions_fail( self, agent_launcher, test_client_factory ) -> None: - def can_view_metrics(): + def deny_view_metrics(call_id: str): raise HTTPException(status_code=403) runner = Runner( launcher=agent_launcher, - serve_options=ServeOptions(can_view_metrics=can_view_metrics), + serve_options=ServeOptions(can_view_metrics=deny_view_metrics), ) async with test_client_factory(runner) as client: resp = await client.post( - "/sessions", json={"call_id": "test", "call_type": "default"} + "/calls/test/sessions", json={"call_type": "default"} ) assert resp.status_code == 201 resp_json = resp.json() @@ -393,7 +362,7 @@ def can_view_metrics(): session.agent.metrics.llm_input_tokens__total.inc(250) session.agent.metrics.llm_output_tokens__total.inc(250) - resp = await client.get(f"/sessions/{session_id}/metrics") + resp = await client.get(f"/calls/test/sessions/{session_id}/metrics") assert resp.status_code == 403 async def test_fastapi_bypass(self, agent_launcher, test_client_factory) -> None: @@ -413,6 +382,75 @@ def hello_world(): assert resp.status_code == 200 assert resp.content.decode() == "Hello world" + async def test_close_session_permission_receives_call_id( + self, agent_launcher, test_client_factory + ) -> None: + received_call_ids: list[str] = [] + + def can_close(call_id: str): + received_call_ids.append(call_id) + + runner = Runner( + launcher=agent_launcher, + serve_options=ServeOptions(can_close_session=can_close), + ) + + async with test_client_factory(runner) as client: + resp = await client.post( + "/calls/my-call-456/sessions", json={"call_type": "default"} + ) + assert resp.status_code == 201 + session_id = resp.json()["session_id"] + + await client.delete(f"/calls/my-call-456/sessions/{session_id}") + assert received_call_ids == ["my-call-456"] + + async def test_view_session_permission_receives_call_id( + self, agent_launcher, test_client_factory + ) -> None: + received_call_ids: list[str] = [] + + def can_view(call_id: str): + received_call_ids.append(call_id) + + runner = Runner( + launcher=agent_launcher, + serve_options=ServeOptions(can_view_session=can_view), + ) + + async with test_client_factory(runner) as client: + resp = await client.post( + "/calls/my-call-789/sessions", json={"call_type": "default"} + ) + assert resp.status_code == 201 + session_id = resp.json()["session_id"] + + await client.get(f"/calls/my-call-789/sessions/{session_id}") + assert received_call_ids == ["my-call-789"] + + async def test_view_metrics_permission_receives_call_id( + self, agent_launcher, test_client_factory + ) -> None: + received_call_ids: list[str] = [] + + def can_view_m(call_id: str): + received_call_ids.append(call_id) + + runner = Runner( + launcher=agent_launcher, + serve_options=ServeOptions(can_view_metrics=can_view_m), + ) + + async with test_client_factory(runner) as client: + resp = await client.post( + "/calls/my-call-abc/sessions", json={"call_type": "default"} + ) + assert resp.status_code == 201 + session_id = resp.json()["session_id"] + + await client.get(f"/calls/my-call-abc/sessions/{session_id}/metrics") + assert received_call_ids == ["my-call-abc"] + async def test_start_session_max_concurrent_sessions_exceeded( self, agent_launcher_factory, test_client_factory ) -> None: @@ -421,15 +459,17 @@ async def test_start_session_max_concurrent_sessions_exceeded( async with test_client_factory(runner) as client: resp = await client.post( - "/sessions", json={"call_id": "test-1", "call_type": "default"} + "/calls/test-1/sessions", json={"call_type": "default"} ) assert resp.status_code == 201 resp = await client.post( - "/sessions", json={"call_id": "test-2", "call_type": "default"} + "/calls/test-2/sessions", json={"call_type": "default"} ) assert resp.status_code == 429 - assert "Reached maximum concurrent sessions of" in resp.json()["detail"] + assert ( + resp.json()["detail"] == "Reached maximum number of concurrent sessions" + ) async def test_start_session_max_sessions_per_call_exceeded( self, agent_launcher_factory, test_client_factory @@ -439,12 +479,15 @@ async def test_start_session_max_sessions_per_call_exceeded( async with test_client_factory(runner) as client: resp = await client.post( - "/sessions", json={"call_id": "test", "call_type": "default"} + "/calls/test/sessions", json={"call_type": "default"} ) assert resp.status_code == 201 resp = await client.post( - "/sessions", json={"call_id": "test", "call_type": "default"} + "/calls/test/sessions", json={"call_type": "default"} ) assert resp.status_code == 429 - assert "Reached maximum sessions per call of" in resp.json()["detail"] + assert ( + resp.json()["detail"] + == "Reached maximum number of sessions for this call" + ) diff --git a/tests/test_agents/test_session_registry/__init__.py b/tests/test_agents/test_session_registry/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_agents/test_session_registry/test_in_memory_store.py b/tests/test_agents/test_session_registry/test_in_memory_store.py new file mode 100644 index 000000000..2d1e090c0 --- /dev/null +++ b/tests/test_agents/test_session_registry/test_in_memory_store.py @@ -0,0 +1,115 @@ +import asyncio + +import pytest +from vision_agents.core.agents.session_registry.in_memory_store import ( + InMemorySessionKVStore, +) + + +@pytest.fixture() +async def store(): + s = InMemorySessionKVStore() + await s.start() + try: + yield s + finally: + await s.close() + + +class TestInMemorySessionKVStore: + async def test_set_and_get(self, store: InMemorySessionKVStore) -> None: + await store.set("k1", b"hello", ttl=10.0) + assert await store.get("k1") == b"hello" + + async def test_get_missing_key(self, store: InMemorySessionKVStore) -> None: + assert await store.get("nonexistent") is None + + async def test_set_overwrites(self, store: InMemorySessionKVStore) -> None: + await store.set("k1", b"first", ttl=10.0) + await store.set("k1", b"second", ttl=10.0) + assert await store.get("k1") == b"second" + + async def test_ttl_expiry(self, store: InMemorySessionKVStore) -> None: + await store.set("ephemeral", b"bye", ttl=0.5) + await asyncio.sleep(2) + assert await store.get("ephemeral") is None + + async def test_mset_and_mget(self, store: InMemorySessionKVStore) -> None: + await store.mset( + [ + ("a", b"1", 10.0), + ("b", b"2", 10.0), + ("c", b"3", 10.0), + ] + ) + result = await store.mget(["a", "b", "c"]) + assert result == [b"1", b"2", b"3"] + + async def test_mget_partial_missing(self, store: InMemorySessionKVStore) -> None: + await store.mset([("x", b"1", 10.0), ("y", b"2", 10.0)]) + result = await store.mget(["x", "y", "z"]) + assert result == [b"1", b"2", None] + + async def test_mget_empty(self, store: InMemorySessionKVStore) -> None: + assert await store.mget([]) == [] + + async def test_expire_refreshes_ttl(self, store: InMemorySessionKVStore) -> None: + await store.set("refresh_me", b"val", ttl=1.0) + await asyncio.sleep(0.5) + await store.expire("refresh_me", ttl=2.0) + await asyncio.sleep(1.0) + assert await store.get("refresh_me") == b"val" + + async def test_expire_nonexistent_key(self, store: InMemorySessionKVStore) -> None: + await store.expire("ghost", ttl=5.0) + + async def test_expire_multiple_keys(self, store: InMemorySessionKVStore) -> None: + await store.mset([("m1", b"a", 1.0), ("m2", b"b", 1.0)]) + await asyncio.sleep(0.5) + await store.expire("m1", "m2", ttl=2.0) + await asyncio.sleep(1.0) + assert await store.get("m1") == b"a" + assert await store.get("m2") == b"b" + + async def test_keys_with_prefix(self, store: InMemorySessionKVStore) -> None: + await store.mset( + [ + ("sessions/s1", b"a", 10.0), + ("sessions/s2", b"b", 10.0), + ("other/x", b"c", 10.0), + ] + ) + matched = await store.keys("sessions/") + assert sorted(matched) == ["sessions/s1", "sessions/s2"] + + async def test_delete(self, store: InMemorySessionKVStore) -> None: + await store.mset([("d1", b"a", 10.0), ("d2", b"b", 10.0)]) + await store.delete(["d1"]) + assert await store.get("d1") is None + assert await store.get("d2") == b"b" + + async def test_delete_nonexistent(self, store: InMemorySessionKVStore) -> None: + await store.delete(["does_not_exist"]) + + async def test_delete_empty(self, store: InMemorySessionKVStore) -> None: + await store.delete([]) + + async def test_set_only_if_exists_writes_existing_key( + self, store: InMemorySessionKVStore + ) -> None: + await store.set("k1", b"original", ttl=10.0) + await store.set("k1", b"updated", ttl=10.0, only_if_exists=True) + assert await store.get("k1") == b"updated" + + async def test_set_only_if_exists_skips_missing_key( + self, store: InMemorySessionKVStore + ) -> None: + await store.set("ghost", b"value", ttl=10.0, only_if_exists=True) + assert await store.get("ghost") is None + + def test_invalid_cleanup_interval(self) -> None: + with pytest.raises(ValueError, match="cleanup_interval must be > 0"): + InMemorySessionKVStore(cleanup_interval=0) + + with pytest.raises(ValueError, match="cleanup_interval must be > 0"): + InMemorySessionKVStore(cleanup_interval=-1) diff --git a/tests/test_agents/test_session_registry/test_redis_store.py b/tests/test_agents/test_session_registry/test_redis_store.py new file mode 100644 index 000000000..eca10d725 --- /dev/null +++ b/tests/test_agents/test_session_registry/test_redis_store.py @@ -0,0 +1,122 @@ +import asyncio + +import pytest +import redis.asyncio as redis +from testcontainers.redis import RedisContainer +from vision_agents.core.agents.session_registry.redis_store import RedisSessionKVStore + + +@pytest.fixture(scope="module") +def redis_url(): + with RedisContainer() as container: + host = container.get_container_host_ip() + port = container.get_exposed_port(6379) + yield f"redis://{host}:{port}/0" + + +@pytest.fixture() +async def redis_store(redis_url): + client = redis.from_url(redis_url) + store = RedisSessionKVStore(client=client, key_prefix="test:") + await store.start() + try: + yield store + finally: + keys = await store.keys("") + if keys: + await store.delete(keys) + await client.aclose() + + +class TestRedisSessionKVStore: + async def test_set_and_get(self, redis_store: RedisSessionKVStore) -> None: + await redis_store.set("k1", b"hello", ttl=10.0) + assert await redis_store.get("k1") == b"hello" + + async def test_get_missing_key(self, redis_store: RedisSessionKVStore) -> None: + assert await redis_store.get("nonexistent") is None + + async def test_set_overwrites(self, redis_store: RedisSessionKVStore) -> None: + await redis_store.set("k1", b"first", ttl=10.0) + await redis_store.set("k1", b"second", ttl=10.0) + assert await redis_store.get("k1") == b"second" + + async def test_ttl_expiry(self, redis_store: RedisSessionKVStore) -> None: + await redis_store.set("ephemeral", b"bye", ttl=0.5) + await asyncio.sleep(2) + assert await redis_store.get("ephemeral") is None + + async def test_mset_and_mget(self, redis_store: RedisSessionKVStore) -> None: + await redis_store.mset( + [ + ("a", b"1", 10.0), + ("b", b"2", 10.0), + ("c", b"3", 10.0), + ] + ) + result = await redis_store.mget(["a", "b", "c"]) + assert result == [b"1", b"2", b"3"] + + async def test_mget_partial_missing(self, redis_store: RedisSessionKVStore) -> None: + await redis_store.mset([("x", b"1", 10.0), ("y", b"2", 10.0)]) + result = await redis_store.mget(["x", "y", "z"]) + assert result == [b"1", b"2", None] + + async def test_mget_empty(self, redis_store: RedisSessionKVStore) -> None: + assert await redis_store.mget([]) == [] + + async def test_expire_refreshes_ttl(self, redis_store: RedisSessionKVStore) -> None: + await redis_store.set("refresh_me", b"val", ttl=1.0) + await asyncio.sleep(0.5) + await redis_store.expire("refresh_me", ttl=2.0) + await asyncio.sleep(1.0) + assert await redis_store.get("refresh_me") == b"val" + + async def test_expire_nonexistent_key( + self, redis_store: RedisSessionKVStore + ) -> None: + await redis_store.expire("ghost", ttl=5.0) + + async def test_expire_multiple_keys(self, redis_store: RedisSessionKVStore) -> None: + await redis_store.mset([("m1", b"a", 1.0), ("m2", b"b", 1.0)]) + await asyncio.sleep(0.5) + await redis_store.expire("m1", "m2", ttl=2.0) + await asyncio.sleep(1.0) + assert await redis_store.get("m1") == b"a" + assert await redis_store.get("m2") == b"b" + + async def test_keys_with_prefix(self, redis_store: RedisSessionKVStore) -> None: + await redis_store.mset( + [ + ("sessions/s1", b"a", 10.0), + ("sessions/s2", b"b", 10.0), + ("other/x", b"c", 10.0), + ] + ) + matched = await redis_store.keys("sessions/") + assert sorted(matched) == ["sessions/s1", "sessions/s2"] + + async def test_set_only_if_exists_writes_existing_key( + self, redis_store: RedisSessionKVStore + ) -> None: + await redis_store.set("k1", b"original", ttl=10.0) + await redis_store.set("k1", b"updated", ttl=10.0, only_if_exists=True) + assert await redis_store.get("k1") == b"updated" + + async def test_set_only_if_exists_skips_missing_key( + self, redis_store: RedisSessionKVStore + ) -> None: + await redis_store.set("ghost", b"value", ttl=10.0, only_if_exists=True) + assert await redis_store.get("ghost") is None + + async def test_delete(self, redis_store: RedisSessionKVStore) -> None: + await redis_store.mset([("d1", b"a", 10.0), ("d2", b"b", 10.0)]) + await redis_store.delete(["d1"]) + assert await redis_store.get("d1") is None + assert await redis_store.get("d2") == b"b" + + async def test_delete_nonexistent(self, redis_store: RedisSessionKVStore) -> None: + await redis_store.delete(["does_not_exist"]) + + async def test_delete_empty(self, redis_store: RedisSessionKVStore) -> None: + await redis_store.delete([]) diff --git a/tests/test_agents/test_session_registry/test_registry.py b/tests/test_agents/test_session_registry/test_registry.py new file mode 100644 index 000000000..d9b066104 --- /dev/null +++ b/tests/test_agents/test_session_registry/test_registry.py @@ -0,0 +1,198 @@ +import asyncio +import json + +import pytest +import redis.asyncio as redis +from testcontainers.redis import RedisContainer +from vision_agents.core.agents.session_registry import SessionKVStore, SessionRegistry +from vision_agents.core.agents.session_registry.in_memory_store import ( + InMemorySessionKVStore, +) +from vision_agents.core.agents.session_registry.redis_store import RedisSessionKVStore +from vision_agents.core.agents.session_registry.types import SessionInfo + + +@pytest.fixture(scope="module") +def redis_url(): + with RedisContainer() as container: + host = container.get_container_host_ip() + port = container.get_exposed_port(6379) + yield f"redis://{host}:{port}/0" + + +@pytest.fixture() +async def in_memory_store(): + yield InMemorySessionKVStore() + + +@pytest.fixture() +async def redis_store(redis_url): + client = redis.from_url(redis_url) + store = RedisSessionKVStore(client=client, key_prefix="test_reg:") + try: + yield store + finally: + keys = await store.keys("") + if keys: + await store.delete(keys) + await client.aclose() + + +@pytest.fixture(params=["in_memory", "redis"]) +async def registry(request, in_memory_store, redis_store): + if request.param == "in_memory": + store: SessionKVStore = in_memory_store + elif request.param == "redis": + store: SessionKVStore = redis_store + else: + raise ValueError(f"Invalid param {request.param}") + + reg = SessionRegistry(store=store, ttl=5.0) + await reg.start() + try: + yield reg + finally: + await reg.stop() + + +class TestSessionRegistry: + async def test_register_and_get(self, registry: SessionRegistry) -> None: + await registry.register("call-1", "sess-1") + info = await registry.get("call-1", "sess-1") + assert info is not None + assert info.session_id == "sess-1" + assert info.call_id == "call-1" + assert info.node_id == registry.node_id + + async def test_get_for_call(self, registry: SessionRegistry) -> None: + await registry.register("call-multi", "s1") + await registry.register("call-multi", "s2") + sessions = await registry.get_for_call("call-multi") + session_ids = {s.session_id for s in sessions} + assert session_ids == {"s1", "s2"} + + async def test_remove(self, registry: SessionRegistry) -> None: + await registry.register("call-r", "to-remove") + await registry.remove("call-r", "to-remove") + assert await registry.get("call-r", "to-remove") is None + + async def test_refresh_extends_ttl(self, registry: SessionRegistry) -> None: + await registry.register("call-r", "sess-r") + await asyncio.sleep(3.0) + await registry.refresh({"sess-r": "call-r"}) + await asyncio.sleep(3.0) + info = await registry.get("call-r", "sess-r") + assert info is not None + + async def test_request_close_and_get_close_requests( + self, registry: SessionRegistry + ) -> None: + await registry.register("call-c", "sess-close") + await registry.request_close("call-c", "sess-close") + flagged = await registry.get_close_requests( + {"sess-close": "call-c", "other": "call-x"} + ) + assert flagged == ["sess-close"] + + async def test_update_metrics(self, registry: SessionRegistry) -> None: + await registry.register("call-m", "sess-m") + await registry.update_metrics("call-m", "sess-m", {"latency_ms": 42.0}) + info = await registry.get("call-m", "sess-m") + assert info is not None + assert info.metrics["latency_ms"] == 42.0 + + async def test_update_metrics_skipped_for_expired_session( + self, registry: SessionRegistry + ) -> None: + short_registry = SessionRegistry(store=registry._store, ttl=1.0) + await short_registry.register("call-exp", "sess-exp") + await asyncio.sleep(1.5) + await short_registry.update_metrics( + "call-exp", "sess-exp", {"latency_ms": 99.0} + ) + assert await short_registry.get("call-exp", "sess-exp") is None + + async def test_session_expires_without_refresh( + self, registry: SessionRegistry + ) -> None: + short_registry = SessionRegistry(store=registry._store, ttl=1.0) + await short_registry.register("call-e", "sess-expire") + await asyncio.sleep(1.5) + assert await short_registry.get("call-e", "sess-expire") is None + + async def test_get_ignores_extra_keys(self, registry: SessionRegistry) -> None: + data = { + "session_id": "s-extra", + "call_id": "c-extra", + "node_id": registry.node_id, + "started_at": 1.0, + "metrics_updated_at": 1.0, + "metrics": {}, + "unknown_field": "should be ignored", + "another": 42, + } + key = "sessions/c-extra/s-extra" + await registry._store.set(key, json.dumps(data).encode(), 10.0) + + info = await registry.get("c-extra", "s-extra") + assert info is not None + assert info.session_id == "s-extra" + assert info.call_id == "c-extra" + + async def test_get_for_call_ignores_extra_keys( + self, registry: SessionRegistry + ) -> None: + data = { + "session_id": "s-fc", + "call_id": "c-fc", + "node_id": registry.node_id, + "started_at": 1.0, + "metrics_updated_at": 1.0, + "metrics": {}, + "future_field": True, + } + key = "sessions/c-fc/s-fc" + await registry._store.set(key, json.dumps(data).encode(), 10.0) + + sessions = await registry.get_for_call("c-fc") + assert len(sessions) == 1 + assert sessions[0].session_id == "s-fc" + + def test_invalid_ttl(self) -> None: + with pytest.raises(ValueError, match="ttl must be > 0"): + SessionRegistry(ttl=0) + + with pytest.raises(ValueError, match="ttl must be > 0"): + SessionRegistry(ttl=-5.0) + + +class TestSessionInfo: + def test_from_dict_exact_keys(self) -> None: + data = { + "session_id": "s1", + "call_id": "c1", + "node_id": "n1", + "started_at": 1.0, + "metrics_updated_at": 2.0, + "metrics": {"latency": 10}, + } + info = SessionInfo.from_dict(data) + assert info.session_id == "s1" + assert info.metrics == {"latency": 10} + + def test_from_dict_extra_keys_ignored(self) -> None: + data = { + "session_id": "s1", + "call_id": "c1", + "node_id": "n1", + "started_at": 1.0, + "metrics_updated_at": 2.0, + "unknown": "value", + "another_unknown": [1, 2, 3], + } + info = SessionInfo.from_dict(data) + assert info.session_id == "s1" + + def test_from_dict_missing_required_key_raises(self) -> None: + with pytest.raises(TypeError): + SessionInfo.from_dict({"session_id": "s1"}) diff --git a/uv.lock b/uv.lock index d0f601ea6..5d9ed2380 100644 --- a/uv.lock +++ b/uv.lock @@ -65,8 +65,10 @@ dev = [ { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist" }, { name = "python-dotenv" }, + { name = "redis", extras = ["hiredis"], specifier = ">=5.0.0" }, { name = "ruff" }, { name = "scalene", specifier = ">=1.5.54" }, + { name = "testcontainers", extras = ["redis"], specifier = ">=4.0.0" }, { name = "toml", specifier = ">=0.10.2" }, ] @@ -1274,6 +1276,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] +[[package]] +name = "docker" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" }, +] + [[package]] name = "docopt" version = "0.6.2" @@ -1936,6 +1952,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/44/870d44b30e1dcfb6a65932e3e1506c103a8a5aea9103c337e7a53180322c/hf_xet-1.2.0-cp37-abi3-win_amd64.whl", hash = "sha256:e6584a52253f72c9f52f9e549d5895ca7a471608495c4ecaa6cc73dba2b24d69", size = 2905735, upload-time = "2025-10-24T19:04:35.928Z" }, ] +[[package]] +name = "hiredis" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/82/d2817ce0653628e0a0cb128533f6af0dd6318a49f3f3a6a7bd1f2f2154af/hiredis-3.3.0.tar.gz", hash = "sha256:105596aad9249634361815c574351f1bd50455dc23b537c2940066c4a9dea685", size = 89048, upload-time = "2025-10-14T16:33:34.263Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/1c/ed28ae5d704f5c7e85b946fa327f30d269e6272c847fef7e91ba5fc86193/hiredis-3.3.0-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:5b8e1d6a2277ec5b82af5dce11534d3ed5dffeb131fd9b210bc1940643b39b5f", size = 82026, upload-time = "2025-10-14T16:32:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/f4/9b/79f30c5c40e248291023b7412bfdef4ad9a8a92d9e9285d65d600817dac7/hiredis-3.3.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c4981de4d335f996822419e8a8b3b87367fcef67dc5fb74d3bff4df9f6f17783", size = 46217, upload-time = "2025-10-14T16:32:13.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c3/02b9ed430ad9087aadd8afcdf616717452d16271b701fa47edfe257b681e/hiredis-3.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1706480a683e328ae9ba5d704629dee2298e75016aa0207e7067b9c40cecc271", size = 41858, upload-time = "2025-10-14T16:32:13.98Z" }, + { url = "https://files.pythonhosted.org/packages/f1/98/b2a42878b82130a535c7aa20bc937ba2d07d72e9af3ad1ad93e837c419b5/hiredis-3.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a95cef9989736ac313639f8f545b76b60b797e44e65834aabbb54e4fad8d6c8", size = 170195, upload-time = "2025-10-14T16:32:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/9dcde7a75115d3601b016113d9b90300726fa8e48aacdd11bf01a453c145/hiredis-3.3.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca2802934557ccc28a954414c245ba7ad904718e9712cb67c05152cf6b9dd0a3", size = 181808, upload-time = "2025-10-14T16:32:15.622Z" }, + { url = "https://files.pythonhosted.org/packages/56/a1/60f6bda9b20b4e73c85f7f5f046bc2c154a5194fc94eb6861e1fd97ced52/hiredis-3.3.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fe730716775f61e76d75810a38ee4c349d3af3896450f1525f5a4034cf8f2ed7", size = 180578, upload-time = "2025-10-14T16:32:16.514Z" }, + { url = "https://files.pythonhosted.org/packages/d9/01/859d21de65085f323a701824e23ea3330a0ac05f8e184544d7aa5c26128d/hiredis-3.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:749faa69b1ce1f741f5eaf743435ac261a9262e2d2d66089192477e7708a9abc", size = 172508, upload-time = "2025-10-14T16:32:17.411Z" }, + { url = "https://files.pythonhosted.org/packages/99/a8/28fd526e554c80853d0fbf57ef2a3235f00e4ed34ce0e622e05d27d0f788/hiredis-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:95c9427f2ac3f1dd016a3da4e1161fa9d82f221346c8f3fdd6f3f77d4e28946c", size = 166341, upload-time = "2025-10-14T16:32:18.561Z" }, + { url = "https://files.pythonhosted.org/packages/f2/91/ded746b7d2914f557fbbf77be55e90d21f34ba758ae10db6591927c642c8/hiredis-3.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c863ee44fe7bff25e41f3a5105c936a63938b76299b802d758f40994ab340071", size = 176765, upload-time = "2025-10-14T16:32:19.491Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4c/04aa46ff386532cb5f08ee495c2bf07303e93c0acf2fa13850e031347372/hiredis-3.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2213c7eb8ad5267434891f3241c7776e3bafd92b5933fc57d53d4456247dc542", size = 170312, upload-time = "2025-10-14T16:32:20.404Z" }, + { url = "https://files.pythonhosted.org/packages/90/6e/67f9d481c63f542a9cf4c9f0ea4e5717db0312fb6f37fb1f78f3a66de93c/hiredis-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a172bae3e2837d74530cd60b06b141005075db1b814d966755977c69bd882ce8", size = 167965, upload-time = "2025-10-14T16:32:21.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/dde65144d59c3c0d85e43255798f1fa0c48d413e668cfd92b3d9f87924ef/hiredis-3.3.0-cp312-cp312-win32.whl", hash = "sha256:cb91363b9fd6d41c80df9795e12fffbaf5c399819e6ae8120f414dedce6de068", size = 20533, upload-time = "2025-10-14T16:32:22.192Z" }, + { url = "https://files.pythonhosted.org/packages/f5/a9/55a4ac9c16fdf32e92e9e22c49f61affe5135e177ca19b014484e28950f7/hiredis-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:04ec150e95eea3de9ff8bac754978aa17b8bf30a86d4ab2689862020945396b0", size = 22379, upload-time = "2025-10-14T16:32:22.916Z" }, + { url = "https://files.pythonhosted.org/packages/6d/39/2b789ebadd1548ccb04a2c18fbc123746ad1a7e248b7f3f3cac618ca10a6/hiredis-3.3.0-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:b7048b4ec0d5dddc8ddd03da603de0c4b43ef2540bf6e4c54f47d23e3480a4fa", size = 82035, upload-time = "2025-10-14T16:32:23.715Z" }, + { url = "https://files.pythonhosted.org/packages/85/74/4066d9c1093be744158ede277f2a0a4e4cd0fefeaa525c79e2876e9e5c72/hiredis-3.3.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:e5f86ce5a779319c15567b79e0be806e8e92c18bb2ea9153e136312fafa4b7d6", size = 46219, upload-time = "2025-10-14T16:32:24.554Z" }, + { url = "https://files.pythonhosted.org/packages/fa/3f/f9e0f6d632f399d95b3635703e1558ffaa2de3aea4cfcbc2d7832606ba43/hiredis-3.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fbdb97a942e66016fff034df48a7a184e2b7dc69f14c4acd20772e156f20d04b", size = 41860, upload-time = "2025-10-14T16:32:25.356Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c5/b7dde5ec390dabd1cabe7b364a509c66d4e26de783b0b64cf1618f7149fc/hiredis-3.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0fb4bea72fe45ff13e93ddd1352b43ff0749f9866263b5cca759a4c960c776f", size = 170094, upload-time = "2025-10-14T16:32:26.148Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d6/7f05c08ee74d41613be466935688068e07f7b6c55266784b5ace7b35b766/hiredis-3.3.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85b9baf98050e8f43c2826ab46aaf775090d608217baf7af7882596aef74e7f9", size = 181746, upload-time = "2025-10-14T16:32:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d2/aaf9f8edab06fbf5b766e0cae3996324297c0516a91eb2ca3bd1959a0308/hiredis-3.3.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69079fb0f0ebb61ba63340b9c4bce9388ad016092ca157e5772eb2818209d930", size = 180465, upload-time = "2025-10-14T16:32:29.185Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1e/93ded8b9b484519b211fc71746a231af98c98928e3ebebb9086ed20bb1ad/hiredis-3.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c17f77b79031ea4b0967d30255d2ae6e7df0603ee2426ad3274067f406938236", size = 172419, upload-time = "2025-10-14T16:32:30.059Z" }, + { url = "https://files.pythonhosted.org/packages/68/13/02880458e02bbfcedcaabb8f7510f9dda1c89d7c1921b1bb28c22bb38cbf/hiredis-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45d14f745fc177bc05fc24bdf20e2b515e9a068d3d4cce90a0fb78d04c9c9d9a", size = 166400, upload-time = "2025-10-14T16:32:31.173Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/896e03267670570f19f61dc65a2137fcb2b06e83ab0911d58eeec9f3cb88/hiredis-3.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ba063fdf1eff6377a0c409609cbe890389aefddfec109c2d20fcc19cfdafe9da", size = 176845, upload-time = "2025-10-14T16:32:32.12Z" }, + { url = "https://files.pythonhosted.org/packages/f1/90/a1d4bd0cdcf251fda72ac0bd932f547b48ad3420f89bb2ef91bf6a494534/hiredis-3.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:1799cc66353ad066bfdd410135c951959da9f16bcb757c845aab2f21fc4ef099", size = 170365, upload-time = "2025-10-14T16:32:33.035Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9a/7c98f7bb76bdb4a6a6003cf8209721f083e65d2eed2b514f4a5514bda665/hiredis-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2cbf71a121996ffac82436b6153290815b746afb010cac19b3290a1644381b07", size = 168022, upload-time = "2025-10-14T16:32:34.81Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/672ee658ffe9525558615d955b554ecd36aa185acd4431ccc9701c655c9b/hiredis-3.3.0-cp313-cp313-win32.whl", hash = "sha256:a7cbbc6026bf03659f0b25e94bbf6e64f6c8c22f7b4bc52fe569d041de274194", size = 20533, upload-time = "2025-10-14T16:32:35.7Z" }, + { url = "https://files.pythonhosted.org/packages/20/93/511fd94f6a7b6d72a4cf9c2b159bf3d780585a9a1dca52715dd463825299/hiredis-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:a8def89dd19d4e2e4482b7412d453dec4a5898954d9a210d7d05f60576cedef6", size = 22387, upload-time = "2025-10-14T16:32:36.441Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b3/b948ee76a6b2bc7e45249861646f91f29704f743b52565cf64cee9c4658b/hiredis-3.3.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c135bda87211f7af9e2fd4e046ab433c576cd17b69e639a0f5bb2eed5e0e71a9", size = 82105, upload-time = "2025-10-14T16:32:37.204Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/4210f4ebfb3ab4ada964b8de08190f54cbac147198fb463cd3c111cc13e0/hiredis-3.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f855c678230aed6fc29b962ce1cc67e5858a785ef3a3fd6b15dece0487a2e60", size = 46237, upload-time = "2025-10-14T16:32:38.07Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7a/e38bfd7d04c05036b4ccc6f42b86b1032185cf6ae426e112a97551fece14/hiredis-3.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4059c78a930cbb33c391452ccce75b137d6f89e2eebf6273d75dafc5c2143c03", size = 41894, upload-time = "2025-10-14T16:32:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/28/d3/eae43d9609c5d9a6effef0586ee47e13a0d84b44264b688d97a75cd17ee5/hiredis-3.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:334a3f1d14c253bb092e187736c3384203bd486b244e726319bbb3f7dffa4a20", size = 170486, upload-time = "2025-10-14T16:32:40.147Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fd/34d664554880b27741ab2916d66207357563b1639e2648685f4c84cfb755/hiredis-3.3.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd137b147235447b3d067ec952c5b9b95ca54b71837e1b38dbb2ec03b89f24fc", size = 182031, upload-time = "2025-10-14T16:32:41.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/a3/0c69fdde3f4155b9f7acc64ccffde46f312781469260061b3bbaa487fd34/hiredis-3.3.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f88f4f2aceb73329ece86a1cb0794fdbc8e6d614cb5ca2d1023c9b7eb432db8", size = 180542, upload-time = "2025-10-14T16:32:42.993Z" }, + { url = "https://files.pythonhosted.org/packages/68/7a/ad5da4d7bc241e57c5b0c4fe95aa75d1f2116e6e6c51577394d773216e01/hiredis-3.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:550f4d1538822fc75ebf8cf63adc396b23d4958bdbbad424521f2c0e3dfcb169", size = 172353, upload-time = "2025-10-14T16:32:43.965Z" }, + { url = "https://files.pythonhosted.org/packages/4b/dc/c46eace64eb047a5b31acd5e4b0dc6d2f0390a4a3f6d507442d9efa570ad/hiredis-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54b14211fbd5930fc696f6fcd1f1f364c660970d61af065a80e48a1fa5464dd6", size = 166435, upload-time = "2025-10-14T16:32:44.97Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ac/ad13a714e27883a2e4113c980c94caf46b801b810de5622c40f8d3e8335f/hiredis-3.3.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9e96f63dbc489fc86f69951e9f83dadb9582271f64f6822c47dcffa6fac7e4a", size = 177218, upload-time = "2025-10-14T16:32:45.936Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/268fabd85b225271fe1ba82cb4a484fcc1bf922493ff2c74b400f1a6f339/hiredis-3.3.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:106e99885d46684d62ab3ec1d6b01573cc0e0083ac295b11aaa56870b536c7ec", size = 170477, upload-time = "2025-10-14T16:32:46.898Z" }, + { url = "https://files.pythonhosted.org/packages/20/6b/02bb8af810ea04247334ab7148acff7a61c08a8832830c6703f464be83a9/hiredis-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:087e2ef3206361281b1a658b5b4263572b6ba99465253e827796964208680459", size = 167915, upload-time = "2025-10-14T16:32:47.847Z" }, + { url = "https://files.pythonhosted.org/packages/83/94/901fa817e667b2e69957626395e6dee416e31609dca738f28e6b545ca6c2/hiredis-3.3.0-cp314-cp314-win32.whl", hash = "sha256:80638ebeab1cefda9420e9fedc7920e1ec7b4f0513a6b23d58c9d13c882f8065", size = 21165, upload-time = "2025-10-14T16:32:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7e/4881b9c1d0b4cdaba11bd10e600e97863f977ea9d67c5988f7ec8cd363e5/hiredis-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a68aaf9ba024f4e28cf23df9196ff4e897bd7085872f3a30644dca07fa787816", size = 22996, upload-time = "2025-10-14T16:32:51.543Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b6/d7e6c17da032665a954a89c1e6ee3bd12cb51cd78c37527842b03519981d/hiredis-3.3.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:f7f80442a32ce51ee5d89aeb5a84ee56189a0e0e875f1a57bbf8d462555ae48f", size = 83034, upload-time = "2025-10-14T16:32:52.395Z" }, + { url = "https://files.pythonhosted.org/packages/27/6c/6751b698060cdd1b2d8427702cff367c9ed7a1705bcf3792eb5b896f149b/hiredis-3.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a1a67530da714954ed50579f4fe1ab0ddbac9c43643b1721c2cb226a50dde263", size = 46701, upload-time = "2025-10-14T16:32:53.572Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8e/20a5cf2c83c7a7e08c76b9abab113f99f71cd57468a9c7909737ce6e9bf8/hiredis-3.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:616868352e47ab355559adca30f4f3859f9db895b4e7bc71e2323409a2add751", size = 42381, upload-time = "2025-10-14T16:32:54.762Z" }, + { url = "https://files.pythonhosted.org/packages/be/0a/547c29c06e8c9c337d0df3eec39da0cf1aad701daf8a9658dd37f25aca66/hiredis-3.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e799b79f3150083e9702fc37e6243c0bd47a443d6eae3f3077b0b3f510d6a145", size = 180313, upload-time = "2025-10-14T16:32:55.644Z" }, + { url = "https://files.pythonhosted.org/packages/89/8a/488de5469e3d0921a1c425045bf00e983d48b2111a90e47cf5769eaa536c/hiredis-3.3.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ef1dfb0d2c92c3701655e2927e6bbe10c499aba632c7ea57b6392516df3864b", size = 190488, upload-time = "2025-10-14T16:32:56.649Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/8493edc3eb9ae0dbea2b2230c2041a52bc03e390b02ffa3ac0bca2af9aea/hiredis-3.3.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c290da6bc2a57e854c7da9956cd65013483ede935677e84560da3b848f253596", size = 189210, upload-time = "2025-10-14T16:32:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/f0/de/8c9a653922057b32fb1e2546ecd43ef44c9aa1a7cf460c87cae507eb2bc7/hiredis-3.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd8c438d9e1728f0085bf9b3c9484d19ec31f41002311464e75b69550c32ffa8", size = 180972, upload-time = "2025-10-14T16:32:58.737Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a3/51e6e6afaef2990986d685ca6e254ffbd191f1635a59b2d06c9e5d10c8a2/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1bbc6b8a88bbe331e3ebf6685452cebca6dfe6d38a6d4efc5651d7e363ba28bd", size = 175315, upload-time = "2025-10-14T16:32:59.774Z" }, + { url = "https://files.pythonhosted.org/packages/96/54/e436312feb97601f70f8b39263b8da5ac4a5d18305ebdfb08ad7621f6119/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:55d8c18fe9a05496c5c04e6eccc695169d89bf358dff964bcad95696958ec05f", size = 185653, upload-time = "2025-10-14T16:33:00.749Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a3/88e66030d066337c6c0f883a912c6d4b2d6d7173490fbbc113a6cbe414ff/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4ddc79afa76b805d364e202a754666cb3c4d9c85153cbfed522871ff55827838", size = 179032, upload-time = "2025-10-14T16:33:01.711Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1f/fb7375467e9adaa371cd617c2984fefe44bdce73add4c70b8dd8cab1b33a/hiredis-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e8a4b8540581dcd1b2b25827a54cfd538e0afeaa1a0e3ca87ad7126965981cc", size = 176127, upload-time = "2025-10-14T16:33:02.793Z" }, + { url = "https://files.pythonhosted.org/packages/66/14/0dc2b99209c400f3b8f24067273e9c3cb383d894e155830879108fb19e98/hiredis-3.3.0-cp314-cp314t-win32.whl", hash = "sha256:298593bb08487753b3afe6dc38bac2532e9bac8dcee8d992ef9977d539cc6776", size = 22024, upload-time = "2025-10-14T16:33:03.812Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/8a0befeed8bbe142d5a6cf3b51e8cbe019c32a64a596b0ebcbc007a8f8f1/hiredis-3.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b442b6ab038a6f3b5109874d2514c4edf389d8d8b553f10f12654548808683bc", size = 23808, upload-time = "2025-10-14T16:33:04.965Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -5258,6 +5334,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, ] +[package.optional-dependencies] +hiredis = [ + { name = "hiredis" }, +] + [[package]] name = "referencing" version = "0.36.2" @@ -6229,6 +6310,27 @@ wheels = [ { 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]] +name = "testcontainers" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/02/ef62dec9e4f804189c44df23f0b86897c738d38e9c48282fcd410308632f/testcontainers-4.14.1.tar.gz", hash = "sha256:316f1bb178d829c003acd650233e3ff3c59a833a08d8661c074f58a4fbd42a64", size = 80148, upload-time = "2026-01-31T23:13:46.915Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/31/5e7b23f9e43ff7fd46d243808d70c5e8daf3bc08ecf5a7fb84d5e38f7603/testcontainers-4.14.1-py3-none-any.whl", hash = "sha256:03dfef4797b31c82e7b762a454b6afec61a2a512ad54af47ab41e4fa5415f891", size = 125640, upload-time = "2026-01-31T23:13:45.464Z" }, +] + +[package.optional-dependencies] +redis = [ + { name = "redis" }, +] + [[package]] name = "thinc" version = "8.3.8" @@ -6934,6 +7036,9 @@ pocket = [ qwen = [ { name = "vision-agents-plugins-qwen" }, ] +redis = [ + { name = "redis", extra = ["hiredis"] }, +] roboflow = [ { name = "vision-agents-plugins-roboflow" }, ] @@ -6972,6 +7077,7 @@ requires-dist = [ { name = "pillow", specifier = ">=10.4.0" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "python-dotenv", specifier = ">=1.1.1" }, + { name = "redis", extras = ["hiredis"], marker = "extra == 'redis'", specifier = ">=5.0.0" }, { name = "ruff", marker = "extra == 'dev'" }, { name = "urllib3", specifier = ">=2.6.3" }, { name = "uvicorn", specifier = ">=0.38.0" }, @@ -7038,7 +7144,7 @@ requires-dist = [ { name = "vision-agents-plugins-xai", marker = "extra == 'all-plugins'", editable = "plugins/xai" }, { name = "vision-agents-plugins-xai", marker = "extra == 'xai'", editable = "plugins/xai" }, ] -provides-extras = ["all-plugins", "anthropic", "aws", "cartesia", "decart", "deepgram", "dev", "elevenlabs", "fast-whisper", "fish", "gemini", "getstream", "heygen", "huggingface", "inworld", "kokoro", "lemonslice", "mistral", "moondream", "moonshine", "nvidia", "openai", "openrouter", "pocket", "qwen", "roboflow", "smart-turn", "turbopuffer", "twilio", "ultralytics", "vogent", "wizper", "xai"] +provides-extras = ["all-plugins", "anthropic", "aws", "cartesia", "decart", "deepgram", "dev", "elevenlabs", "fast-whisper", "fish", "gemini", "getstream", "heygen", "huggingface", "inworld", "kokoro", "lemonslice", "mistral", "moondream", "moonshine", "nvidia", "openai", "openrouter", "pocket", "qwen", "redis", "roboflow", "smart-turn", "turbopuffer", "twilio", "ultralytics", "vogent", "wizper", "xai"] [[package]] name = "vision-agents-plugins-anthropic"