diff --git a/agentops/__init__.py b/agentops/__init__.py index 4150f839a..e2f9757ad 100755 --- a/agentops/__init__.py +++ b/agentops/__init__.py @@ -1,53 +1,40 @@ -# agentops/__init__.py -import sys -from typing import Optional, List, Union +from typing import TYPE_CHECKING, List, Optional, Union from .client import Client -from .event import Event, ActionEvent, LLMEvent, ToolEvent, ErrorEvent -from .decorators import record_action, track_agent, record_tool, record_function -from .helpers import check_agentops_update -from .log_config import logger from .session import Session -import threading -from importlib.metadata import version as get_version -from packaging import version -from .llms import tracker - -try: - from .partners.langchain_callback_handler import ( - LangchainCallbackHandler, - AsyncLangchainCallbackHandler, - ) -except ModuleNotFoundError: - pass -if "autogen" in sys.modules: - Client().configure(instrument_llm_calls=False) - Client()._initialize_autogen_logger() - Client().add_default_tags(["autogen"]) +# Import semantic conventions +from .semconv import SpanKind, CoreAttributes, AgentAttributes, ToolAttributes, ToolStatus -if "crewai" in sys.modules: - crew_version = version.parse(get_version("crewai")) +# Import decorators +from .decorators import session, agent, tool, span, create_span, current_span, add_span_attribute, add_span_event - # uses langchain, greater versions will use litellm and default is to instrument - if crew_version < version.parse("0.56.0"): - Client().configure(instrument_llm_calls=False) +from opentelemetry.sdk.trace import SpanProcessor +from opentelemetry.sdk.trace.export import SpanExporter - Client().add_default_tags(["crewai"]) +# Client global instance; one per process runtime +_client = Client() def init( api_key: Optional[str] = None, - parent_key: Optional[str] = None, endpoint: Optional[str] = None, max_wait_time: Optional[int] = None, max_queue_size: Optional[int] = None, - tags: Optional[List[str]] = None, # Deprecated + tags: Optional[List[str]] = None, default_tags: Optional[List[str]] = None, instrument_llm_calls: Optional[bool] = None, auto_start_session: Optional[bool] = None, - inherited_session_id: Optional[str] = None, + auto_init: Optional[bool] = None, skip_auto_end_session: Optional[bool] = None, + env_data_opt_out: Optional[bool] = None, + log_level: Optional[Union[str, int]] = None, + fail_safe: Optional[bool] = None, + exporter: Optional[SpanExporter] = None, + processor: Optional[SpanProcessor] = None, + exporter_endpoint: Optional[str] = None, + metrics_exporter_endpoint: Optional[str] = None, + **kwargs, ) -> Union[Session, None]: """ Initializes the AgentOps singleton pattern. @@ -55,8 +42,6 @@ def init( Args: api_key (str, optional): API Key for AgentOps services. If none is provided, key will be read from the AGENTOPS_API_KEY environment variable. - parent_key (str, optional): Organization key to give visibility of all user sessions the user's organization. If none is provided, key will - be read from the AGENTOPS_PARENT_KEY environment variable. endpoint (str, optional): The endpoint for the AgentOps service. If none is provided, key will be read from the AGENTOPS_API_ENDPOINT environment variable. Defaults to 'https://api.agentops.ai'. max_wait_time (int, optional): The maximum time to wait in milliseconds before flushing the queue. @@ -66,108 +51,111 @@ def init( default_tags (List[str], optional): Default tags for the sessions that can be used for grouping or sorting later (e.g. ["GPT-4"]). instrument_llm_calls (bool): Whether to instrument LLM calls and emit LLMEvents. auto_start_session (bool): Whether to start a session automatically when the client is created. - inherited_session_id (optional, str): Init Agentops with an existing Session + auto_init (bool): Whether to automatically initialize the client on import. Defaults to True. skip_auto_end_session (optional, bool): Don't automatically end session based on your framework's decision-making (i.e. Crew determining when tasks are complete and ending the session) - Attributes: - """ - Client().unsuppress_logs() - t = threading.Thread(target=check_agentops_update) - t.start() - if Client().is_initialized: - return logger.warning( - "AgentOps has already been initialized. If you are trying to start a session, call agentops.start_session() instead." - ) - - if tags is not None: - logger.warning("The 'tags' parameter is deprecated. Use 'default_tags' instead") - if default_tags is None: - default_tags = tags - - Client().configure( + env_data_opt_out (bool): Whether to opt out of collecting environment data. + log_level (str, int): The log level to use for the client. Defaults to 'CRITICAL'. + fail_safe (bool): Whether to suppress errors and continue execution when possible. + exporter (SpanExporter): Custom span exporter for OpenTelemetry trace data. If provided, + will be used instead of the default OTLPSpanExporter. Not needed if processor is specified. + processor (SpanProcessor): Custom span processor for OpenTelemetry trace data. If provided, + takes precedence over exporter. Used for complete control over span processing. + exporter_endpoint (str, optional): Endpoint for the trace exporter. If none is provided, key will + be read from the AGENTOPS_EXPORTER_ENDPOINT environment variable. + metrics_exporter_endpoint (str, optional): Endpoint for the metrics exporter. If none is provided, key will + be read from the AGENTOPS_METRICS_EXPORTER_ENDPOINT environment variable. + **kwargs: Additional configuration parameters to be passed to the client. + """ + # Merge tags and default_tags if both are provided + merged_tags = None + if tags and default_tags: + merged_tags = list(set(tags + default_tags)) + elif tags: + merged_tags = tags + elif default_tags: + merged_tags = default_tags + + return _client.init( api_key=api_key, - parent_key=parent_key, endpoint=endpoint, max_wait_time=max_wait_time, max_queue_size=max_queue_size, - default_tags=default_tags, + default_tags=merged_tags, instrument_llm_calls=instrument_llm_calls, auto_start_session=auto_start_session, + auto_init=auto_init, skip_auto_end_session=skip_auto_end_session, + env_data_opt_out=env_data_opt_out, + log_level=log_level, + fail_safe=fail_safe, + exporter=exporter, + processor=processor, + exporter_endpoint=exporter_endpoint, + metrics_exporter_endpoint=metrics_exporter_endpoint, + **kwargs, ) - if inherited_session_id is not None: - if auto_start_session == False: - Client().add_pre_init_warning( - "auto_start_session is set to False - inherited_session_id will not be used to automatically start a session" - ) - return Client().initialize() - Client().configure(auto_start_session=False) - Client().initialize() - return Client().start_session(inherited_session_id=inherited_session_id) - - return Client().initialize() - -def configure( - api_key: Optional[str] = None, - parent_key: Optional[str] = None, - endpoint: Optional[str] = None, - max_wait_time: Optional[int] = None, - max_queue_size: Optional[int] = None, - default_tags: Optional[List[str]] = None, - instrument_llm_calls: Optional[bool] = None, - auto_start_session: Optional[bool] = None, - skip_auto_end_session: Optional[bool] = None, -): - """ - Configure the AgentOps Client +def configure(**kwargs): + """Update client configuration Args: - api_key (str, optional): API Key for AgentOps services. - parent_key (str, optional): Organization key to give visibility of all user sessions the user's organization. - endpoint (str, optional): The endpoint for the AgentOps service. - max_wait_time (int, optional): The maximum time to wait in milliseconds before flushing the queue. - max_queue_size (int, optional): The maximum size of the event queue - default_tags (List[str], optional): Default tags for the sessions that can be used for grouping or sorting later (e.g. ["GPT-4"]). - instrument_llm_calls (bool, optional): Whether to instrument LLM calls and emit LLMEvents. - auto_start_session (bool, optional): Whether to start a session automatically when the client is created. - skip_auto_end_session (bool, optional): Don't automatically end session based on your framework's decision-making - (i.e. Crew determining when tasks are complete and ending the session) - """ - Client().configure( - api_key=api_key, - parent_key=parent_key, - endpoint=endpoint, - max_wait_time=max_wait_time, - max_queue_size=max_queue_size, - default_tags=default_tags, - instrument_llm_calls=instrument_llm_calls, - auto_start_session=auto_start_session, - skip_auto_end_session=skip_auto_end_session, - ) - - -def start_session( - tags: Optional[List[str]] = None, - inherited_session_id: Optional[str] = None, -) -> Union[Session, None]: - """ - Start a new session for recording events. + **kwargs: Configuration parameters. Supported parameters include: + - api_key: API Key for AgentOps services + - endpoint: The endpoint for the AgentOps service + - max_wait_time: Maximum time to wait in milliseconds before flushing the queue + - max_queue_size: Maximum size of the event queue + - default_tags: Default tags for the sessions + - instrument_llm_calls: Whether to instrument LLM calls + - auto_start_session: Whether to start a session automatically + - skip_auto_end_session: Don't automatically end session + - env_data_opt_out: Whether to opt out of collecting environment data + - log_level: The log level to use for the client + - fail_safe: Whether to suppress errors and continue execution + - exporter: Custom span exporter for OpenTelemetry trace data + - processor: Custom span processor for OpenTelemetry trace data + - exporter_endpoint: Endpoint for the exporter + """ + # List of valid parameters that can be passed to configure + valid_params = { + "api_key", + "endpoint", + "max_wait_time", + "max_queue_size", + "default_tags", + "instrument_llm_calls", + "auto_start_session", + "skip_auto_end_session", + "env_data_opt_out", + "log_level", + "fail_safe", + "exporter", + "processor", + "exporter_endpoint", + } + + # Check for invalid parameters + invalid_params = set(kwargs.keys()) - valid_params + if invalid_params: + from .logging.config import logger + + logger.warning(f"Invalid configuration parameters: {invalid_params}") + + _client.configure(**kwargs) + + +def start_session(**kwargs) -> Optional[Session]: + """Start a new session for recording events. Args: tags (List[str], optional): Tags that can be used for grouping or sorting later. - e.g. ["test_run"]. - inherited_session_id: (str, optional): Set the session ID to inherit from another client - """ - Client().unsuppress_logs() + e.g. ["test_run"] - if not Client().is_initialized: - return logger.warning( - "AgentOps has not been initialized yet. Please call agentops.init() before starting a session" - ) - - return Client().start_session(tags, inherited_session_id) + Returns: + Optional[Session]: Returns Session if successful, None otherwise. + """ + return _client.start_session(**kwargs) def end_session( @@ -184,67 +172,29 @@ def end_session( end_state_reason (str, optional): The reason for ending the session. video (str, optional): URL to a video recording of the session """ - Client().unsuppress_logs() - - if Client().is_multi_session: - return logger.warning( - "Could not end session - multiple sessions detected. You must use session.end_session() instead of agentops.end_session()" - + " More info: https://docs.agentops.ai/v1/concepts/core-concepts#session-management" - ) - - if not Client().has_sessions: - return logger.warning("Could not end session - no sessions detected") - - Client().end_session( - end_state=end_state, - end_state_reason=end_state_reason, - video=video, - is_auto_end=is_auto_end, - ) + _client.end_session(end_state, end_state_reason, video, is_auto_end) -def record(event: Union[Event, ErrorEvent]): +def record(): """ Record an event with the AgentOps service. Args: event (Event): The event to record. """ - Client().unsuppress_logs() - - if Client().is_multi_session: - return logger.warning( - "Could not record event - multiple sessions detected. You must use session.record() instead of agentops.record()" - + " More info: https://docs.agentops.ai/v1/concepts/core-concepts#session-management" - ) - - if not Client().has_sessions: - return logger.warning( - "Could not record event - no sessions detected. Create a session by calling agentops.start_session()" - ) - - Client().record(event) + raise NotImplementedError def add_tags(tags: List[str]): """ Append to session tags at runtime. + TODO: How do we retrieve the session context to add tags to? + Args: tags (List[str]): The list of tags to append. """ - if Client().is_multi_session: - return logger.warning( - "Could not add tags to session - multiple sessions detected. You must use session.add_tags() instead of agentops.add_tags()" - + " More info: https://docs.agentops.ai/v1/concepts/core-concepts#session-management" - ) - - if not Client().has_sessions: - return logger.warning( - "Could not add tags to session - no sessions detected. Create a session by calling agentops.start_session()" - ) - - Client().add_tags(tags) + _client.add_tags(tags) def set_tags(tags: List[str]): @@ -254,71 +204,17 @@ def set_tags(tags: List[str]): Args: tags (List[str]): The list of tags to set. """ - if Client().is_multi_session: - return logger.warning( - "Could not set tags on session - multiple sessions detected. You must use session.set_tags() instead of agentops.set_tags()" - + " More info: https://docs.agentops.ai/v1/concepts/core-concepts#session-management" - ) - - if not Client().has_sessions: - return logger.warning( - "Could not set tags on session - no sessions detected. Create a session by calling agentops.start_session()" - ) - - Client().set_tags(tags) - - -def get_api_key() -> Union[str, None]: - return Client().api_key - - -def set_api_key(api_key: str) -> None: - Client().configure(api_key=api_key) - - -def set_parent_key(parent_key: str): - """ - Set the parent API key so another organization can view data. - - Args: - parent_key (str): The API key of the parent organization to set. - """ - Client().configure(parent_key=parent_key) - - -def stop_instrumenting(): - if Client().is_initialized: - Client().stop_instrumenting() - - -def create_agent(name: str, agent_id: Optional[str] = None): - if Client().is_multi_session: - return logger.warning( - "Could not create agent - multiple sessions detected. You must use session.create_agent() instead of agentops.create_agent()" - + " More info: https://docs.agentops.ai/v1/concepts/core-concepts#session-management" - ) - - if not Client().has_sessions: - return logger.warning( - "Could not create agent - no sessions detected. Create a session by calling agentops.start_session()" - ) - - return Client().create_agent(name=name, agent_id=agent_id) - - -def get_session(session_id: str): - """ - Get an active (not ended) session from the AgentOps service - - Args: - session_id (str): the session id for the session to be retreived - """ - Client().unsuppress_logs() - - return Client().get_session(session_id) + _client.set_tags(tags) # Mostly used for unit testing - # prevents unexpected sessions on new tests def end_all_sessions() -> None: - return Client().end_all_sessions() + """End all active sessions""" + _client.end_all_sessions() + + +# For backwards compatibility and testing +def get_client() -> Client: + """Get the singleton client instance""" + return _client diff --git a/agentops/cli.py b/agentops/cli.py deleted file mode 100644 index 29a81123e..000000000 --- a/agentops/cli.py +++ /dev/null @@ -1,35 +0,0 @@ -import argparse -from .time_travel import fetch_time_travel_id, set_time_travel_active_state - - -def main(): - parser = argparse.ArgumentParser(description="AgentOps CLI") - subparsers = parser.add_subparsers(dest="command") - - timetravel_parser = subparsers.add_parser("timetravel", help="Time Travel Debugging commands", aliases=["tt"]) - timetravel_parser.add_argument( - "branch_name", - type=str, - nargs="?", - help="Given a branch name, fetches the cache file for Time Travel Debugging. Turns on feature by default", - ) - timetravel_parser.add_argument( - "--on", - action="store_true", - help="Turns on Time Travel Debugging", - ) - timetravel_parser.add_argument( - "--off", - action="store_true", - help="Turns off Time Travel Debugging", - ) - - args = parser.parse_args() - - if args.command in ["timetravel", "tt"]: - if args.branch_name: - fetch_time_travel_id(args.branch_name) - if args.on: - set_time_travel_active_state(True) - if args.off: - set_time_travel_active_state(False) diff --git a/agentops/client.py b/agentops/client.py deleted file mode 100644 index fb3e17937..000000000 --- a/agentops/client.py +++ /dev/null @@ -1,439 +0,0 @@ -""" -AgentOps client module that provides a client class with public interfaces and configuration. - -Classes: - Client: Provides methods to interact with the AgentOps service. -""" - -import atexit -import inspect -import logging -import os -import signal -import sys -import threading -import traceback -from decimal import Decimal -from functools import cached_property -from typing import List, Optional, Tuple, Union -from uuid import UUID, uuid4 - -from termcolor import colored - -from .config import Configuration -from .event import ErrorEvent, Event -from .host_env import get_host_env -from .llms.tracker import LlmTracker -from .log_config import logger -from .meta_client import MetaClient -from .session import Session, active_sessions -from .singleton import conditional_singleton - - -@conditional_singleton -class Client(metaclass=MetaClient): - def __init__(self): - self._pre_init_messages: List[str] = [] - self._initialized: bool = False - self._llm_tracker: Optional[LlmTracker] = None - self._sessions: List[Session] = active_sessions - self._config = Configuration() - self._pre_init_queue = {"agents": []} - self._host_env = None # Cache host env data - - self.configure( - api_key=os.environ.get("AGENTOPS_API_KEY"), - parent_key=os.environ.get("AGENTOPS_PARENT_KEY"), - endpoint=os.environ.get("AGENTOPS_API_ENDPOINT"), - env_data_opt_out=os.environ.get("AGENTOPS_ENV_DATA_OPT_OUT", "False").lower() == "true", - ) - - def configure( - self, - api_key: Optional[str] = None, - parent_key: Optional[str] = None, - endpoint: Optional[str] = None, - max_wait_time: Optional[int] = None, - max_queue_size: Optional[int] = None, - default_tags: Optional[List[str]] = None, - instrument_llm_calls: Optional[bool] = None, - auto_start_session: Optional[bool] = None, - skip_auto_end_session: Optional[bool] = None, - env_data_opt_out: Optional[bool] = None, - ): - if self.has_sessions: - return logger.warning( - f"{len(self._sessions)} session(s) in progress. Configuration is locked until there are no more sessions running" - ) - - self._config.configure( - self, - api_key=api_key, - parent_key=parent_key, - endpoint=endpoint, - max_wait_time=max_wait_time, - max_queue_size=max_queue_size, - default_tags=default_tags, - instrument_llm_calls=instrument_llm_calls, - auto_start_session=auto_start_session, - skip_auto_end_session=skip_auto_end_session, - env_data_opt_out=env_data_opt_out, - ) - - def initialize(self) -> Union[Session, None]: - if self.is_initialized: - return - - self.unsuppress_logs() - if self._config.api_key is None: - return logger.error( - "Could not initialize AgentOps client - API Key is missing." - + "\n\t Find your API key at https://app.agentops.ai/settings/projects" - ) - - self._handle_unclean_exits() - self._initialized = True - - if self._config.instrument_llm_calls: - self._llm_tracker = LlmTracker(self) - self._llm_tracker.override_api() - - session = None - if self._config.auto_start_session: - session = self.start_session() - - if session: - for agent_args in self._pre_init_queue["agents"]: - session.create_agent(name=agent_args["name"], agent_id=agent_args["agent_id"]) - self._pre_init_queue["agents"] = [] - - return session - - def _initialize_autogen_logger(self) -> None: - try: - import autogen - - from .partners.autogen_logger import AutogenLogger - - autogen.runtime_logging.start(logger=AutogenLogger()) - except ImportError: - pass - except Exception as e: - logger.warning(f"Failed to set up AutoGen logger with AgentOps. Error: {e}") - - def add_tags(self, tags: List[str]) -> None: - """ - Append to session tags at runtime. - - Args: - tags (List[str]): The list of tags to append. - """ - if not self.is_initialized: - return - - # if a string and not a list of strings - if not (isinstance(tags, list) and all(isinstance(item, str) for item in tags)): - if isinstance(tags, str): # if it's a single string - tags = [tags] # make it a list - - session = self._safe_get_session() - if session is None: - return logger.warning("Could not add tags. Start a session by calling agentops.start_session().") - - session.add_tags(tags=tags) - - self._update_session(session) - - def set_tags(self, tags: List[str]) -> None: - """ - Replace session tags at runtime. - - Args: - tags (List[str]): The list of tags to set. - """ - if not self.is_initialized: - return - - session = self._safe_get_session() - - if session is None: - return logger.warning("Could not set tags. Start a session by calling agentops.start_session().") - else: - session.set_tags(tags=tags) - - def add_default_tags(self, tags: List[str]) -> None: - """ - Append default tags at runtime. - - Args: - tags (List[str]): The list of tags to set. - """ - self._config.default_tags.update(tags) - - def get_default_tags(self) -> List[str]: - """ - Append default tags at runtime. - - Args: - tags (List[str]): The list of tags to set. - """ - return list(self._config.default_tags) - - def record(self, event: Union[Event, ErrorEvent]) -> None: - """ - Record an event with the AgentOps service. - - Args: - event (Event): The event to record. - """ - if not self.is_initialized: - return - - session = self._safe_get_session() - if session is None: - return logger.error("Could not record event. Start a session by calling agentops.start_session().") - session.record(event) - - def start_session( - self, - tags: Optional[List[str]] = None, - inherited_session_id: Optional[str] = None, - ) -> Union[Session, None]: - """ - Start a new session for recording events. - - Args: - tags (List[str], optional): Tags that can be used for grouping or sorting later. - e.g. ["test_run"]. - config: (Configuration, optional): Client configuration object - inherited_session_id (optional, str): assign session id to match existing Session - """ - if not self.is_initialized: - return - - if inherited_session_id is not None: - try: - session_id = UUID(inherited_session_id) - except ValueError: - return logger.warning(f"Invalid session id: {inherited_session_id}") - else: - session_id = uuid4() - - session_tags = self._config.default_tags.copy() - if tags is not None: - session_tags.update(tags) - - session = Session( - session_id=session_id, - tags=list(session_tags), - host_env=self.host_env, - config=self._config, - ) - - if not session.is_running: - return logger.error("Failed to start session") - - if self._pre_init_queue["agents"] and len(self._pre_init_queue["agents"]) > 0: - for agent_args in self._pre_init_queue["agents"]: - session.create_agent(name=agent_args["name"], agent_id=agent_args["agent_id"]) - self._pre_init_queue["agents"] = [] - - self._sessions.append(session) - return session - - def end_session( - self, - end_state: str, - end_state_reason: Optional[str] = None, - video: Optional[str] = None, - is_auto_end: Optional[bool] = None, - ) -> Optional[Decimal]: - """ - End the current session with the AgentOps service. - - Args: - end_state (str): The final state of the session. Options: Success, Fail, or Indeterminate (default). - end_state_reason (str, optional): The reason for ending the session. - video (str, optional): The video screen recording of the session - is_auto_end (bool, optional): is this an automatic use of end_session and should be skipped with skip_auto_end_session - - Returns: - Decimal: The token cost of the session. Returns 0 if the cost is unknown. - """ - session = self._safe_get_session() - if session is None: - return - if is_auto_end and self._config.skip_auto_end_session: - return - - token_cost = session.end_session(end_state=end_state, end_state_reason=end_state_reason, video=video) - - return token_cost - - def create_agent( - self, - name: str, - agent_id: Optional[str] = None, - session: Optional[Session] = None, - ): - if agent_id is None: - agent_id = str(uuid4()) - - # if a session is passed in, use multi-session logic - if session: - return session.create_agent(name=name, agent_id=agent_id) - else: - # if no session passed, assume single session - session = self._safe_get_session() - if session is None: - self._pre_init_queue["agents"].append({"name": name, "agent_id": agent_id}) - else: - session.create_agent(name=name, agent_id=agent_id) - - return agent_id - - def _handle_unclean_exits(self): - def cleanup(end_state: str = "Fail", end_state_reason: Optional[str] = None): - for session in self._sessions: - if session.end_state is None: - session.end_session( - end_state=end_state, - end_state_reason=end_state_reason, - ) - - def signal_handler(signum, frame): - """ - Signal handler for SIGINT (Ctrl+C) and SIGTERM. Ends the session and exits the program. - - Args: - signum (int): The signal number. - frame: The current stack frame. - """ - signal_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM" - logger.info("%s detected. Ending session...", signal_name) - self.end_session(end_state="Fail", end_state_reason=f"Signal {signal_name} detected") - sys.exit(0) - - def handle_exception(exc_type, exc_value, exc_traceback): - """ - Handle uncaught exceptions before they result in program termination. - - Args: - exc_type (Type[BaseException]): The type of the exception. - exc_value (BaseException): The exception instance. - exc_traceback (TracebackType): A traceback object encapsulating the call stack at the - point where the exception originally occurred. - """ - formatted_traceback = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)) - - for session in self._sessions: - session.end_session( - end_state="Fail", - end_state_reason=f"{str(exc_value)}: {formatted_traceback}", - ) - - # Then call the default excepthook to exit the program - sys.__excepthook__(exc_type, exc_value, exc_traceback) - - # if main thread - if threading.current_thread() is threading.main_thread(): - atexit.register( - lambda: cleanup( - end_state="Indeterminate", - end_state_reason="N/A (process exited without calling agentops.end_session(...))", - ) - ) - signal.signal(signal.SIGINT, signal_handler) - signal.signal(signal.SIGTERM, signal_handler) - sys.excepthook = handle_exception - - def stop_instrumenting(self): - if self._llm_tracker is not None: - self._llm_tracker.stop_instrumenting() - - def add_pre_init_warning(self, message: str): - self._pre_init_messages.append(message) - - # replaces the session currently stored with a specific session_id, with a new session - def _update_session(self, session: Session): - self._sessions[ - self._sessions.index([sess for sess in self._sessions if sess.session_id == session.session_id][0]) - ] = session - - def _safe_get_session(self) -> Optional[Session]: - if not self.is_initialized: - return None - if len(self._sessions) == 1: - return self._sessions[0] - - if len(self._sessions) > 1: - calling_function = inspect.stack()[2].function # Using index 2 because we have a wrapper at index 1 - return logger.warning( - f"Multiple sessions detected. You must use session.{calling_function}(). More info: https://docs.agentops.ai/v1/concepts/core-concepts#session-management" - ) - - return None - - def get_session(self, session_id: str): - """ - Get an active (not ended) session from the AgentOps service - - Args: - session_id (str): the session id for the session to be retreived - """ - for session in self._sessions: - if session.session_id == session_id: - return session - - def unsuppress_logs(self): - logging_level = os.getenv("AGENTOPS_LOGGING_LEVEL", "INFO") - log_levels = { - "CRITICAL": logging.CRITICAL, - "ERROR": logging.ERROR, - "INFO": logging.INFO, - "WARNING": logging.WARNING, - "DEBUG": logging.DEBUG, - } - logger.setLevel(log_levels.get(logging_level, "INFO")) - - for message in self._pre_init_messages: - logger.warning(message) - - def end_all_sessions(self): - for s in self._sessions: - s.end_session() - - self._sessions.clear() - - @property - def is_initialized(self) -> bool: - return self._initialized - - @property - def has_sessions(self) -> bool: - return len(self._sessions) > 0 - - @property - def is_multi_session(self) -> bool: - return len(self._sessions) > 1 - - @property - def session_count(self) -> int: - return len(self._sessions) - - @property - def current_session_ids(self) -> List[str]: - return [str(s.session_id) for s in self._sessions] - - @property - def api_key(self): - return self._config.api_key - - @property - def parent_key(self): - return self._config.parent_key - - @cached_property - def host_env(self): - """Cache and reuse host environment data""" - return get_host_env(self._config.env_data_opt_out) diff --git a/agentops/client/__init__.py b/agentops/client/__init__.py new file mode 100644 index 000000000..d5f0e271c --- /dev/null +++ b/agentops/client/__init__.py @@ -0,0 +1,139 @@ +import uuid +from typing import Any, Dict, List, Optional, Union +from uuid import UUID + +from agentops.client.api import ApiClient +from agentops.client.api.versions.v3 import V3Client +from agentops.config import Config, ConfigDict +from agentops.exceptions import (AgentOpsClientNotInitializedException, + NoApiKeyException, NoSessionException) +from agentops.instrumentation import instrument_all, uninstrument_all +from agentops.logging import logger +from agentops.session import Session +from agentops.session.registry import get_active_sessions, get_default_session +from agentops.session.state import SessionState + + +class Client: + """Singleton client for AgentOps service""" + + config: Config + _initialized: bool + + api: ApiClient + + def __new__(cls, *args, **kwargs): + if cls.__instance is None: + cls.__instance = super(Client, cls).__new__(cls) + return cls.__instance + + def __init__(self): + # Only initialize once + self._initialized = False + self.config = Config() + + def init(self, **kwargs) -> Union[Session, None]: + self.configure(**kwargs) + + if not self.config.api_key: + raise NoApiKeyException + + self.api = ApiClient(self.config.endpoint) + + # Always fetch JWT token to get project_id before instrumentation + # This ensures project_id is available for resource attributes + try: + token = self.api.v3.fetch_auth_token(self.config.api_key) + logger.debug("Fetched JWT token for authentication and project_id") + except Exception as e: + if not self.config.fail_safe: + raise + logger.error(f"Failed to fetch JWT token: {e}") + + # Instrument LLM calls if enabled + if self.config.instrument_llm_calls: + logger.debug("Initializing instrumentation with project_id in resource attributes") + instrument_all() + + self.initialized = True + + if self.config.auto_start_session: + return self.start_session() + + def configure(self, **kwargs): + """Update client configuration""" + self.config.configure(**kwargs) + + def start_session(self, **kwargs) -> Union[Session, None]: + """Start a new session for recording events + + Args: + tags: Optional list of tags for the session + inherited_session_id: Optional ID to inherit from another session + + Returns: + Session or None: New session if successful, None if no API key configured + """ + + if not self.initialized: + # Attempt to initialize the client if not already initialized + if self.config.auto_init: + self.init() + else: + raise AgentOpsClientNotInitializedException + + try: + return Session(config=self.config, **kwargs) + except Exception as e: + logger.error(f"Failed to create session: {e}") + if not self.config.fail_safe: + raise + return None + + def end_session( + self, + end_state: str, + end_state_reason: Optional[str] = None, + video: Optional[str] = None, + is_auto_end: Optional[bool] = False, + ): + """End the current session""" + session = get_default_session() + if session: + session.end(SessionState(end_state)) + else: + logger.warning("No active session to end") + + def add_tags(self, tags: List[str]): + """Add tags to current session""" + session = get_default_session() + if session: + session.add_tags(tags) + else: + raise NoSessionException("No active session to add tags to") + + def set_tags(self, tags: List[str]): + """Set tags for current session""" + session = get_default_session() + if session: + session.set_tags(tags) + else: + raise NoSessionException("No active session to set tags for") + + def end_all_sessions(self): + """End all active sessions""" + for session in get_active_sessions(): + session.end(SessionState.INDETERMINATE) + + @property + def initialized(self) -> bool: + return self._initialized + + @initialized.setter + def initialized(self, value: bool): + if self._initialized and self._initialized != value: + raise ValueError("Client already initialized") + self._initialized = value + + # ------------------------------------------------------------ + __instance = None diff --git a/agentops/client/api/__init__.py b/agentops/client/api/__init__.py new file mode 100644 index 000000000..b4282d62f --- /dev/null +++ b/agentops/client/api/__init__.py @@ -0,0 +1,57 @@ +""" +AgentOps API client package. + +This package provides clients for interacting with the AgentOps API. +""" + +from typing import Dict, Optional, Type, TypeVar, cast + +from agentops.client.api.base import AuthenticatedApiClient, BaseApiClient +from agentops.client.api.versions.v3 import V3Client + +# Define a type variable for client classes +T = TypeVar("T", bound=AuthenticatedApiClient) + + +class ApiClient: + """ + Master API client that contains all version-specific clients. + + This client provides a unified interface for accessing different API versions. + It lazily initializes version-specific clients when they are first accessed. + """ + + def __init__(self, endpoint: str = "https://api.agentops.ai"): + """ + Initialize the master API client. + + Args: + endpoint: The base URL for the API + """ + self.endpoint = endpoint + self._clients: Dict[str, AuthenticatedApiClient] = {} + + @property + def v3(self) -> V3Client: + """ + Get the V3 API client. + + Returns: + The V3 API client + """ + return self._get_client("v3", V3Client) + + def _get_client(self, version: str, client_class: Type[T]) -> T: + """ + Get or create a version-specific client. + + Args: + version: The API version + client_class: The client class to instantiate + + Returns: + The version-specific client + """ + if version not in self._clients: + self._clients[version] = client_class(self.endpoint) + return cast(T, self._clients[version]) diff --git a/agentops/client/api/base.py b/agentops/client/api/base.py new file mode 100644 index 000000000..b373dc0a1 --- /dev/null +++ b/agentops/client/api/base.py @@ -0,0 +1,275 @@ +""" +Base API client classes for making HTTP requests. + +This module provides the foundation for all API clients in the AgentOps SDK. +""" + +from typing import Any, Dict, Optional, Protocol + +import requests + +from agentops.client.auth_manager import AuthManager +from agentops.client.http.http_adapter import AuthenticatedHttpAdapter +from agentops.client.http.http_client import HttpClient + + +class TokenFetcher(Protocol): + """Protocol for token fetching functions""" + + def __call__(self, api_key: str) -> str: ... + + +class BaseApiClient: + """ + Base class for API communication with connection pooling. + + This class provides the core HTTP functionality without authentication. + It should be used for APIs that don't require authentication. + """ + + def __init__(self, endpoint: str): + """ + Initialize the base API client. + + Args: + endpoint: The base URL for the API + """ + self.endpoint = endpoint + self.http_client = HttpClient() + self.last_response: Optional[requests.Response] = None + + def _get_full_url(self, path: str) -> str: + """ + Get the full URL for a path. + + Args: + path: The API endpoint path + + Returns: + The full URL + """ + return f"{self.endpoint}{path}" + + def request( + self, + method: str, + path: str, + data: Optional[Dict[str, Any]] = None, + headers: Optional[Dict[str, str]] = None, + timeout: int = 30, + ) -> requests.Response: + """ + Make a generic HTTP request + + Args: + method: HTTP method (e.g., 'get', 'post', 'put', 'delete') + path: API endpoint path + data: Request payload (for POST, PUT methods) + headers: Request headers + timeout: Request timeout in seconds + + Returns: + Response from the API + + Raises: + Exception: If the request fails + """ + url = self._get_full_url(path) + + try: + response = self.http_client.request(method=method, url=url, data=data, headers=headers, timeout=timeout) + + self.last_response = response + return response + except requests.RequestException as e: + self.last_response = None + raise Exception(f"{method.upper()} request failed: {str(e)}") from e + + def post(self, path: str, data: Dict[str, Any], headers: Dict[str, str]) -> requests.Response: + """ + Make POST request + + Args: + path: API endpoint path + data: Request payload + headers: Request headers + + Returns: + Response from the API + """ + return self.request("post", path, data=data, headers=headers) + + def get(self, path: str, headers: Dict[str, str]) -> requests.Response: + """ + Make GET request + + Args: + path: API endpoint path + headers: Request headers + + Returns: + Response from the API + """ + return self.request("get", path, headers=headers) + + def put(self, path: str, data: Dict[str, Any], headers: Dict[str, str]) -> requests.Response: + """ + Make PUT request + + Args: + path: API endpoint path + data: Request payload + headers: Request headers + + Returns: + Response from the API + """ + return self.request("put", path, data=data, headers=headers) + + def delete(self, path: str, headers: Dict[str, str]) -> requests.Response: + """ + Make DELETE request + + Args: + path: API endpoint path + headers: Request headers + + Returns: + Response from the API + """ + return self.request("delete", path, headers=headers) + + +class AuthenticatedApiClient(BaseApiClient): + """ + API client with authentication support. + + This class extends BaseApiClient with authentication functionality. + It should be used as a base class for version-specific API clients + that require authentication. + """ + + def __init__(self, endpoint: str, auth_endpoint: Optional[str] = None): + """ + Initialize the authenticated API client. + + Args: + endpoint: The base URL for the API + auth_endpoint: The endpoint for authentication (defaults to {endpoint}/auth/token) + """ + super().__init__(endpoint) + + # Set up authentication manager + if auth_endpoint is None: + auth_endpoint = f"{endpoint}/auth/token" + self.auth_manager = AuthManager(auth_endpoint) + + def create_authenticated_session(self, api_key: str) -> requests.Session: + """ + Create a new session with authentication handling. + + This method is designed to be used by other components like the OTLPSpanExporter + that need to include authentication in their requests. + + Args: + api_key: The API key to use for authentication + + Returns: + A requests.Session with authentication handling + """ + session = requests.Session() + + # Create an authenticated adapter + adapter = AuthenticatedHttpAdapter( + auth_manager=self.auth_manager, api_key=api_key, token_fetcher=self.fetch_auth_token + ) + + # Mount the adapter for both HTTP and HTTPS + session.mount("http://", adapter) + session.mount("https://", adapter) + + # Set default headers + session.headers.update( + { + "Connection": "keep-alive", + "Keep-Alive": "timeout=10, max=1000", + "Content-Type": "application/json", + } + ) + + return session + + def get_auth_headers(self, api_key: str, custom_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]: + """ + Get headers with valid authentication token. + + Args: + api_key: The API key to use for authentication + custom_headers: Additional headers to include + + Returns: + Headers dictionary with valid authentication + """ + # Ensure we have a valid token + self.auth_manager.get_valid_token(api_key, self.fetch_auth_token) + + # Prepare headers with the token + return self.auth_manager.prepare_auth_headers(api_key, custom_headers) + + def fetch_auth_token(self, api_key: str) -> str: + """ + Fetch a new authentication token. + + This method should be implemented by subclasses to provide + API-specific token acquisition logic. + + Args: + api_key: The API key to authenticate with + + Returns: + A JWT token + + Raises: + NotImplementedError: If not implemented by a subclass + """ + raise NotImplementedError("Subclasses must implement fetch_auth_token") + + def authenticated_request( + self, + method: str, + path: str, + api_key: str, + data: Optional[Dict[str, Any]] = None, + custom_headers: Optional[Dict[str, str]] = None, + ) -> requests.Response: + """ + Make an authenticated request with automatic token refresh. + + Args: + method: HTTP method (e.g., 'get', 'post') + path: API endpoint path + api_key: API key for authentication + data: Request payload + custom_headers: Additional headers + + Returns: + Response from the API + """ + # Get headers with authentication + headers = self.get_auth_headers(api_key, custom_headers) + + # Make the initial request + response = self.request(method, path, data, headers) + + # Check if token expired and retry if needed + if self.auth_manager.is_token_expired_response(response): + # Clear the token to force a refresh + self.auth_manager.clear_token() + + # Get fresh headers with a new token + headers = self.get_auth_headers(api_key, custom_headers) + + # Retry the request + response = self.request(method, path, data, headers) + + return response diff --git a/agentops/client/api/versions/__init__.py b/agentops/client/api/versions/__init__.py new file mode 100644 index 000000000..c680e5c29 --- /dev/null +++ b/agentops/client/api/versions/__init__.py @@ -0,0 +1,9 @@ +""" +API client versions package. + +This package contains client implementations for different API versions. +""" + +from agentops.client.api.versions.v3 import V3Client + +__all__ = ["V3Client"] \ No newline at end of file diff --git a/agentops/client/api/versions/v3.py b/agentops/client/api/versions/v3.py new file mode 100644 index 000000000..520740cf3 --- /dev/null +++ b/agentops/client/api/versions/v3.py @@ -0,0 +1,83 @@ +""" +V3 API client for the AgentOps API. + +This module provides the client for the V3 version of the AgentOps API. +""" + +from typing import Any, Dict, List, Optional + +import requests + +from agentops.client.api.base import AuthenticatedApiClient +from agentops.exceptions import ApiServerException +from agentops.logging import logger +from agentops.config import get_config +from agentops.client.http.http_client import HttpClient + + +class V3Client(AuthenticatedApiClient): + """Client for the AgentOps V3 API""" + + def __init__(self, endpoint: str): + """ + Initialize the V3 API client. + + Args: + endpoint: The base URL for the API + """ + # Set up with V3-specific auth endpoint + super().__init__(endpoint, auth_endpoint=f"{endpoint}/v3/auth/token") + + def fetch_auth_token(self, api_key: str) -> str: + """ + Fetch a new authentication token from the V3 API. + + Args: + api_key: The API key to authenticate with + + Returns: + A JWT token + + Raises: + ApiServerException: If authentication fails + """ + path = "/v3/auth/token" + data = {"api_key": api_key} + headers = self.auth_manager.prepare_auth_headers(api_key) + + response = self.post(path, data, headers) + + if response.status_code != 200: + error_msg = f"Authentication failed: {response.status_code}" + try: + error_data = response.json() + if "error" in error_data: + error_msg = f"Authentication failed: {error_data['error']}" + except Exception: + pass + raise ApiServerException(error_msg) + + try: + token_data = response.json() + token = token_data.get("token") + if not token: + raise ApiServerException("No token in authentication response") + + # Extract project_id from the token response if available + if "project_id" in token_data: + project_id = token_data["project_id"] + + # Update HttpClient._project_id + HttpClient._project_id = project_id + logger.debug(f"Extracted project_id: {project_id}") + + # Update the config's project_id + config = get_config() + config.project_id = project_id + logger.debug(f"Updated config project_id: {config.project_id}") + + return token + except Exception as e: + raise ApiServerException(f"Failed to process authentication response: {str(e)}") + + # Add V3-specific API methods here diff --git a/agentops/client/auth_manager.py b/agentops/client/auth_manager.py new file mode 100644 index 000000000..730468d9e --- /dev/null +++ b/agentops/client/auth_manager.py @@ -0,0 +1,110 @@ +import threading +import time +from typing import Callable, Dict, Optional + +import requests + +from agentops.exceptions import (AgentOpsApiJwtExpiredException, + ApiServerException) + + +class AuthManager: + """Manages authentication tokens and related operations""" + + def __init__(self, token_endpoint: str): + """ + Initialize the authentication manager. + + Args: + token_endpoint: The full URL for token acquisition + """ + self.token_endpoint = token_endpoint + self.jwt_token: Optional[str] = None + self._token_lock = threading.Lock() + + def is_token_valid(self) -> bool: + """ + Check if the current JWT token exists. + + Note: We don't try to decode the token to check expiration. + Instead, we rely on HTTP 401/403 responses to indicate when + a token needs to be refreshed. + """ + return self.jwt_token is not None + + def get_valid_token(self, api_key: str, token_fetcher: Callable[[str], str]) -> str: + """ + Get a JWT token, only getting a new one if we don't have one. + + Args: + api_key: The API key to authenticate with if refresh is needed + token_fetcher: Function to fetch a new token if needed + + Returns: + A JWT token + """ + with self._token_lock: + if not self.is_token_valid(): + self.jwt_token = token_fetcher(api_key) + assert self.jwt_token is not None # For type checking + return self.jwt_token + + def prepare_auth_headers( + self, + api_key: str, + custom_headers: Optional[Dict[str, str]] = None, + ) -> Dict[str, str]: + """ + Prepare headers with authentication information. + + Args: + api_key: The API key to include in headers + custom_headers: Additional headers to include + + Returns: + Headers dictionary with authentication information + """ + headers = {"Content-Type": "application/json; charset=UTF-8", "Accept": "*/*"} + + if api_key: + headers["X-Agentops-Api-Key"] = api_key + + if self.jwt_token: + headers["Authorization"] = f"Bearer {self.jwt_token}" + + if custom_headers: + # Don't let custom headers override critical headers + safe_headers = custom_headers.copy() + for protected in ["Authorization", "X-Agentops-Api-Key"]: + safe_headers.pop(protected, None) + headers.update(safe_headers) + + return headers + + def is_token_expired_response(self, response: requests.Response) -> bool: + """ + Check if a response indicates an expired token. + + Args: + response: The HTTP response to check + + Returns: + True if the response indicates an expired token, False otherwise + """ + if response.status_code not in (401, 403): + return False + + # Check if the response indicates a token expiration + try: + # Try to parse the response as JSON + response_data = response.json() + error_msg = response_data.get("error", "").lower() + return "expired" in error_msg or "token" in error_msg + except Exception: + # If we can't parse JSON, check the raw text + return bool(response.text and "expired" in response.text.lower()) + + def clear_token(self): + """Clear the stored token, forcing a refresh on next use""" + with self._token_lock: + self.jwt_token = None diff --git a/agentops/client/http/README.md b/agentops/client/http/README.md new file mode 100644 index 000000000..379b8ab92 --- /dev/null +++ b/agentops/client/http/README.md @@ -0,0 +1,87 @@ +# AgentOps HTTP Client Architecture + +This directory contains the HTTP client architecture for the AgentOps SDK. The architecture follows a clean separation of concerns design principle. + +## Components + +### HttpClient + +The `HttpClient` class provides low-level HTTP functionality: +- Connection pooling +- Retry logic +- Basic HTTP methods (GET, POST, PUT, DELETE) + +### AuthManager + +The `AuthManager` class handles authentication concerns: +- Token acquisition and storage +- Token refresh logic +- Authentication header preparation +- Thread-safe token operations + +### HTTP Adapters + +#### BaseHTTPAdapter +- Enhanced connection pooling and retry logic +- Used by the `HttpClient` for basic HTTP operations + +#### AuthenticatedHttpAdapter +- Extends `BaseHTTPAdapter` with authentication capabilities +- Automatically adds authentication headers to requests +- Handles token refresh when authentication fails +- Can be mounted to any requests.Session + +## Design Principles + +1. **Separation of Concerns** + - HTTP concerns are isolated from authentication concerns + - Each component has a single responsibility + +2. **Composition over Inheritance** + - Components use composition rather than inheritance + - `ApiClient` composes `HttpClient` and `AuthManager` + +3. **Clear Interfaces** + - Each component has a well-defined interface + - Implementation details are hidden + +4. **Dependency Flow** + - Dependencies flow in one direction + - Lower-level components (HTTP, Auth) don't depend on higher-level components + +## Usage + +### Basic API Client Usage + +The HTTP client architecture is used by the `ApiClient` class, which provides a high-level interface for making API calls. Specific API versions (like `V3Client`) extend the `ApiClient` to provide version-specific functionality. + +```python +# Example usage +from agentops.client.v3_client import V3Client + +client = V3Client(endpoint="https://api.agentops.ai") +response = client.authenticated_request( + method="get", + path="/v3/some/endpoint", + api_key="your-api-key" +) +``` + +### Using with External Libraries + +The architecture also supports integration with external libraries that need authenticated HTTP sessions: + +```python +# Example with OpenTelemetry exporter +from agentops.client.v3_client import V3Client +from agentops.client.exporters import AuthenticatedOTLPExporter + +client = V3Client(endpoint="https://api.agentops.ai") +session = client.create_authenticated_session(api_key="your-api-key") + +exporter = AuthenticatedOTLPExporter( + endpoint="https://api.agentops.ai/v3/traces", + api_client=client, + api_key="your-api-key" +) +``` diff --git a/agentops/llms/__init__.py b/agentops/client/http/__init__.py similarity index 100% rename from agentops/llms/__init__.py rename to agentops/client/http/__init__.py diff --git a/agentops/client/http/http_adapter.py b/agentops/client/http/http_adapter.py new file mode 100644 index 000000000..22939d15e --- /dev/null +++ b/agentops/client/http/http_adapter.py @@ -0,0 +1,125 @@ +from typing import Callable, Optional + +from requests.adapters import HTTPAdapter +from urllib3.util import Retry + +from agentops.client.auth_manager import AuthManager +from agentops.exceptions import AgentOpsApiJwtExpiredException, ApiServerException +from agentops.logging import logger + + +class BaseHTTPAdapter(HTTPAdapter): + """Base HTTP adapter with enhanced connection pooling and retry logic""" + + def __init__( + self, + pool_connections: int = 15, + pool_maxsize: int = 256, + max_retries: Optional[Retry] = None, + ): + """ + Initialize the base HTTP adapter. + + Args: + pool_connections: Number of connection pools to cache + pool_maxsize: Maximum number of connections to save in the pool + max_retries: Retry configuration for failed requests + """ + if max_retries is None: + max_retries = Retry( + total=3, + backoff_factor=0.1, + status_forcelist=[500, 502, 503, 504], + ) + + super().__init__( + pool_connections=pool_connections, + pool_maxsize=pool_maxsize, + max_retries=max_retries + ) + + +class AuthenticatedHttpAdapter(BaseHTTPAdapter): + """HTTP adapter with automatic JWT authentication and refresh""" + + def __init__( + self, + auth_manager: AuthManager, + api_key: str, + token_fetcher: Callable[[str], str], + pool_connections: int = 15, + pool_maxsize: int = 256, + max_retries: Optional[Retry] = None, + ): + """ + Initialize the authenticated HTTP adapter. + + Args: + auth_manager: The authentication manager to use + api_key: The API key to authenticate with + token_fetcher: Function to fetch a new token if needed + pool_connections: Number of connection pools to cache + pool_maxsize: Maximum number of connections to save in the pool + max_retries: Retry configuration for failed requests + """ + self.auth_manager = auth_manager + self.api_key = api_key + self.token_fetcher = token_fetcher + + super().__init__( + pool_connections=pool_connections, + pool_maxsize=pool_maxsize, + max_retries=max_retries + ) + + def add_headers(self, request, **kwargs): + """Add authentication headers to the request""" + # Get fresh auth headers from the auth manager + self.auth_manager.get_valid_token(self.api_key, self.token_fetcher) + auth_headers = self.auth_manager.prepare_auth_headers(self.api_key) + + # Update request headers + for key, value in auth_headers.items(): + request.headers[key] = value + + return request + + def send(self, request, **kwargs): + """Send the request with authentication retry logic""" + # Ensure allow_redirects is set to False + kwargs["allow_redirects"] = False + + # Add auth headers to initial request + request = self.add_headers(request, **kwargs) + + # Make the initial request + response = super().send(request, **kwargs) + + # If we get a 401/403, check if it's due to token expiration + if self.auth_manager.is_token_expired_response(response): + logger.debug("Token expired, attempting to refresh") + try: + # Force token refresh + self.auth_manager.clear_token() + self.auth_manager.get_valid_token(self.api_key, self.token_fetcher) + + # Update request with new token + request = self.add_headers(request, **kwargs) + + # Retry the request + logger.debug("Retrying request with new token") + response = super().send(request, **kwargs) + except AgentOpsApiJwtExpiredException as e: + # Authentication failed + logger.warning(f"Failed to refresh authentication token: {e}") + except ApiServerException as e: + # Server error during token refresh + logger.error(f"Server error during token refresh: {e}") + except Exception as e: + # Unexpected error during token refresh + logger.error(f"Unexpected error during token refresh: {e}") + + return response + + + diff --git a/agentops/client/http/http_client.py b/agentops/client/http/http_client.py new file mode 100644 index 000000000..b07f9473f --- /dev/null +++ b/agentops/client/http/http_client.py @@ -0,0 +1,228 @@ +from typing import Callable, Dict, Optional + +import requests + +from agentops.client.auth_manager import AuthManager +from agentops.client.http.http_adapter import (AuthenticatedHttpAdapter, + BaseHTTPAdapter) +from agentops.exceptions import AgentOpsApiJwtExpiredException, ApiServerException +from agentops.logging import logger +from agentops.config import get_config + + +class HttpClient: + """Base HTTP client with connection pooling and session management""" + + _session: Optional[requests.Session] = None + # Store project_id at class level for use in resource attributes + _project_id: Optional[str] = None + + @classmethod + def get_session(cls) -> requests.Session: + """Get or create the global session with optimized connection pooling""" + if cls._session is None: + cls._session = requests.Session() + + # Configure connection pooling + adapter = BaseHTTPAdapter() + + # Mount adapter for both HTTP and HTTPS + cls._session.mount("http://", adapter) + cls._session.mount("https://", adapter) + + # Set default headers + cls._session.headers.update( + { + "Connection": "keep-alive", + "Keep-Alive": "timeout=10, max=1000", + "Content-Type": "application/json", + } + ) + + return cls._session + + @classmethod + def get_project_id(cls) -> Optional[str]: + """ + Get the project ID extracted from the authentication response. + + Returns: + The project ID if available, None otherwise + """ + return cls._project_id + + @classmethod + def get_authenticated_session( + cls, + endpoint: str, + api_key: str, + token_fetcher: Optional[Callable[[str], str]] = None, + ) -> requests.Session: + """ + Create a new session with authentication handling. + + Args: + endpoint: Base API endpoint (used to derive auth endpoint if needed) + api_key: The API key to use for authentication + token_fetcher: Optional custom token fetcher function + + Returns: + A requests.Session with authentication handling + """ + # Create auth manager with default token endpoint + auth_endpoint = f"{endpoint}/auth/token" + auth_manager = AuthManager(auth_endpoint) + + # Use provided token fetcher or create a default one + if token_fetcher is None: + def default_token_fetcher(key: str) -> str: + # Simple token fetching implementation + try: + response = requests.post( + auth_manager.token_endpoint, + json={"api_key": key}, + headers={"Content-Type": "application/json"}, + timeout=30 + ) + + if response.status_code == 401 or response.status_code == 403: + error_msg = "Invalid API key or unauthorized access" + try: + error_data = response.json() + if "error" in error_data: + error_msg = error_data["error"] + except Exception: + if response.text: + error_msg = response.text + + logger.error(f"Authentication failed: {error_msg}") + raise AgentOpsApiJwtExpiredException(f"Authentication failed: {error_msg}") + + if response.status_code >= 500: + logger.error(f"Server error during authentication: {response.status_code}") + raise ApiServerException(f"Server error during authentication: {response.status_code}") + + if response.status_code != 200: + logger.error(f"Unexpected status code during authentication: {response.status_code}") + raise AgentOpsApiJwtExpiredException(f"Failed to fetch token: {response.status_code}") + + token_data = response.json() + if "token" not in token_data: + logger.error("Token not found in response") + raise AgentOpsApiJwtExpiredException("Token not found in response") + + # Extract project_id from the token response if available + if "project_id" in token_data: + cls._project_id = token_data["project_id"] + logger.debug(f"Extracted project_id: {cls._project_id}") + + # Update the config's project_id + config = get_config() + config.project_id = cls._project_id + logger.debug(f"Updated config project_id: {config.project_id}") + + return token_data["token"] + except requests.RequestException as e: + logger.error(f"Network error during authentication: {e}") + raise AgentOpsApiJwtExpiredException(f"Network error during authentication: {e}") + + token_fetcher = default_token_fetcher + + # Create a new session + session = requests.Session() + + # Create an authenticated adapter + adapter = AuthenticatedHttpAdapter( + auth_manager=auth_manager, + api_key=api_key, + token_fetcher=token_fetcher + ) + + # Mount the adapter for both HTTP and HTTPS + session.mount("http://", adapter) + session.mount("https://", adapter) + + # Set default headers + session.headers.update({ + "Connection": "keep-alive", + "Keep-Alive": "timeout=10, max=1000", + "Content-Type": "application/json", + }) + + return session + + @classmethod + def request( + cls, + method: str, + url: str, + data: Optional[Dict] = None, + headers: Optional[Dict] = None, + timeout: int = 30, + max_redirects: int = 5, + ) -> requests.Response: + """ + Make a generic HTTP request + + Args: + method: HTTP method (e.g., 'get', 'post', 'put', 'delete') + url: Full URL for the request + data: Request payload (for POST, PUT methods) + headers: Request headers + timeout: Request timeout in seconds + max_redirects: Maximum number of redirects to follow (default: 5) + + Returns: + Response from the API + + Raises: + requests.RequestException: If the request fails + ValueError: If the redirect limit is exceeded or an unsupported HTTP method is used + """ + session = cls.get_session() + method = method.lower() + redirect_count = 0 + + while redirect_count <= max_redirects: + # Make the request with allow_redirects=False + if method == "get": + response = session.get(url, headers=headers, timeout=timeout, allow_redirects=False) + elif method == "post": + response = session.post(url, json=data, headers=headers, timeout=timeout, allow_redirects=False) + elif method == "put": + response = session.put(url, json=data, headers=headers, timeout=timeout, allow_redirects=False) + elif method == "delete": + response = session.delete(url, headers=headers, timeout=timeout, allow_redirects=False) + else: + raise ValueError(f"Unsupported HTTP method: {method}") + + # Check if we got a redirect response + if response.status_code in (301, 302, 303, 307, 308): + redirect_count += 1 + + if redirect_count > max_redirects: + raise ValueError(f"Exceeded maximum number of redirects ({max_redirects})") + + # Get the new location + if "location" not in response.headers: + # No location header, can't redirect + return response + + # Update URL to the redirect location + url = response.headers["location"] + + # For 303 redirects, always use GET for the next request + if response.status_code == 303: + method = "get" + data = None + + logger.debug(f"Following redirect ({redirect_count}/{max_redirects}) to: {url}") + + # Continue the loop to make the next request + continue + + # Not a redirect, return the response + return response + + # This should never be reached due to the max_redirects check above + return response diff --git a/agentops/config.py b/agentops/config.py index 7dfb574d2..1edf87fa5 100644 --- a/agentops/config.py +++ b/agentops/config.py @@ -1,53 +1,163 @@ -from typing import List, Optional +import json +import logging +import os +import sys +from dataclasses import asdict, dataclass, field +from typing import Any, List, Optional, Set, TypedDict, Union from uuid import UUID -from .log_config import logger +from opentelemetry.sdk.trace import SpanProcessor +from opentelemetry.sdk.trace.export import SpanExporter +from agentops.exceptions import InvalidApiKeyException +from agentops.helpers.env import get_env_bool, get_env_int, get_env_list +from agentops.helpers.serialization import AgentOpsJSONEncoder -class Configuration: - def __init__(self): - self.api_key: Optional[str] = None - self.parent_key: Optional[str] = None - self.endpoint: str = "https://api.agentops.ai" - self.max_wait_time: int = 5000 - self.max_queue_size: int = 512 - self.default_tags: set[str] = set() - self.instrument_llm_calls: bool = True - self.auto_start_session: bool = True - self.skip_auto_end_session: bool = False - self.env_data_opt_out: bool = False +from .logging.config import logger + + +class ConfigDict(TypedDict): + api_key: Optional[str] + endpoint: Optional[str] + max_wait_time: Optional[int] + max_queue_size: Optional[int] + default_tags: Optional[List[str]] + instrument_llm_calls: Optional[bool] + auto_start_session: Optional[bool] + auto_init: Optional[bool] + skip_auto_end_session: Optional[bool] + env_data_opt_out: Optional[bool] + log_level: Optional[Union[str, int]] + fail_safe: Optional[bool] + prefetch_jwt_token: Optional[bool] + exporter_endpoint: Optional[str] + metrics_exporter_endpoint: Optional[str] + project_id: Optional[str] + + +@dataclass(slots=True) +class Config: + api_key: Optional[str] = field( + default_factory=lambda: os.getenv("AGENTOPS_API_KEY"), + metadata={"description": "API key for authentication with AgentOps services"}, + ) + + endpoint: str = field( + default_factory=lambda: os.getenv("AGENTOPS_API_ENDPOINT", "https://api.agentops.ai"), + metadata={"description": "Base URL for the AgentOps API"}, + ) + + max_wait_time: int = field( + default_factory=lambda: get_env_int("AGENTOPS_MAX_WAIT_TIME", 5000), + metadata={"description": "Maximum time in milliseconds to wait for API responses"}, + ) + + max_queue_size: int = field( + default_factory=lambda: get_env_int("AGENTOPS_MAX_QUEUE_SIZE", 512), + metadata={"description": "Maximum number of events to queue before forcing a flush"}, + ) + + default_tags: Set[str] = field( + default_factory=lambda: get_env_list("AGENTOPS_DEFAULT_TAGS"), + metadata={"description": "Default tags to apply to all sessions"}, + ) + + instrument_llm_calls: bool = field( + default_factory=lambda: get_env_bool("AGENTOPS_INSTRUMENT_LLM_CALLS", True), + metadata={"description": "Whether to automatically instrument and track LLM API calls"}, + ) + + auto_start_session: bool = field( + default_factory=lambda: get_env_bool("AGENTOPS_AUTO_START_SESSION", True), + metadata={"description": "Whether to automatically start a session when initializing"}, + ) + + auto_init: bool = field( + default_factory=lambda: get_env_bool("AGENTOPS_AUTO_INIT", True), + metadata={"description": "Whether to automatically initialize the client on import"}, + ) + + skip_auto_end_session: bool = field( + default_factory=lambda: get_env_bool("AGENTOPS_SKIP_AUTO_END_SESSION", False), + metadata={"description": "Whether to skip automatically ending sessions on program exit"}, + ) + + env_data_opt_out: bool = field( + default_factory=lambda: get_env_bool("AGENTOPS_ENV_DATA_OPT_OUT", False), + metadata={"description": "Whether to opt out of collecting environment data"}, + ) + + log_level: Union[str, int] = field( + default_factory=lambda: os.getenv("AGENTOPS_LOG_LEVEL", "CRITICAL"), + metadata={"description": "Logging level for AgentOps logs"}, + ) + + fail_safe: bool = field( + default_factory=lambda: get_env_bool("AGENTOPS_FAIL_SAFE", False), + metadata={"description": "Whether to suppress errors and continue execution when possible"}, + ) + + prefetch_jwt_token: bool = field( + default_factory=lambda: get_env_bool("AGENTOPS_PREFETCH_JWT_TOKEN", True), + metadata={"description": "Whether to prefetch JWT token during initialization"}, + ) + + exporter_endpoint: Optional[str] = field( + default_factory=lambda: os.getenv("AGENTOPS_EXPORTER_ENDPOINT", "https://otlp.agentops.cloud/v1/traces"), + metadata={ + "description": "Endpoint for the span exporter. When not provided, the default AgentOps endpoint will be used." + }, + ) + + metrics_exporter_endpoint: Optional[str] = field( + default_factory=lambda: os.getenv("AGENTOPS_METRICS_EXPORTER_ENDPOINT"), + metadata={ + "description": "Endpoint for the metrics exporter. When not provided, the default AgentOps endpoint will be used." + }, + ) + + project_id: Optional[str] = field( + default_factory=lambda: os.getenv("AGENTOPS_PROJECT_ID"), + metadata={"description": "Project ID for resource attributes in spans and metrics"}, + ) + + exporter: Optional[SpanExporter] = field( + default_factory=lambda: None, metadata={"description": "Custom span exporter for OpenTelemetry trace data"} + ) + + processor: Optional[SpanProcessor] = field( + default_factory=lambda: None, metadata={"description": "Custom span processor for OpenTelemetry trace data"} + ) def configure( self, - client, api_key: Optional[str] = None, - parent_key: Optional[str] = None, endpoint: Optional[str] = None, max_wait_time: Optional[int] = None, max_queue_size: Optional[int] = None, default_tags: Optional[List[str]] = None, instrument_llm_calls: Optional[bool] = None, auto_start_session: Optional[bool] = None, + auto_init: Optional[bool] = None, skip_auto_end_session: Optional[bool] = None, env_data_opt_out: Optional[bool] = None, + log_level: Optional[Union[str, int]] = None, + fail_safe: Optional[bool] = None, + prefetch_jwt_token: Optional[bool] = None, + exporter: Optional[SpanExporter] = None, + processor: Optional[SpanProcessor] = None, + exporter_endpoint: Optional[str] = None, + metrics_exporter_endpoint: Optional[str] = None, + project_id: Optional[str] = None, ): + """Configure settings from kwargs, validating where necessary""" if api_key is not None: - try: - UUID(api_key) - self.api_key = api_key - except ValueError: - message = f"API Key is invalid: {{{api_key}}}.\n\t Find your API key at https://app.agentops.ai/settings/projects" - client.add_pre_init_warning(message) - logger.error(message) - - if parent_key is not None: - try: - UUID(parent_key) - self.parent_key = parent_key - except ValueError: - message = f"Parent Key is invalid: {parent_key}" - client.add_pre_init_warning(message) - logger.warning(message) + self.api_key = api_key + if not TESTING: # Allow setting dummy keys in tests + try: + UUID(api_key) + except ValueError: + raise InvalidApiKeyException(api_key, self.endpoint) if endpoint is not None: self.endpoint = endpoint @@ -59,7 +169,7 @@ def configure( self.max_queue_size = max_queue_size if default_tags is not None: - self.default_tags.update(default_tags) + self.default_tags = set(default_tags) if instrument_llm_calls is not None: self.instrument_llm_calls = instrument_llm_calls @@ -67,8 +177,106 @@ def configure( if auto_start_session is not None: self.auto_start_session = auto_start_session + if auto_init is not None: + self.auto_init = auto_init + if skip_auto_end_session is not None: self.skip_auto_end_session = skip_auto_end_session if env_data_opt_out is not None: self.env_data_opt_out = env_data_opt_out + + if log_level is not None: + self.log_level = log_level + + if fail_safe is not None: + self.fail_safe = fail_safe + + if prefetch_jwt_token is not None: + self.prefetch_jwt_token = prefetch_jwt_token + + if exporter is not None: + self.exporter = exporter + + if processor is not None: + self.processor = processor + + if exporter_endpoint is not None: + self.exporter_endpoint = exporter_endpoint + + if metrics_exporter_endpoint is not None: + self.metrics_exporter_endpoint = metrics_exporter_endpoint + + if project_id is not None: + self.project_id = project_id + + def dict(self): + """Return a dictionary representation of the config""" + return { + "api_key": self.api_key, + "endpoint": self.endpoint, + "max_wait_time": self.max_wait_time, + "max_queue_size": self.max_queue_size, + "default_tags": self.default_tags, + "instrument_llm_calls": self.instrument_llm_calls, + "auto_start_session": self.auto_start_session, + "auto_init": self.auto_init, + "skip_auto_end_session": self.skip_auto_end_session, + "env_data_opt_out": self.env_data_opt_out, + "log_level": self.log_level, + "fail_safe": self.fail_safe, + "prefetch_jwt_token": self.prefetch_jwt_token, + "exporter": self.exporter, + "processor": self.processor, + "exporter_endpoint": self.exporter_endpoint, + "metrics_exporter_endpoint": self.metrics_exporter_endpoint, + "project_id": self.project_id, + } + + def json(self): + """Return a JSON representation of the config""" + return json.dumps(self.dict(), cls=AgentOpsJSONEncoder) + + +def default_config(): + """Return a default configuration instance""" + return Config() +def get_config(): + """Return the current configuration instance""" + return Config() + +# Detect if we're running under pytest +TESTING = "pytest" in sys.modules + + +if TESTING: + + def hook_pdb(): + """Set up automatic pdb debugging during test runs. + + This hooks into Python's exception handling system to automatically start pdb + when an uncaught exception occurs during tests. This makes it easier to debug + test failures by dropping into the debugger at the point of failure. + + The hook is only installed when running under pytest. It will: + - Print the full traceback + - Start pdb post-mortem debugging + - Skip this behavior if running non-interactively + """ + import sys + + def info(type, value, tb): + # Skip if we're in interactive mode or stdout isn't a terminal + if hasattr(sys, "ps1") or not sys.stderr.isatty(): + sys.__excepthook__(type, value, tb) + else: + import pdb + import traceback + + # Print the traceback and start the debugger + traceback.print_exception(type, value, tb) + pdb.post_mortem(tb) + + sys.excepthook = info + + hook_pdb() diff --git a/agentops/decorators.py b/agentops/decorators.py index 62e18a62f..8051d7093 100644 --- a/agentops/decorators.py +++ b/agentops/decorators.py @@ -1,347 +1,636 @@ +"""Decorators for AgentOps.""" +from __future__ import annotations + import functools import inspect -from typing import Optional, Union -from uuid import uuid4 - -from .client import Client -from .descriptor import agentops_property -from .event import ActionEvent, ErrorEvent, ToolEvent -from .helpers import check_call_stack_for_agent_id, get_ISO_time -from .log_config import logger -from .session import Session - - -def record_function(event_name: str): - logger.warning( - "DEPRECATION WARNING: record_function has been replaced with record_action and will be removed in the next minor version. Also see: record_tool" - ) - return record_action(event_name) +import uuid +import wrapt +from contextlib import contextmanager +from typing import Any, Callable, Dict, List, Optional, TypeVar, Union, cast, ContextManager + +from opentelemetry import trace, context +from opentelemetry.trace import Span, SpanKind as OTelSpanKind + +import agentops +from agentops.session.state import SessionState +from agentops.semconv import ( + AgentOpsSpanKindValues, + AgentAttributes, + ToolAttributes, + CoreAttributes, + ToolStatus, +) + +# Type variable for functions +F = TypeVar("F", bound=Callable[..., Any]) + +# Get the tracer +_tracer = trace.get_tracer("agentops.decorators") + +def session(func_or_tags: Optional[Union[F, List[str]]] = None) -> Union[F, Callable[[F], F]]: + """Decorator to wrap a function with a session. + + Can be used as: + @session + def my_function(): + pass + + @session(tags=["test_run"]) + def my_function(): + pass + Args: + func_or_tags: Either the function to wrap or a list of tags. -def record_action(event_name: Optional[str] = None): + Returns: + The wrapped function. + """ + tags: Optional[List[str]] = None + if isinstance(func_or_tags, list): + tags = func_or_tags + + @wrapt.decorator + def wrapper(wrapped: F, instance: Any, args: tuple, kwargs: dict) -> Any: + session = agentops.start_session(tags) + try: + return wrapped(*args, **kwargs) + finally: + if session: + agentops.end_session(end_state=str(SessionState.SUCCEEDED), is_auto_end=True) + + if func_or_tags is None or isinstance(func_or_tags, list): + return wrapper + + # @session case - func_or_tags is the function + return wrapper(cast(F, func_or_tags)) + +def agent( + name: Optional[str] = None, + role: Optional[str] = None, + tools: Optional[List[str]] = None, + models: Optional[List[str]] = None, + attributes: Optional[Dict[str, Any]] = None, + **kwargs +) -> Callable: """ - Decorator to record an event before and after a function call. - Usage: - - Actions: Records function parameters and return statements of the - function being decorated. Additionally, timing information about - the action is recorded + Decorator for agent classes. + + Creates a span of kind AGENT for the lifetime of the agent instance. + The span will be a child of the current session span. + Args: - event_name (optional, str): The name of the event to record. + name: Name of the agent + role: Role of the agent + tools: List of tools available to the agent + models: List of models available to the agent + attributes: Additional attributes to add to the span + **kwargs: Additional keyword arguments to add as attributes + + Returns: + Decorated class """ + def decorator(cls): + # Store original __init__ and __del__ methods + original_init = cls.__init__ + original_del = cls.__del__ if hasattr(cls, "__del__") else None + + @functools.wraps(original_init) + def init_wrapper(self, *args, **kwargs): + # Call original __init__ + original_init(self, *args, **kwargs) + + # Create span attributes + span_attributes = {} + + # Add agent attributes + if name is not None: + span_attributes[AgentAttributes.AGENT_NAME] = name + elif hasattr(self, "name"): + span_attributes[AgentAttributes.AGENT_NAME] = self.name + else: + span_attributes[AgentAttributes.AGENT_NAME] = cls.__name__ + + if role is not None: + span_attributes[AgentAttributes.AGENT_ROLE] = role + elif hasattr(self, "role"): + span_attributes[AgentAttributes.AGENT_ROLE] = self.role + + if tools is not None: + span_attributes[AgentAttributes.AGENT_TOOLS] = tools + elif hasattr(self, "tools"): + span_attributes[AgentAttributes.AGENT_TOOLS] = self.tools + + if models is not None: + span_attributes[AgentAttributes.AGENT_MODELS] = models + elif hasattr(self, "model") and isinstance(self.model, str): + span_attributes[AgentAttributes.AGENT_MODELS] = [self.model] + elif hasattr(self, "models"): + span_attributes[AgentAttributes.AGENT_MODELS] = self.models + + # Add custom attributes + if attributes: + span_attributes.update(attributes) + + # Add kwargs as attributes + span_attributes.update(kwargs) + + # Generate a unique ID for the agent + agent_id = str(uuid.uuid4()) + span_attributes[AgentAttributes.AGENT_ID] = agent_id + + # Add span kind directly to attributes + span_attributes["span.kind"] = AgentOpsSpanKindValues.AGENT.value + + # Create and start the span as a child of the current span (session) + # Store the context manager and use it to access the span + self._agentops_span_ctx = _tracer.start_as_current_span( + name=span_attributes.get(AgentAttributes.AGENT_NAME, cls.__name__), + kind=OTelSpanKind.INTERNAL, + attributes=span_attributes + ) + self._agentops_span_ctx.__enter__() # Enter the context + self._agentops_span = trace.get_current_span() # Get the actual span + + # Store the span and context token in the instance + self._agentops_agent_id = agent_id + # Store the context for later use by methods + self._agentops_context = trace.set_span_in_context(self._agentops_span) + + def del_wrapper(self): + # End the span if it exists + if hasattr(self, "_agentops_span_ctx"): + self._agentops_span_ctx.__exit__(None, None, None) # Exit the context + + # Call original __del__ if it exists + if original_del: + original_del(self) + + # Replace __init__ and __del__ methods + cls.__init__ = init_wrapper + cls.__del__ = del_wrapper + + return cls + + return decorator +def tool( + name: Optional[str] = None, + description: Optional[str] = None, + capture_args: bool = True, + capture_result: bool = True, + attributes: Optional[Dict[str, Any]] = None, + **kwargs +) -> Callable: + """ + Decorator for tool functions. + + Creates a span of kind TOOL for each invocation of the function. + The span will be a child of the current span (typically a method span). + + Args: + name: Name of the tool + description: Description of the tool + capture_args: Whether to capture function arguments as span attributes + capture_result: Whether to capture function result as span attribute + attributes: Additional attributes to add to the span + **kwargs: Additional keyword arguments to add as attributes + + Returns: + Decorated function + """ def decorator(func): - if inspect.iscoroutinefunction(func): - - @functools.wraps(func) - async def async_wrapper(*args, session: Optional[Session] = None, **kwargs): - init_time = get_ISO_time() - if "session" in kwargs.keys(): - del kwargs["session"] - if session is None: - if Client().is_multi_session: - raise ValueError( - "If multiple sessions exists, `session` is a required parameter in the function decorated by @record_action" - ) - func_args = inspect.signature(func).parameters - arg_names = list(func_args.keys()) - # Get default values - arg_values = { - name: func_args[name].default for name in arg_names if func_args[name].default is not inspect._empty - } - # Update with positional arguments - arg_values.update(dict(zip(arg_names, args))) - arg_values.update(kwargs) - - if not event_name: - action_type = func.__name__ - else: - action_type = event_name - - event = ActionEvent( - params=arg_values, - init_timestamp=init_time, - agent_id=check_call_stack_for_agent_id(), - action_type=action_type, - ) - - try: - returns = await func(*args, **kwargs) - - event.returns = list(returns) if isinstance(returns, tuple) else returns - - # NOTE: Will likely remove in future since this is tightly coupled. Adding it to see how useful we find it for now - # TODO: check if screenshot is the url string we expect it to be? And not e.g. "True" - if hasattr(returns, "screenshot"): - event.screenshot = returns.screenshot # type: ignore - - event.end_timestamp = get_ISO_time() - - if session: - session.record(event) - else: - Client().record(event) - - except Exception as e: - Client().record(ErrorEvent(trigger_event=event, exception=e)) - - # Re-raise the exception - raise - - return returns - - return async_wrapper - else: - - @functools.wraps(func) - def sync_wrapper(*args, session: Optional[Session] = None, **kwargs): - init_time = get_ISO_time() - if "session" in kwargs.keys(): - del kwargs["session"] - if session is None: - if Client().is_multi_session: - raise ValueError( - "If multiple sessions exists, `session` is a required parameter in the function decorated by @record_action" - ) - func_args = inspect.signature(func).parameters - arg_names = list(func_args.keys()) - # Get default values - arg_values = { - name: func_args[name].default for name in arg_names if func_args[name].default is not inspect._empty - } - # Update with positional arguments - arg_values.update(dict(zip(arg_names, args))) - arg_values.update(kwargs) - - if not event_name: - action_type = func.__name__ - else: - action_type = event_name - - event = ActionEvent( - params=arg_values, - init_timestamp=init_time, - agent_id=check_call_stack_for_agent_id(), - action_type=action_type, - ) - + # Get function signature for argument names + sig = inspect.signature(func) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + # Create span attributes + span_attributes = {} + + # Add tool attributes + tool_name = name if name is not None else func.__name__ + span_attributes[ToolAttributes.TOOL_NAME] = tool_name + + if description is not None: + span_attributes[ToolAttributes.TOOL_DESCRIPTION] = description + elif func.__doc__: + span_attributes[ToolAttributes.TOOL_DESCRIPTION] = func.__doc__.strip() + + # Generate a unique ID for the tool invocation + tool_id = str(uuid.uuid4()) + span_attributes[ToolAttributes.TOOL_ID] = tool_id + + # Capture arguments if enabled + if capture_args: + # Bind arguments to parameter names + bound_args = sig.bind(*args, **kwargs) + bound_args.apply_defaults() + + # Convert arguments to a serializable format + params = {} + for param_name, param_value in bound_args.arguments.items(): + try: + # Try to convert to a simple type + params[param_name] = str(param_value) + except: + # Fall back to the parameter name if conversion fails + params[param_name] = f"<{type(param_value).__name__}>" + + # Convert params dictionary to a string representation + span_attributes[ToolAttributes.TOOL_PARAMETERS] = str(params) + + # Add custom attributes + if attributes: + span_attributes.update(attributes) + + # Add kwargs as attributes + span_attributes.update(kwargs) + + # Add span kind directly to attributes + span_attributes["span.kind"] = AgentOpsSpanKindValues.TOOL.value + + # Create and start the span as a child of the current span + with _tracer.start_as_current_span( + name=tool_name, + kind=OTelSpanKind.INTERNAL, + attributes=span_attributes + ) as span: try: - returns = func(*args, **kwargs) - - event.returns = list(returns) if isinstance(returns, tuple) else returns - - if hasattr(returns, "screenshot"): - event.screenshot = returns.screenshot # type: ignore - - event.end_timestamp = get_ISO_time() - - if session: - session.record(event) - else: - Client().record(event) - + # Set initial status + span.set_attribute(ToolAttributes.TOOL_STATUS, ToolStatus.EXECUTING.value) + + # Call the original function + result = func(*args, **kwargs) + + # Capture result if enabled + if capture_result: + try: + # Try to convert to a simple type + span.set_attribute(ToolAttributes.TOOL_RESULT, str(result)) + except: + # Fall back to the type name if conversion fails + span.set_attribute(ToolAttributes.TOOL_RESULT, f"<{type(result).__name__}>") + + # Set success status + span.set_attribute(ToolAttributes.TOOL_STATUS, ToolStatus.SUCCEEDED.value) + + return result except Exception as e: - Client().record(ErrorEvent(trigger_event=event, exception=e)) - + # Set error status and attributes + span.set_attribute(ToolAttributes.TOOL_STATUS, ToolStatus.FAILED.value) + span.set_attribute(CoreAttributes.ERROR_TYPE, type(e).__name__) + span.set_attribute(CoreAttributes.ERROR_MESSAGE, str(e)) + # Re-raise the exception raise - - return returns - - return sync_wrapper - + + return wrapper + return decorator - -def record_tool(tool_name: Optional[str] = None): +def span( + name: Optional[str] = None, + kind: Optional[str] = None, + capture_args: bool = True, + capture_result: bool = True, + attributes: Optional[Dict[str, Any]] = None, + **kwargs +) -> Callable: """ - Decorator to record a tool use event before and after a function call. - Usage: - - Tools: Records function parameters and return statements of the - function being decorated. Additionally, timing information about - the action is recorded + General-purpose span decorator for functions and methods. + + Creates a span for each invocation of the function. + For methods of an agent class, the span will be a child of the agent span. + Args: - tool_name (optional, str): The name of the event to record. + name: Name of the span (defaults to function name) + kind: Kind of span (from SpanKind) + capture_args: Whether to capture function arguments as span attributes + capture_result: Whether to capture function result as span attribute + attributes: Additional attributes to add to the span + **kwargs: Additional keyword arguments to add as attributes + + Returns: + Decorated function """ - def decorator(func): - if inspect.iscoroutinefunction(func): - + # Get function signature for argument names + sig = inspect.signature(func) + + # Determine if the function is a coroutine + is_coroutine = inspect.iscoroutinefunction(func) + + if is_coroutine: @functools.wraps(func) - async def async_wrapper(*args, session: Optional[Session] = None, **kwargs): - init_time = get_ISO_time() - if "session" in kwargs.keys(): - del kwargs["session"] - if session is None: - if Client().is_multi_session: - raise ValueError( - "If multiple sessions exists, `session` is a required parameter in the function decorated by @record_tool" - ) - func_args = inspect.signature(func).parameters - arg_names = list(func_args.keys()) - # Get default values - arg_values = { - name: func_args[name].default for name in arg_names if func_args[name].default is not inspect._empty - } - # Update with positional arguments - arg_values.update(dict(zip(arg_names, args))) - arg_values.update(kwargs) - - if not tool_name: - name = func.__name__ + async def async_wrapper(self_or_arg, *args, **kwargs): + # Determine if this is a method call (has self) + is_method = not inspect.isfunction(self_or_arg) and not inspect.ismethod(self_or_arg) + self = self_or_arg if is_method else None + + # Adjust args if this is not a method call + if not is_method: + args = (self_or_arg,) + args + + # Create span attributes + span_attributes = {} + + # Add span name + span_name = name if name is not None else func.__name__ + + # Capture arguments if enabled + if capture_args: + try: + # Bind arguments to parameter names + if is_method: + # For methods, include self in the binding + method_args = (self,) + args + bound_args = sig.bind(self, *args, **kwargs) + else: + # For regular functions + bound_args = sig.bind(*args, **kwargs) + + bound_args.apply_defaults() + + # Convert arguments to a serializable format + for param_name, param_value in bound_args.arguments.items(): + # Skip 'self' parameter + if param_name == 'self': + continue + + try: + # Try to convert to a simple type + span_attributes[f"arg.{param_name}"] = str(param_value) + except: + # Fall back to the parameter name if conversion fails + span_attributes[f"arg.{param_name}"] = f"<{type(param_value).__name__}>" + except Exception as e: + # If binding fails, log it as an attribute but continue + span_attributes["error.binding_args"] = str(e) + + # Add custom attributes + if attributes: + span_attributes.update(attributes) + + # Add kwargs as attributes + span_attributes.update(kwargs) + + # Add span kind directly to attributes if provided + if kind: + span_attributes["span.kind"] = kind + + # Check if this is a method of an agent class + parent_context = None + if is_method and hasattr(self, "_agentops_context"): + # Use the agent's context as parent + parent_context = self._agentops_context + + # Create and start the span with the appropriate parent context + if parent_context: + # Use the agent's context + token = context.attach(parent_context) + try: + with _tracer.start_as_current_span( + name=span_name, + kind=OTelSpanKind.INTERNAL, + attributes=span_attributes + ) as span: + try: + # Call the original function + result = await func(self, *args, **kwargs) if is_method else await func(*args, **kwargs) + + # Capture result if enabled + if capture_result: + try: + # Try to convert to a simple type + span.set_attribute("result", str(result)) + except: + # Fall back to the type name if conversion fails + span.set_attribute("result", f"<{type(result).__name__}>") + + return result + except Exception as e: + # Set error attributes + span.set_attribute(CoreAttributes.ERROR_TYPE, type(e).__name__) + span.set_attribute(CoreAttributes.ERROR_MESSAGE, str(e)) + + # Re-raise the exception + raise + finally: + context.detach(token) else: - name = tool_name - - event = ToolEvent( - params=arg_values, - init_timestamp=init_time, - agent_id=check_call_stack_for_agent_id(), - name=name, - ) - - try: - returns = await func(*args, **kwargs) - - event.returns = list(returns) if isinstance(returns, tuple) else returns - - # NOTE: Will likely remove in future since this is tightly coupled. Adding it to see how useful we find it for now - # TODO: check if screenshot is the url string we expect it to be? And not e.g. "True" - if hasattr(returns, "screenshot"): - event.screenshot = returns.screenshot # type: ignore - - event.end_timestamp = get_ISO_time() - - if session: - session.record(event) - else: - Client().record(event) - - except Exception as e: - Client().record(ErrorEvent(trigger_event=event, exception=e)) - - # Re-raise the exception - raise - - return returns - + # No agent context, use current context + with _tracer.start_as_current_span( + name=span_name, + kind=OTelSpanKind.INTERNAL, + attributes=span_attributes + ) as span: + try: + # Call the original function + result = await func(self, *args, **kwargs) if is_method else await func(*args, **kwargs) + + # Capture result if enabled + if capture_result: + try: + # Try to convert to a simple type + span.set_attribute("result", str(result)) + except: + # Fall back to the type name if conversion fails + span.set_attribute("result", f"<{type(result).__name__}>") + + return result + except Exception as e: + # Set error attributes + span.set_attribute(CoreAttributes.ERROR_TYPE, type(e).__name__) + span.set_attribute(CoreAttributes.ERROR_MESSAGE, str(e)) + + # Re-raise the exception + raise + return async_wrapper else: - @functools.wraps(func) - def sync_wrapper(*args, session: Optional[Session] = None, **kwargs): - init_time = get_ISO_time() - if "session" in kwargs.keys(): - del kwargs["session"] - if session is None: - if Client().is_multi_session: - raise ValueError( - "If multiple sessions exists, `session` is a required parameter in the function decorated by @record_tool" - ) - func_args = inspect.signature(func).parameters - arg_names = list(func_args.keys()) - # Get default values - arg_values = { - name: func_args[name].default for name in arg_names if func_args[name].default is not inspect._empty - } - # Update with positional arguments - arg_values.update(dict(zip(arg_names, args))) - arg_values.update(kwargs) - - if not tool_name: - name = func.__name__ + def wrapper(self_or_arg, *args, **kwargs): + # Determine if this is a method call (has self) + is_method = not inspect.isfunction(self_or_arg) and not inspect.ismethod(self_or_arg) + self = self_or_arg if is_method else None + + # Adjust args if this is not a method call + if not is_method: + args = (self_or_arg,) + args + + # Create span attributes + span_attributes = {} + + # Add span name + span_name = name if name is not None else func.__name__ + + # Capture arguments if enabled + if capture_args: + try: + # Bind arguments to parameter names + if is_method: + # For methods, include self in the binding + method_args = (self,) + args + bound_args = sig.bind(self, *args, **kwargs) + else: + # For regular functions + bound_args = sig.bind(*args, **kwargs) + + bound_args.apply_defaults() + + # Convert arguments to a serializable format + for param_name, param_value in bound_args.arguments.items(): + # Skip 'self' parameter + if param_name == 'self': + continue + + try: + # Try to convert to a simple type + span_attributes[f"arg.{param_name}"] = str(param_value) + except: + # Fall back to the parameter name if conversion fails + span_attributes[f"arg.{param_name}"] = f"<{type(param_value).__name__}>" + except Exception as e: + # If binding fails, log it as an attribute but continue + span_attributes["error.binding_args"] = str(e) + + # Add custom attributes + if attributes: + span_attributes.update(attributes) + + # Add kwargs as attributes + span_attributes.update(kwargs) + + # Add span kind directly to attributes if provided + if kind: + span_attributes["span.kind"] = kind + + # Check if this is a method of an agent class + parent_context = None + if is_method and hasattr(self, "_agentops_context"): + # Use the agent's context as parent + parent_context = self._agentops_context + + # Create and start the span with the appropriate parent context + if parent_context: + # Use the agent's context + token = context.attach(parent_context) + try: + with _tracer.start_as_current_span( + name=span_name, + kind=OTelSpanKind.INTERNAL, + attributes=span_attributes + ) as span: + try: + # Call the original function + result = func(self, *args, **kwargs) if is_method else func(*args, **kwargs) + + # Capture result if enabled + if capture_result: + try: + # Try to convert to a simple type + span.set_attribute("result", str(result)) + except: + # Fall back to the type name if conversion fails + span.set_attribute("result", f"<{type(result).__name__}>") + + return result + except Exception as e: + # Set error attributes + span.set_attribute(CoreAttributes.ERROR_TYPE, type(e).__name__) + span.set_attribute(CoreAttributes.ERROR_MESSAGE, str(e)) + + # Re-raise the exception + raise + finally: + context.detach(token) else: - name = tool_name - - event = ToolEvent( - params=arg_values, - init_timestamp=init_time, - agent_id=check_call_stack_for_agent_id(), - name=name, - ) - - try: - returns = func(*args, **kwargs) - - event.returns = list(returns) if isinstance(returns, tuple) else returns - - if hasattr(returns, "screenshot"): - event.screenshot = returns.screenshot # type: ignore - - event.end_timestamp = get_ISO_time() - - if session: - session.record(event) - else: - Client().record(event) - - except Exception as e: - Client().record(ErrorEvent(trigger_event=event, exception=e)) - - # Re-raise the exception - raise - - return returns - - return sync_wrapper - + # No agent context, use current context + with _tracer.start_as_current_span( + name=span_name, + kind=OTelSpanKind.INTERNAL, + attributes=span_attributes + ) as span: + try: + # Call the original function + result = func(self, *args, **kwargs) if is_method else func(*args, **kwargs) + + # Capture result if enabled + if capture_result: + try: + # Try to convert to a simple type + span.set_attribute("result", str(result)) + except: + # Fall back to the type name if conversion fails + span.set_attribute("result", f"<{type(result).__name__}>") + + return result + except Exception as e: + # Set error attributes + span.set_attribute(CoreAttributes.ERROR_TYPE, type(e).__name__) + span.set_attribute(CoreAttributes.ERROR_MESSAGE, str(e)) + + # Re-raise the exception + raise + + return wrapper + return decorator - -def track_agent(name: Union[str, None] = None): - def decorator(obj): - if inspect.isclass(obj): - # Set up the descriptors on the class - setattr(obj, "agentops_agent_id", agentops_property()) - setattr(obj, "agentops_agent_name", agentops_property()) - - original_init = obj.__init__ - - def new_init(self, *args, **kwargs): - """ - WIthin the __init__ method, we set agentops_ properties via the private, internal descriptor - """ - try: - # Handle name from kwargs first - name_ = kwargs.pop("agentops_name", None) - - # Call original init - original_init(self, *args, **kwargs) - - # Set the agent ID - self._agentops_agent_id = str(uuid4()) - - # Force set the private name directly to bypass potential Pydantic interference - if name_ is not None: - setattr(self, "_agentops_agent_name", name_) - elif name is not None: - setattr(self, "_agentops_agent_name", name) - elif hasattr(self, "role"): - setattr(self, "_agentops_agent_name", self.role) - - session = kwargs.get("session", None) - if session is not None: - self._agentops_session_id = session.session_id - - Client().create_agent( - name=self.agentops_agent_name, - agent_id=self.agentops_agent_id, - session=session, - ) - - except AttributeError as ex: - logger.debug(ex) - Client().add_pre_init_warning(f"Failed to track an agent {name} with the @track_agent decorator.") - logger.warning("Failed to track an agent with the @track_agent decorator.") - - obj.__init__ = new_init - - elif inspect.isfunction(obj): - obj.agentops_agent_id = str(uuid4()) - obj.agentops_agent_name = name - Client().create_agent(name=obj.agentops_agent_name, agent_id=obj.agentops_agent_id) - - else: - raise Exception("Invalid input, 'obj' must be a class or a function") - - return obj - - return decorator +@contextmanager +def create_span( + name: str, + kind: Optional[str] = None, + attributes: Optional[Dict[str, Any]] = None, + **kwargs +) -> ContextManager: + """ + Context manager for creating spans manually. + + Creates a span that's a child of the current span. + """ + # Create span attributes + span_attributes = {} + + # Add custom attributes + if attributes: + span_attributes.update(attributes) + + # Add kwargs as attributes + span_attributes.update(kwargs) + + # Add span kind directly to attributes if provided + if kind: + span_attributes["span.kind"] = kind + + # Create and start the span as a child of the current span + with _tracer.start_as_current_span( + name=name, + kind=OTelSpanKind.INTERNAL, + attributes=span_attributes + ) as span: + try: + yield span + except Exception as e: + # Set error attributes + span.set_attribute(CoreAttributes.ERROR_TYPE, type(e).__name__) + span.set_attribute(CoreAttributes.ERROR_MESSAGE, str(e)) + + # Re-raise the exception + raise + +def current_span() -> Optional[Span]: + """Get the current active span.""" + return trace.get_current_span() + +def add_span_attribute(key: str, value: Any) -> None: + """Add an attribute to the current span.""" + span = current_span() + if span: + span.set_attribute(key, value) + +def add_span_event(name: str, attributes: Optional[Dict[str, Any]] = None) -> None: + """Add an event to the current span.""" + span = current_span() + if span: + span.add_event(name, attributes) \ No newline at end of file diff --git a/agentops/descriptor.py b/agentops/descriptor.py deleted file mode 100644 index 020804cbe..000000000 --- a/agentops/descriptor.py +++ /dev/null @@ -1,187 +0,0 @@ -import inspect -import logging -from typing import Union -from uuid import UUID - - -class agentops_property: - """ - A descriptor that provides a standardized way to handle agent property access and storage. - Properties are automatically stored with an '_agentops_' prefix to avoid naming conflicts. - - The descriptor can be used in two ways: - 1. As a class attribute directly - 2. Added dynamically through a decorator (like @track_agent) - - Attributes: - private_name (str): The internal name used for storing the property value, - prefixed with '_agentops_'. Set either through __init__ or __set_name__. - - Example: - ```python - # Direct usage in a class - class Agent: - name = agentops_property() - id = agentops_property() - - def __init__(self): - self.name = "Agent1" # Stored as '_agentops_name' - self.id = "123" # Stored as '_agentops_id' - - # Usage with decorator - @track_agent() - class Agent: - pass - # agentops_agent_id and agentops_agent_name are added automatically - ``` - - Notes: - - Property names with 'agentops_' prefix are automatically stripped when creating - the internal storage name - - Returns None if the property hasn't been set - - The descriptor will attempt to resolve property names even when added dynamically - """ - - def __init__(self, name=None): - """ - Initialize the descriptor. - - Args: - name (str, optional): The name for the property. Used as fallback when - the descriptor is added dynamically and __set_name__ isn't called. - """ - self.private_name = None - if name: - self.private_name = f"_agentops_{name.replace('agentops_', '')}" - - def __set_name__(self, owner, name): - """ - Called by Python when the descriptor is defined directly in a class. - Sets up the private name used for attribute storage. - - Args: - owner: The class that owns this descriptor - name: The name given to this descriptor in the class - """ - self.private_name = f"_agentops_{name.replace('agentops_', '')}" - - def __get__(self, obj, objtype=None): - """ - Get the property value. - - Args: - obj: The instance to get the property from - objtype: The class of the instance - - Returns: - The property value, or None if not set - The descriptor itself if accessed on the class rather than an instance - - Raises: - AttributeError: If the property name cannot be determined - """ - if obj is None: - return self - - # Handle case where private_name wasn't set by __set_name__ - if self.private_name is None: - # Try to find the name by looking through the class dict - for name, value in type(obj).__dict__.items(): - if value is self: - self.private_name = f"_agentops_{name.replace('agentops_', '')}" - break - if self.private_name is None: - raise AttributeError("Property name could not be determined") - - # First try getting from object's __dict__ (for Pydantic) - if hasattr(obj, "__dict__"): - dict_value = obj.__dict__.get(self.private_name[1:]) - if dict_value is not None: - return dict_value - - # Fall back to our private storage - return getattr(obj, self.private_name, None) - - def __set__(self, obj, value): - """ - Set the property value. - - Args: - obj: The instance to set the property on - value: The value to set - - Raises: - AttributeError: If the property name cannot be determined - """ - if self.private_name is None: - # Same name resolution as in __get__ - for name, val in type(obj).__dict__.items(): - if val is self: - self.private_name = f"_agentops_{name.replace('agentops_', '')}" - break - if self.private_name is None: - raise AttributeError("Property name could not be determined") - - # Set in both object's __dict__ (for Pydantic) and our private storage - if hasattr(obj, "__dict__"): - obj.__dict__[self.private_name[1:]] = value - setattr(obj, self.private_name, value) - - def __delete__(self, obj): - """ - Delete the property value. - - Args: - obj: The instance to delete the property from - - Raises: - AttributeError: If the property name cannot be determined - """ - if self.private_name is None: - raise AttributeError("Property name could not be determined") - try: - delattr(obj, self.private_name) - except AttributeError: - pass - - @staticmethod - def stack_lookup() -> Union[UUID, None]: - """ - Look through the call stack to find an agent ID. - - This method searches the call stack for objects that have agentops_property - descriptors and returns the agent_id if found. - - Returns: - UUID: The agent ID if found in the call stack - None: If no agent ID is found or if "__main__" is encountered - """ - for frame_info in inspect.stack(): - local_vars = frame_info.frame.f_locals - - for var_name, var in local_vars.items(): - # Stop at main - if var == "__main__": - return None - - try: - # Check if object has our AgentOpsDescriptor descriptors - var_type = type(var) - - # Get all class attributes - class_attrs = {name: getattr(var_type, name, None) for name in dir(var_type)} - - agent_id_desc = class_attrs.get("agentops_agent_id") - - if isinstance(agent_id_desc, agentops_property): - agent_id = agent_id_desc.__get__(var, var_type) - - if agent_id: - agent_name_desc = class_attrs.get("agentops_agent_name") - if isinstance(agent_name_desc, agentops_property): - agent_name = agent_name_desc.__get__(var, var_type) - return agent_id - except Exception: - continue - - return None diff --git a/agentops/event.py b/agentops/event.py deleted file mode 100644 index abffcacc4..000000000 --- a/agentops/event.py +++ /dev/null @@ -1,156 +0,0 @@ -""" -AgentOps events. - -Data Class: - Event: Represents discrete events to be recorded. -""" - -import traceback -from dataclasses import dataclass, field -from enum import Enum -from typing import Any, Dict, List, Optional, Sequence, Union -from uuid import UUID, uuid4 - -from .helpers import check_call_stack_for_agent_id, get_ISO_time - - -class EventType(Enum): - LLM = "llms" - ACTION = "actions" - API = "apis" - TOOL = "tools" - ERROR = "errors" - - -@dataclass -class Event: - """ - Abstract base class for events that will be recorded. Should not be instantiated directly. - - event_type(str): The type of event. Defined in events.EventType. Some values are 'llm', 'action', 'api', 'tool', 'error'. - params(dict, optional): The parameters of the function containing the triggered event, e.g. {'x': 1} in example below - returns(str, optional): The return value of the function containing the triggered event, e.g. 2 in example below - init_timestamp(str): A timestamp indicating when the event began. Defaults to the time when this Event was instantiated. - end_timestamp(str): A timestamp indicating when the event ended. Defaults to the time when this Event was instantiated. - agent_id(UUID, optional): The unique identifier of the agent that triggered the event. - id(UUID): A unique identifier for the event. Defaults to a new UUID. - session_id(UUID, optional): The unique identifier of the session that the event belongs to. - - foo(x=1) { - ... - // params equals {'x': 1} - record(ActionEvent(params=**kwargs, ...)) - ... - // returns equals 2 - return x+1 - } - """ - - event_type: EventType - params: Optional[dict] = None - returns: Optional[Union[str, List[str]]] = None - init_timestamp: str = field(default_factory=get_ISO_time) - end_timestamp: Optional[str] = None - agent_id: Optional[UUID] = field(default_factory=check_call_stack_for_agent_id) - id: UUID = field(default_factory=uuid4) - session_id: Optional[UUID] = None - - -@dataclass -class ActionEvent(Event): - """ - For generic events - - action_type(str, optional): High level name describing the action - logs(str, optional): For detailed information/logging related to the action - screenshot(str, optional): url to snapshot if agent interacts with UI - """ - - event_type: str = EventType.ACTION.value - # TODO: Should not be optional, but non-default argument 'agent_id' follows default argument error - action_type: Optional[str] = None - logs: Optional[Union[str, Sequence[Any]]] = None - screenshot: Optional[str] = None - - -@dataclass -class LLMEvent(Event): - """ - For recording calls to LLMs. AgentOps auto-instruments calls to the most popular LLMs e.g. GPT, Claude, Gemini, etc. - - thread_id(UUID, optional): The unique identifier of the contextual thread that a message pertains to. - prompt(str, list, optional): The message or messages that were used to prompt the LLM. Preferably in ChatML format which is more fully supported by AgentOps. - prompt_tokens(int, optional): The number of tokens in the prompt message. - completion(str, object, optional): The message or messages returned by the LLM. Preferably in ChatML format which is more fully supported by AgentOps. - completion_tokens(int, optional): The number of tokens in the completion message. - model(str, optional): LLM model e.g. "gpt-4", "gpt-3.5-turbo". - - """ - - event_type: str = EventType.LLM.value - thread_id: Optional[UUID] = None - prompt: Optional[Union[str, List]] = None - prompt_tokens: Optional[int] = None - completion: Union[str, object] = None - completion_tokens: Optional[int] = None - cost: Optional[float] = None - model: Optional[str] = None - - -@dataclass -class ToolEvent(Event): - """ - For recording calls to tools e.g. searchWeb, fetchFromDB - - name(str, optional): A name describing the tool or the actual function name if applicable e.g. searchWeb, fetchFromDB. - logs(str, dict, optional): For detailed information/logging related to the tool. - - """ - - event_type: str = EventType.TOOL.value - name: Optional[str] = None - logs: Optional[Union[str, dict]] = None - - -# Does not inherit from Event because error will (optionally) be linked to an ActionEvent, LLMEvent, etc that will have the details - - -@dataclass -class ErrorEvent(Event): - """ - For recording any errors e.g. ones related to agent execution - - trigger_event(Event, optional): The event object that triggered the error if applicable. - exception(BaseException, optional): The thrown exception. We will automatically parse the error_type and details from this. - error_type(str, optional): The type of error e.g. "ValueError". - code(str, optional): A code that can be used to identify the error e.g. 501. - details(str, optional): Detailed information about the error. - logs(str, optional): For detailed information/logging related to the error. - """ - - # Inherit common Event fields - event_type: str = field(default=EventType.ERROR.value) - - # Error-specific fields - trigger_event: Optional[Event] = None - exception: Optional[BaseException] = None - error_type: Optional[str] = None - code: Optional[str] = None - details: Optional[Union[str, Dict[str, str]]] = None - logs: Optional[str] = field(default_factory=traceback.format_exc) - - def __post_init__(self): - """Process exception if provided""" - if self.exception: - self.error_type = self.error_type or type(self.exception).__name__ - self.details = self.details or str(self.exception) - self.exception = None # removes exception from serialization - - # Ensure end timestamp is set - if not self.end_timestamp: - self.end_timestamp = get_ISO_time() - - @property - def timestamp(self) -> str: - """Maintain backward compatibility with old code expecting timestamp""" - return self.init_timestamp diff --git a/agentops/exceptions.py b/agentops/exceptions.py index 9a6d0b76e..12b0e5405 100644 --- a/agentops/exceptions.py +++ b/agentops/exceptions.py @@ -1,4 +1,4 @@ -from .log_config import logger +from agentops.logging import logger class MultiSessionException(Exception): @@ -7,10 +7,35 @@ def __init__(self, message): class NoSessionException(Exception): - def __init__(self, message): + def __init__(self, message="No session found"): + super().__init__(message) + + +class NoApiKeyException(Exception): + def __init__( + self, + message="Could not initialize AgentOps client - API Key is missing." + + "\n\t Find your API key at https://app.agentops.ai/settings/projects", + ): + super().__init__(message) + + +class InvalidApiKeyException(Exception): + def __init__(self, api_key, endpoint): + message = f"API Key is invalid: {{{api_key}}}.\n\t Find your API key at {endpoint}/settings/projects" super().__init__(message) class ApiServerException(Exception): def __init__(self, message): super().__init__(message) + + +class AgentOpsClientNotInitializedException(RuntimeError): + def __init__(self, message="AgentOps client must be initialized before using this feature"): + super().__init__(message) + + +class AgentOpsApiJwtExpiredException(Exception): + def __init__(self, message="JWT token has expired"): + super().__init__(message) diff --git a/agentops/helpers.py b/agentops/helpers.py deleted file mode 100644 index ca0c4f0e3..000000000 --- a/agentops/helpers.py +++ /dev/null @@ -1,176 +0,0 @@ -import inspect -import json -from datetime import datetime, timezone -from functools import wraps -from importlib.metadata import PackageNotFoundError, version -from pprint import pformat -from typing import Any, Optional, Union -from uuid import UUID -from .descriptor import agentops_property - -import requests - -from .log_config import logger - - -def get_ISO_time(): - """ - Get the current UTC time in ISO 8601 format with milliseconds precision in UTC timezone. - - Returns: - str: The current UTC time as a string in ISO 8601 format. - """ - return datetime.now(timezone.utc).isoformat() - - -def is_jsonable(x): - try: - json.dumps(x) - return True - except (TypeError, OverflowError): - return False - - -def filter_unjsonable(d: dict) -> dict: - def filter_dict(obj): - if isinstance(obj, dict): - # TODO: clean up this mess lol - return { - k: ( - filter_dict(v) - if isinstance(v, (dict, list)) or is_jsonable(v) - else str(v) - if isinstance(v, UUID) - else "" - ) - for k, v in obj.items() - } - elif isinstance(obj, list): - return [ - ( - filter_dict(x) - if isinstance(x, (dict, list)) or is_jsonable(x) - else str(x) - if isinstance(x, UUID) - else "" - ) - for x in obj - ] - else: - return obj if is_jsonable(obj) or isinstance(obj, UUID) else "" - - return filter_dict(d) - - -def safe_serialize(obj): - def default(o): - try: - if isinstance(o, UUID): - return str(o) - elif hasattr(o, "model_dump_json"): - return str(o.model_dump_json()) - elif hasattr(o, "to_json"): - return str(o.to_json()) - elif hasattr(o, "json"): - return str(o.json()) - elif hasattr(o, "to_dict"): - return {k: str(v) for k, v in o.to_dict().items() if not callable(v)} - elif hasattr(o, "dict"): - return {k: str(v) for k, v in o.dict().items() if not callable(v)} - elif isinstance(o, dict): - return {k: str(v) for k, v in o.items()} - elif isinstance(o, list): - return [str(item) for item in o] - else: - return f"<>" - except Exception as e: - return f"<>" - - def remove_unwanted_items(value): - """Recursively remove self key and None/... values from dictionaries so they aren't serialized""" - if isinstance(value, dict): - return { - k: remove_unwanted_items(v) for k, v in value.items() if v is not None and v is not ... and k != "self" - } - elif isinstance(value, list): - return [remove_unwanted_items(item) for item in value] - else: - return value - - cleaned_obj = remove_unwanted_items(obj) - return json.dumps(cleaned_obj, default=default) - - -def check_call_stack_for_agent_id() -> Union[UUID, None]: - return agentops_property.stack_lookup() - - -def get_agentops_version(): - try: - pkg_version = version("agentops") - return pkg_version - except Exception as e: - logger.warning("Error reading package version: %s", e) - return None - - -def check_agentops_update(): - try: - response = requests.get("https://pypi.org/pypi/agentops/json") - - if response.status_code == 200: - json_data = response.json() - latest_version = json_data["info"]["version"] - - try: - current_version = version("agentops") - except PackageNotFoundError: - return None - - if not latest_version == current_version: - logger.warning( - " WARNING: agentops is out of date. Please update with the command: 'pip install --upgrade agentops'" - ) - except Exception as e: - logger.debug(f"Failed to check for updates: {e}") - return None - - -# Function decorator that prints function name and its arguments to the console for debug purposes -# Example output: -# -# on_llm_start called with arguments: -# run_id: UUID('5fda42fe-809b-4179-bad2-321d1a6090c7') -# parent_run_id: UUID('63f1c4da-3e9f-4033-94d0-b3ebed06668f') -# tags: [] -# metadata: {} -# invocation_params: {'_type': 'openai-chat', -# 'model': 'gpt-3.5-turbo', -# 'model_name': 'gpt-3.5-turbo', -# 'n': 1, -# 'stop': ['Observation:'], -# 'stream': False, -# 'temperature': 0.7} -# options: {'stop': ['Observation:']} -# name: None -# batch_size: 1 -# - -# regex to filter for just this: -# ([\s\S]*?)<\/AGENTOPS_DEBUG_OUTPUT>\n - - -def debug_print_function_params(func): - @wraps(func) - def wrapper(self, *args, **kwargs): - logger.debug("\n") - logger.debug(f"{func.__name__} called with arguments:") - - for key, value in kwargs.items(): - logger.debug(f"{key}: {pformat(value)}") - - logger.debug("\n") - - return func(self, *args, **kwargs) - - return wrapper diff --git a/agentops/helpers/__init__.py b/agentops/helpers/__init__.py new file mode 100644 index 000000000..b0f7bbdc2 --- /dev/null +++ b/agentops/helpers/__init__.py @@ -0,0 +1,48 @@ +from .time import get_ISO_time, iso_to_unix_nano, from_unix_nano_to_iso +from .serialization import ( + AgentOpsJSONEncoder, + serialize_uuid, + safe_serialize, + is_jsonable, + filter_unjsonable, +) +from .system import ( + get_host_env, + get_sdk_details, + get_os_details, + get_cpu_details, + get_ram_details, + get_disk_details, + get_installed_packages, + get_current_directory, + get_virtual_env, +) +from .version import get_agentops_version, check_agentops_update +from .debug import debug_print_function_params +from .env import get_env_bool, get_env_int, get_env_list + +__all__ = [ + 'get_ISO_time', + 'iso_to_unix_nano', + 'from_unix_nano_to_iso', + 'AgentOpsJSONEncoder', + 'serialize_uuid', + 'safe_serialize', + 'is_jsonable', + 'filter_unjsonable', + 'get_host_env', + 'get_sdk_details', + 'get_os_details', + 'get_cpu_details', + 'get_ram_details', + 'get_disk_details', + 'get_installed_packages', + 'get_current_directory', + 'get_virtual_env', + 'get_agentops_version', + 'check_agentops_update', + 'debug_print_function_params', + 'get_env_bool', + 'get_env_int', + 'get_env_list' +] \ No newline at end of file diff --git a/agentops/helpers/debug.py b/agentops/helpers/debug.py new file mode 100644 index 000000000..46e5b0ab4 --- /dev/null +++ b/agentops/helpers/debug.py @@ -0,0 +1,20 @@ +from functools import wraps +from pprint import pformat + +from agentops.logging import logger + + +def debug_print_function_params(func): + @wraps(func) + def wrapper(self, *args, **kwargs): + logger.debug("\n") + logger.debug(f"{func.__name__} called with arguments:") + + for key, value in kwargs.items(): + logger.debug(f"{key}: {pformat(value)}") + + logger.debug("\n") + + return func(self, *args, **kwargs) + + return wrapper diff --git a/agentops/helpers/env.py b/agentops/helpers/env.py new file mode 100644 index 000000000..435446b12 --- /dev/null +++ b/agentops/helpers/env.py @@ -0,0 +1,51 @@ +"""Environment variable helper functions""" +import os +from typing import List, Optional, Set + + +def get_env_bool(key: str, default: bool) -> bool: + """Get boolean from environment variable + + Args: + key: Environment variable name + default: Default value if not set + + Returns: + bool: Parsed boolean value + """ + val = os.getenv(key) + if val is None: + return default + return val.lower() in ('true', '1', 't', 'yes') + + +def get_env_int(key: str, default: int) -> int: + """Get integer from environment variable + + Args: + key: Environment variable name + default: Default value if not set + + Returns: + int: Parsed integer value + """ + try: + return int(os.getenv(key, default)) + except (TypeError, ValueError): + return default + + +def get_env_list(key: str, default: Optional[List[str]] = None) -> Set[str]: + """Get comma-separated list from environment variable + + Args: + key: Environment variable name + default: Default list if not set + + Returns: + Set[str]: Set of parsed values + """ + val = os.getenv(key) + if val is None: + return set(default or []) + return set(val.split(',')) \ No newline at end of file diff --git a/agentops/helpers/serialization.py b/agentops/helpers/serialization.py new file mode 100644 index 000000000..5420bde60 --- /dev/null +++ b/agentops/helpers/serialization.py @@ -0,0 +1,81 @@ +"""Serialization helpers for AgentOps""" + +import json +from datetime import datetime +from decimal import Decimal +from enum import Enum +from typing import Any +from uuid import UUID + +from agentops.logging import logger + + +def is_jsonable(x): + try: + json.dumps(x) + return True + except (TypeError, OverflowError): + return False + + +def filter_unjsonable(d: dict) -> dict: + def filter_dict(obj): + if isinstance(obj, dict): + return { + k: ( + filter_dict(v) + if isinstance(v, (dict, list)) or is_jsonable(v) + else str(v) + if isinstance(v, UUID) + else "" + ) + for k, v in obj.items() + } + elif isinstance(obj, list): + return [ + ( + filter_dict(x) + if isinstance(x, (dict, list)) or is_jsonable(x) + else str(x) + if isinstance(x, UUID) + else "" + ) + for x in obj + ] + else: + return obj if is_jsonable(obj) or isinstance(obj, UUID) else "" + + return filter_dict(d) + + +class AgentOpsJSONEncoder(json.JSONEncoder): + """Custom JSON encoder for AgentOps types""" + + def default(self, obj: Any) -> Any: + if isinstance(obj, UUID): + return str(obj) + if isinstance(obj, datetime): + return obj.isoformat() + if isinstance(obj, Decimal): + return str(obj) + if isinstance(obj, set): + return list(obj) + if hasattr(obj, "to_json"): + return obj.to_json() + if isinstance(obj, Enum): + return obj.value + return str(obj) + + +def serialize_uuid(obj: UUID) -> str: + """Serialize UUID to string""" + return str(obj) + + +def safe_serialize(obj: Any) -> Any: + """Safely serialize an object to JSON-compatible format""" + try: + return json.dumps(obj, cls=AgentOpsJSONEncoder) + except (TypeError, ValueError) as e: + logger.warning(f"Failed to serialize object: {e}") + return str(obj) diff --git a/agentops/host_env.py b/agentops/helpers/system.py similarity index 98% rename from agentops/host_env.py rename to agentops/helpers/system.py index d3f798b72..b071e505e 100644 --- a/agentops/host_env.py +++ b/agentops/helpers/system.py @@ -1,12 +1,15 @@ -import platform -import psutil -import socket -from .helpers import get_agentops_version -from .log_config import logger import importlib.metadata import os +import platform +import socket import sys +import psutil + +from agentops.logging import logger + +from .version import get_agentops_version + def get_sdk_details(): try: diff --git a/agentops/helpers/time.py b/agentops/helpers/time.py new file mode 100644 index 000000000..33fb13aaf --- /dev/null +++ b/agentops/helpers/time.py @@ -0,0 +1,17 @@ +from datetime import datetime, timezone + +def get_ISO_time(): + """ + Get the current UTC time in ISO 8601 format with milliseconds precision in UTC timezone. + + Returns: + str: The current UTC time as a string in ISO 8601 format. + """ + return datetime.now(timezone.utc).isoformat() + +def iso_to_unix_nano(iso_time: str) -> int: + dt = datetime.fromisoformat(iso_time) + return int(dt.timestamp() * 1_000_000_000) + +def from_unix_nano_to_iso(unix_nano: int) -> str: + return datetime.fromtimestamp(unix_nano / 1_000_000_000, timezone.utc).isoformat() \ No newline at end of file diff --git a/agentops/helpers/version.py b/agentops/helpers/version.py new file mode 100644 index 000000000..50a60d5cb --- /dev/null +++ b/agentops/helpers/version.py @@ -0,0 +1,36 @@ +from importlib.metadata import PackageNotFoundError, version + +import requests + +from agentops.logging import logger + + +def get_agentops_version(): + try: + pkg_version = version("agentops") + return pkg_version + except Exception as e: + logger.warning("Error reading package version: %s", e) + return None + + +def check_agentops_update(): + try: + response = requests.get("https://pypi.org/pypi/agentops/json") + + if response.status_code == 200: + json_data = response.json() + latest_version = json_data["info"]["version"] + + try: + current_version = version("agentops") + except PackageNotFoundError: + return None + + if not latest_version == current_version: + logger.warning( + " WARNING: agentops is out of date. Please update with the command: 'pip install --upgrade agentops'" + ) + except Exception as e: + logger.debug(f"Failed to check for updates: {e}") + return None diff --git a/agentops/http_client.py b/agentops/http_client.py deleted file mode 100644 index 11c0bf49f..000000000 --- a/agentops/http_client.py +++ /dev/null @@ -1,208 +0,0 @@ -from enum import Enum -from typing import Optional, Dict, Any - -import requests -from requests.adapters import HTTPAdapter, Retry -import json - -from .exceptions import ApiServerException - -JSON_HEADER = {"Content-Type": "application/json; charset=UTF-8", "Accept": "*/*"} - -retry_config = Retry(total=5, backoff_factor=0.1) - - -class HttpStatus(Enum): - SUCCESS = 200 - INVALID_REQUEST = 400 - INVALID_API_KEY = 401 - TIMEOUT = 408 - PAYLOAD_TOO_LARGE = 413 - TOO_MANY_REQUESTS = 429 - FAILED = 500 - UNKNOWN = -1 - - -class Response: - def __init__(self, status: HttpStatus = HttpStatus.UNKNOWN, body: Optional[dict] = None): - self.status: HttpStatus = status - self.code: int = status.value - self.body = body if body else {} - - def parse(self, res: requests.models.Response): - res_body = res.json() - self.code = res.status_code - self.status = self.get_status(self.code) - self.body = res_body - return self - - @staticmethod - def get_status(code: int) -> HttpStatus: - if 200 <= code < 300: - return HttpStatus.SUCCESS - elif code == 429: - return HttpStatus.TOO_MANY_REQUESTS - elif code == 413: - return HttpStatus.PAYLOAD_TOO_LARGE - elif code == 408: - return HttpStatus.TIMEOUT - elif code == 401: - return HttpStatus.INVALID_API_KEY - elif 400 <= code < 500: - return HttpStatus.INVALID_REQUEST - elif code >= 500: - return HttpStatus.FAILED - return HttpStatus.UNKNOWN - - -class HttpClient: - _session: Optional[requests.Session] = None - - @classmethod - def get_session(cls) -> requests.Session: - """Get or create the global session with optimized connection pooling""" - if cls._session is None: - cls._session = requests.Session() - - # Configure connection pooling - adapter = requests.adapters.HTTPAdapter( - pool_connections=15, # Number of connection pools - pool_maxsize=256, # Connections per pool - max_retries=Retry(total=3, backoff_factor=0.1, status_forcelist=[500, 502, 503, 504]), - ) - - # Mount adapter for both HTTP and HTTPS - cls._session.mount("http://", adapter) - cls._session.mount("https://", adapter) - - # Set default headers - cls._session.headers.update( - { - "Connection": "keep-alive", - "Keep-Alive": "timeout=10, max=1000", - "Content-Type": "application/json", - } - ) - - return cls._session - - @classmethod - def _prepare_headers( - cls, - api_key: Optional[str] = None, - parent_key: Optional[str] = None, - jwt: Optional[str] = None, - custom_headers: Optional[dict] = None, - ) -> dict: - """Prepare headers for the request""" - headers = JSON_HEADER.copy() - - if api_key is not None: - headers["X-Agentops-Api-Key"] = api_key - - if parent_key is not None: - headers["X-Agentops-Parent-Key"] = parent_key - - if jwt is not None: - headers["Authorization"] = f"Bearer {jwt}" - - if custom_headers is not None: - headers.update(custom_headers) - - return headers - - @classmethod - def post( - cls, - url: str, - payload: bytes, - api_key: Optional[str] = None, - parent_key: Optional[str] = None, - jwt: Optional[str] = None, - header: Optional[Dict[str, str]] = None, - ) -> Response: - """Make HTTP POST request using connection pooling""" - result = Response() - try: - headers = cls._prepare_headers(api_key, parent_key, jwt, header) - session = cls.get_session() - res = session.post(url, data=payload, headers=headers, timeout=20) - result.parse(res) - - except requests.exceptions.Timeout: - result.code = 408 - result.status = HttpStatus.TIMEOUT - raise ApiServerException("Could not reach API server - connection timed out") - except requests.exceptions.HTTPError as e: - try: - result.parse(e.response) - except Exception: - result = Response() - result.code = e.response.status_code - result.status = Response.get_status(e.response.status_code) - result.body = {"error": str(e)} - raise ApiServerException(f"HTTPError: {e}") - except requests.exceptions.RequestException as e: - result.body = {"error": str(e)} - raise ApiServerException(f"RequestException: {e}") - - if result.code == 401: - raise ApiServerException( - f"API server: invalid API key: {api_key}. Find your API key at https://app.agentops.ai/settings/projects" - ) - if result.code == 400: - if "message" in result.body: - raise ApiServerException(f"API server: {result.body['message']}") - else: - raise ApiServerException(f"API server: {result.body}") - if result.code == 500: - raise ApiServerException("API server: - internal server error") - - return result - - @classmethod - def get( - cls, - url: str, - api_key: Optional[str] = None, - jwt: Optional[str] = None, - header: Optional[Dict[str, str]] = None, - ) -> Response: - """Make HTTP GET request using connection pooling""" - result = Response() - try: - headers = cls._prepare_headers(api_key, None, jwt, header) - session = cls.get_session() - res = session.get(url, headers=headers, timeout=20) - result.parse(res) - - except requests.exceptions.Timeout: - result.code = 408 - result.status = HttpStatus.TIMEOUT - raise ApiServerException("Could not reach API server - connection timed out") - except requests.exceptions.HTTPError as e: - try: - result.parse(e.response) - except Exception: - result = Response() - result.code = e.response.status_code - result.status = Response.get_status(e.response.status_code) - result.body = {"error": str(e)} - raise ApiServerException(f"HTTPError: {e}") - except requests.exceptions.RequestException as e: - result.body = {"error": str(e)} - raise ApiServerException(f"RequestException: {e}") - - if result.code == 401: - raise ApiServerException( - f"API server: invalid API key: {api_key}. Find your API key at https://app.agentops.ai/settings/projects" - ) - if result.code == 400: - if "message" in result.body: - raise ApiServerException(f"API server: {result.body['message']}") - else: - raise ApiServerException(f"API server: {result.body}") - if result.code == 500: - raise ApiServerException("API server: - internal server error") - - return result diff --git a/agentops/instrumentation/README.md b/agentops/instrumentation/README.md new file mode 100644 index 000000000..dc8949bd3 --- /dev/null +++ b/agentops/instrumentation/README.md @@ -0,0 +1,55 @@ +# AgentOps Instrumentation + +This package provides OpenTelemetry instrumentation for various LLM providers and related services. + +## Available Instrumentors + +- OpenAI (`v0.27.0+` and `v1.0.0+`) + + +## Usage + +### OpenAI Instrumentation + +```python +from opentelemetry.instrumentation.openai import OpenAIInstrumentor + +from agentops.telemetry import get_tracer_provider() + +# Initialize and instrument +instrumentor = OpenAIInstrumentor( + enrich_assistant=True, # Include assistant messages in spans + enrich_token_usage=True, # Include token usage in spans + enable_trace_context_propagation=True, # Enable trace context propagation +) +instrumentor.instrument(tracer_provider=tracer_provider) # <-- Uses the global AgentOps TracerProvider +``` + +## Dependency Handling + +OpenTelemetry instrumentors implement a dependency checking mechanism that runs before instrumentation: + +1. Each instrumentor declares its dependencies via the `instrumentation_dependencies()` method +2. When `instrument()` is called, it checks for dependency conflicts using `_check_dependency_conflicts()` +3. If any conflicts are found, it logs an error and returns without instrumenting + +```python +# Example of how OpenAIInstrumentor declares its dependency +_instruments = ("openai >= 0.27.0",) + +def instrumentation_dependencies(self) -> Collection[str]: + return _instruments +``` + +### Safety Notes + +- The instrumentor is designed to be **safe** when dependencies are missing +- By default, the dependency check prevents instrumentation if the required package is missing or has version conflicts +- The instrumentor will log an error and gracefully exit without attempting to import missing packages +- Imports in `_instrument()` are done lazily (only when the method is called), which helps avoid import errors at module load time +- Only if you explicitly bypass the check with `skip_dep_check=True` would you encounter import errors + +> To add custom instrumentation, please do so in the `third_party/opentelemetry` directory. + + + diff --git a/agentops/instrumentation/__init__.py b/agentops/instrumentation/__init__.py new file mode 100644 index 000000000..1799c7983 --- /dev/null +++ b/agentops/instrumentation/__init__.py @@ -0,0 +1,146 @@ +from typing import Any +from types import ModuleType +from dataclasses import dataclass +import importlib + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor + +from agentops.logging import logger +from agentops.session.tracer import get_tracer_provider + + +# references to all active instrumentors +_active_instrumentors: list[BaseInstrumentor] = [] + + +@dataclass +class InstrumentorLoader: + """ + Represents a dynamically-loadable instrumentor. + + This class is used to load and activate instrumentors based on their module and class names. + We use the `provider_import_name` to determine if the library is installed in the environment. + + `modue_name` is the name of the module to import from. + `class_name` is the name of the class to instantiate from the module. + `provider_import_name` is the name of the package to check for availability. + """ + module_name: str + class_name: str + provider_import_name: str + + @property + def module(self) -> ModuleType: + """Reference to the instrumentor module.""" + return importlib.import_module(self.module_name) + + @property + def should_activate(self) -> bool: + """Is the provider import available in the environment?""" + try: + importlib.import_module(self.provider_import_name) + return True + except ImportError: + return False + + def get_instance(self) -> BaseInstrumentor: + """Return a new instance of the instrumentor.""" + tracer_provider = get_tracer_provider() + return getattr(self.module, self.class_name)() + + +available_instrumentors: list[InstrumentorLoader] = [ + InstrumentorLoader( + module_name='opentelemetry.instrumentation.openai', + class_name='OpenAIInstrumentor', + provider_import_name='openai', + ), + InstrumentorLoader( + module_name='opentelemetry.instrumentation.anthropic', + class_name='AnthropicInstrumentor', + provider_import_name='anthropic', + ), + InstrumentorLoader( + module_name='opentelemetry.instrumentation.cohere', + class_name='CohereInstrumentor', + provider_import_name='cohere', + ), + InstrumentorLoader( + module_name='opentelemetry.instrumentation.crewai', + class_name='CrewAIInstrumentor', + provider_import_name='crewai', + ), + InstrumentorLoader( + module_name='opentelemetry.instrumentation.groq', + class_name='GroqInstrumentor', + provider_import_name='groq', + ), + InstrumentorLoader( + module_name='opentelemetry.instrumentation.haystack', + class_name='HaystackInstrumentor', + provider_import_name='haystack', + ), + InstrumentorLoader( + module_name='opentelemetry.instrumentation.mistralai', + class_name='MistralAiInstrumentor', + provider_import_name='mistralai', + ), + InstrumentorLoader( + module_name='opentelemetry.instrumentation.ollama', + class_name='OllamaInstrumentor', + provider_import_name='ollama', + ), + InstrumentorLoader( + module_name='opentelemetry.instrumentation.agents', + class_name='AgentsInstrumentor', + provider_import_name='agents', + ), +] + + +def instrument_one(loader: InstrumentorLoader) -> BaseInstrumentor: + """Instrument a single instrumentor.""" + if not loader.should_activate: + # this package is not in the environment; skip + logger.debug(f"Package {loader.provider_import_name} not found; skipping instrumentation of {loader.class_name}") + return None + + instrumentor = loader.get_instance() + instrumentor.instrument(tracer_provider=get_tracer_provider()) + logger.info(f"Instrumented {loader.class_name}") + _active_instrumentors.append(instrumentor) + + return instrumentor + + +def instrument_all(): + """ + Instrument all available instrumentors. + This function is called when `instrument_llm_calls` is enabled. + """ + global _active_instrumentors + + if len(_active_instrumentors): + logger.warning("Instrumentors have already been populated.") + return + + for loader in available_instrumentors: + if loader.class_name in _active_instrumentors: + # already instrumented + logger.warning(f"Instrumentor {loader.class_name} has already been instrumented.") + return None + + instrumentor = instrument_one(loader) + _active_instrumentors.append(instrumentor) + + +def uninstrument_all(): + """ + Uninstrument all available instrumentors. + This can be called to disable instrumentation. + """ + global _active_instrumentors + for instrumentor in _active_instrumentors: + instrumentor.uninstrument() + logger.info(f"Uninstrumented {instrumentor.__class__.__name__}") + _active_instrumentors = [] diff --git a/agentops/llms/providers/__init__.py b/agentops/llms/providers/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/agentops/llms/providers/ai21.py b/agentops/llms/providers/ai21.py deleted file mode 100644 index 8271a2a64..000000000 --- a/agentops/llms/providers/ai21.py +++ /dev/null @@ -1,176 +0,0 @@ -import inspect -import pprint -from typing import Optional - -from agentops.llms.providers.base import BaseProvider -from agentops.time_travel import fetch_completion_override_from_time_travel_cache - -from agentops.event import ErrorEvent, LLMEvent, ActionEvent, ToolEvent -from agentops.session import Session -from agentops.log_config import logger -from agentops.helpers import check_call_stack_for_agent_id, get_ISO_time -from agentops.singleton import singleton - - -@singleton -class AI21Provider(BaseProvider): - original_create = None - original_create_async = None - - def __init__(self, client): - super().__init__(client) - self._provider_name = "AI21" - - def handle_response(self, response, kwargs, init_timestamp, session: Optional[Session] = None): - """Handle responses for AI21""" - from ai21.stream.stream import Stream - from ai21.stream.async_stream import AsyncStream - from ai21.models.chat.chat_completion_chunk import ChatCompletionChunk - - llm_event = LLMEvent(init_timestamp=init_timestamp, params=kwargs) - - if session is not None: - llm_event.session_id = session.session_id - - def handle_stream_chunk(chunk: ChatCompletionChunk): - # We take the first ChatCompletionChunk and accumulate the deltas from all subsequent chunks to build one full chat completion - if llm_event.returns is None: - llm_event.returns = chunk - # Manually setting content to empty string to avoid error - llm_event.returns.choices[0].delta.content = "" - - try: - accumulated_delta = llm_event.returns.choices[0].delta - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.model = kwargs["model"] - llm_event.prompt = [message.model_dump() for message in kwargs["messages"]] - - # NOTE: We assume for completion only choices[0] is relevant - choice = chunk.choices[0] - - if choice.delta.content: - accumulated_delta.content += choice.delta.content - - if choice.delta.role: - accumulated_delta.role = choice.delta.role - - if getattr("choice.delta", "tool_calls", None): - accumulated_delta.tool_calls += ToolEvent(logs=choice.delta.tools) - - if choice.finish_reason: - # Streaming is done. Record LLMEvent - llm_event.returns.choices[0].finish_reason = choice.finish_reason - llm_event.completion = { - "role": accumulated_delta.role, - "content": accumulated_delta.content, - } - llm_event.prompt_tokens = chunk.usage.prompt_tokens - llm_event.completion_tokens = chunk.usage.completion_tokens - llm_event.end_timestamp = get_ISO_time() - self._safe_record(session, llm_event) - - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - - kwargs_str = pprint.pformat(kwargs) - chunk = pprint.pformat(chunk) - logger.warning( - f"Unable to parse a chunk for LLM call. Skipping upload to AgentOps\n" - f"chunk:\n {chunk}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - # if the response is a generator, decorate the generator - # For synchronous Stream - if isinstance(response, Stream): - - def generator(): - for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return generator() - - # For asynchronous AsyncStream - if isinstance(response, AsyncStream): - - async def async_generator(): - async for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return async_generator() - - # Handle object responses - try: - llm_event.returns = response - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.model = kwargs["model"] - llm_event.prompt = [message.model_dump() for message in kwargs["messages"]] - llm_event.prompt_tokens = response.usage.prompt_tokens - llm_event.completion = response.choices[0].message.model_dump() - llm_event.completion_tokens = response.usage.completion_tokens - llm_event.end_timestamp = get_ISO_time() - self._safe_record(session, llm_event) - - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - kwargs_str = pprint.pformat(kwargs) - response = pprint.pformat(response) - logger.warning( - f"Unable to parse response for LLM call. Skipping upload to AgentOps\n" - f"response:\n {response}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - return response - - def override(self): - self._override_completion() - self._override_completion_async() - - def _override_completion(self): - from ai21.clients.studio.resources.chat import ChatCompletions - - global original_create - original_create = ChatCompletions.create - - def patched_function(*args, **kwargs): - # Call the original function with its original arguments - init_timestamp = get_ISO_time() - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - result = original_create(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=session) - - # Override the original method with the patched one - ChatCompletions.create = patched_function - - def _override_completion_async(self): - from ai21.clients.studio.resources.chat import AsyncChatCompletions - - global original_create_async - original_create_async = AsyncChatCompletions.create - - async def patched_function(*args, **kwargs): - # Call the original function with its original arguments - init_timestamp = get_ISO_time() - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - result = await original_create_async(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=session) - - # Override the original method with the patched one - AsyncChatCompletions.create = patched_function - - def undo_override(self): - if self.original_create is not None and self.original_create_async is not None: - from ai21.clients.studio.resources.chat import ( - ChatCompletions, - AsyncChatCompletions, - ) - - ChatCompletions.create = self.original_create - AsyncChatCompletions.create = self.original_create_async diff --git a/agentops/llms/providers/anthropic.py b/agentops/llms/providers/anthropic.py deleted file mode 100644 index 02d536fb4..000000000 --- a/agentops/llms/providers/anthropic.py +++ /dev/null @@ -1,332 +0,0 @@ -import json -import pprint -from typing import Optional - -from agentops.llms.providers.base import BaseProvider -from agentops.time_travel import fetch_completion_override_from_time_travel_cache - -from agentops.event import ErrorEvent, LLMEvent, ToolEvent -from agentops.helpers import check_call_stack_for_agent_id, get_ISO_time -from agentops.log_config import logger -from agentops.session import Session -from agentops.singleton import singleton - - -@singleton -class AnthropicProvider(BaseProvider): - original_create = None - original_create_async = None - - def __init__(self, client): - super().__init__(client) - self._provider_name = "Anthropic" - self.tool_event = {} - self.tool_id = "" - - def handle_response(self, response, kwargs, init_timestamp, session: Optional[Session] = None): - """Handle responses for Anthropic""" - import anthropic.resources.beta.messages.messages as beta_messages - from anthropic import AsyncStream, Stream - from anthropic.resources import AsyncMessages - from anthropic.types import Message - - llm_event = LLMEvent(init_timestamp=init_timestamp, params=kwargs) - if session is not None: - llm_event.session_id = session.session_id - - def handle_stream_chunk(chunk: Message): - try: - # We take the first chunk and accumulate the deltas from all subsequent chunks to build one full chat completion - if chunk.type == "message_start": - llm_event.returns = chunk - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.model = kwargs["model"] - llm_event.prompt = kwargs["messages"] - llm_event.prompt_tokens = chunk.message.usage.input_tokens - llm_event.completion = { - "role": chunk.message.role, - "content": "", # Always returned as [] in this instance type - } - - elif chunk.type == "content_block_start": - if chunk.content_block.type == "text": - llm_event.completion["content"] += chunk.content_block.text - - elif chunk.content_block.type == "tool_use": - self.tool_id = chunk.content_block.id - self.tool_event[self.tool_id] = ToolEvent( - name=chunk.content_block.name, - logs={"type": chunk.content_block.type, "input": ""}, - ) - - elif chunk.type == "content_block_delta": - if chunk.delta.type == "text_delta": - llm_event.completion["content"] += chunk.delta.text - - elif chunk.delta.type == "input_json_delta": - self.tool_event[self.tool_id].logs["input"] += chunk.delta.partial_json - - elif chunk.type == "content_block_stop": - pass - - elif chunk.type == "message_delta": - llm_event.completion_tokens = chunk.usage.output_tokens - - elif chunk.type == "message_stop": - llm_event.end_timestamp = get_ISO_time() - self._safe_record(session, llm_event) - - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - - kwargs_str = pprint.pformat(kwargs) - chunk = pprint.pformat(chunk) - logger.warning( - f"Unable to parse a chunk for LLM call. Skipping upload to AgentOps\n" - f"chunk:\n {chunk}\n" - f"kwargs:\n {kwargs_str}\n", - ) - - # if the response is a generator, decorate the generator - if isinstance(response, Stream): - - def generator(): - for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return generator() - - # For asynchronous AsyncStream - if isinstance(response, AsyncStream): - - async def async_generator(): - async for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return async_generator() - - # For async AsyncMessages - if isinstance(response, AsyncMessages): - - async def async_generator(): - async for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return async_generator() - - # Handle object responses - try: - # Naively handle AttributeError("'LegacyAPIResponse' object has no attribute 'model_dump'") - if hasattr(response, "model_dump"): - # This bets on the fact that the response object has a model_dump method - llm_event.returns = response.model_dump() - llm_event.prompt_tokens = response.usage.input_tokens - llm_event.completion_tokens = response.usage.output_tokens - - llm_event.completion = { - "role": "assistant", - "content": response.content[0].text, - } - llm_event.model = response.model - - else: - """Handle raw response data from the Anthropic API. - - The raw response has the following structure: - { - 'id': str, # Message ID (e.g. 'msg_018Gk9N2pcWaYLS7mxXbPD5i') - 'type': str, # Type of response (e.g. 'message') - 'role': str, # Role of responder (e.g. 'assistant') - 'model': str, # Model used (e.g. 'claude-3-5-sonnet-20241022') - 'content': List[Dict], # List of content blocks with 'type' and 'text' - 'stop_reason': str, # Reason for stopping (e.g. 'end_turn') - 'stop_sequence': Any, # Stop sequence used, if any - 'usage': { # Token usage statistics - 'input_tokens': int, - 'output_tokens': int - } - } - - Note: We import Anthropic types here since the package must be installed - for raw responses to be available; doing so in the global scope would - result in dependencies error since this provider is not lazily imported (tests fail) - """ - from anthropic import APIResponse - from anthropic._legacy_response import LegacyAPIResponse - - assert isinstance(response, (APIResponse, LegacyAPIResponse)), ( - f"Expected APIResponse or LegacyAPIResponse, got {type(response)}. " - "This is likely caused by changes in the Anthropic SDK and the integrations with AgentOps needs update." - "Please open an issue at https://github.com/AgentOps-AI/agentops/issues" - ) - response_data = json.loads(response.text) - llm_event.returns = response_data - llm_event.model = response_data["model"] - llm_event.completion = { - "role": response_data.get("role"), - "content": (response_data.get("content")[0].get("text") if response_data.get("content") else ""), - } - if usage := response_data.get("usage"): - llm_event.prompt_tokens = usage.get("input_tokens") - llm_event.completion_tokens = usage.get("output_tokens") - - llm_event.end_timestamp = get_ISO_time() - llm_event.prompt = kwargs["messages"] - llm_event.agent_id = check_call_stack_for_agent_id() - - self._safe_record(session, llm_event) - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - kwargs_str = pprint.pformat(kwargs) - response = pprint.pformat(response) - logger.warning( - f"Unable to parse response for LLM call. Skipping upload to AgentOps\n" - f"response:\n {response}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - return response - - def override(self): - self._override_completion() - self._override_async_completion() - - def _override_completion(self): - import anthropic.resources.beta.messages.messages as beta_messages - from anthropic.resources import messages - from anthropic.types import ( - Message, - RawContentBlockDeltaEvent, - RawContentBlockStartEvent, - RawContentBlockStopEvent, - RawMessageDeltaEvent, - RawMessageStartEvent, - RawMessageStopEvent, - ) - - # Store the original method - self.original_create = messages.Messages.create - self.original_create_beta = beta_messages.Messages.create - - def create_patched_function(is_beta=False): - def patched_function(*args, **kwargs): - init_timestamp = get_ISO_time() - session = kwargs.get("session", None) - - if "session" in kwargs.keys(): - del kwargs["session"] - - completion_override = fetch_completion_override_from_time_travel_cache(kwargs) - if completion_override: - result_model = None - pydantic_models = ( - Message, - RawContentBlockDeltaEvent, - RawContentBlockStartEvent, - RawContentBlockStopEvent, - RawMessageDeltaEvent, - RawMessageStartEvent, - RawMessageStopEvent, - ) - - for pydantic_model in pydantic_models: - try: - result_model = pydantic_model.model_validate_json(completion_override) - break - except Exception as e: - pass - - if result_model is None: - logger.error( - f"Time Travel: Pydantic validation failed for {pydantic_models} \n" - f"Time Travel: Completion override was:\n" - f"{pprint.pformat(completion_override)}" - ) - return None - return self.handle_response(result_model, kwargs, init_timestamp, session=session) - - # Call the original function with its original arguments - original_func = self.original_create_beta if is_beta else self.original_create - result = original_func(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=session) - - return patched_function - - # Override the original methods with the patched ones - messages.Messages.create = create_patched_function(is_beta=False) - beta_messages.Messages.create = create_patched_function(is_beta=True) - - def _override_async_completion(self): - import anthropic.resources.beta.messages.messages as beta_messages - from anthropic.resources import messages - from anthropic.types import ( - Message, - RawContentBlockDeltaEvent, - RawContentBlockStartEvent, - RawContentBlockStopEvent, - RawMessageDeltaEvent, - RawMessageStartEvent, - RawMessageStopEvent, - ) - - # Store the original method - self.original_create_async = messages.AsyncMessages.create - self.original_create_async_beta = beta_messages.AsyncMessages.create - - def create_patched_async_function(is_beta=False): - async def patched_function(*args, **kwargs): - init_timestamp = get_ISO_time() - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - - completion_override = fetch_completion_override_from_time_travel_cache(kwargs) - if completion_override: - result_model = None - pydantic_models = ( - Message, - RawContentBlockDeltaEvent, - RawContentBlockStartEvent, - RawContentBlockStopEvent, - RawMessageDeltaEvent, - RawMessageStartEvent, - RawMessageStopEvent, - ) - - for pydantic_model in pydantic_models: - try: - result_model = pydantic_model.model_validate_json(completion_override) - break - except Exception as e: - pass - - if result_model is None: - logger.error( - f"Time Travel: Pydantic validation failed for {pydantic_models} \n" - f"Time Travel: Completion override was:\n" - f"{pprint.pformat(completion_override)}" - ) - return None - - return self.handle_response(result_model, kwargs, init_timestamp, session=session) - - # Call the original function with its original arguments - original_func = self.original_create_async_beta if is_beta else self.original_create_async - result = await original_func(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=session) - - return patched_function - - # Override the original methods with the patched ones - messages.AsyncMessages.create = create_patched_async_function(is_beta=False) - beta_messages.AsyncMessages.create = create_patched_async_function(is_beta=True) - - def undo_override(self): - if self.original_create is not None and self.original_create_async is not None: - from anthropic.resources import messages - - messages.Messages.create = self.original_create - messages.AsyncMessages.create = self.original_create_async diff --git a/agentops/llms/providers/base.py b/agentops/llms/providers/base.py deleted file mode 100644 index 7a54b5f0e..000000000 --- a/agentops/llms/providers/base.py +++ /dev/null @@ -1,36 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Optional - -from agentops.session import Session -from agentops.event import LLMEvent - - -class BaseProvider(ABC): - _provider_name: str = "InstrumentedModel" - llm_event: Optional[LLMEvent] = None - client = None - - def __init__(self, client): - self.client = client - - @abstractmethod - def handle_response(self, response, kwargs, init_timestamp, session: Optional[Session] = None) -> dict: - pass - - @abstractmethod - def override(self): - pass - - @abstractmethod - def undo_override(self): - pass - - @property - def provider_name(self): - return self._provider_name - - def _safe_record(self, session, event): - if session is not None: - session.record(event) - else: - self.client.record(event) diff --git a/agentops/llms/providers/cohere.py b/agentops/llms/providers/cohere.py deleted file mode 100644 index 5e4961216..000000000 --- a/agentops/llms/providers/cohere.py +++ /dev/null @@ -1,252 +0,0 @@ -import inspect -import pprint -from typing import Optional - -from .base import BaseProvider -from agentops.event import ActionEvent, ErrorEvent, LLMEvent -from agentops.session import Session -from agentops.log_config import logger -from agentops.helpers import get_ISO_time, check_call_stack_for_agent_id -from agentops.singleton import singleton - - -@singleton -class CohereProvider(BaseProvider): - original_create = None - original_create_stream = None - original_create_async = None - - def override(self): - self._override_chat() - self._override_chat_stream() - self._override_async_chat() - - def undo_override(self): - if ( - self.original_create is not None - and self.original_create_async is not None - and self.original_create_stream is not None - ): - import cohere - - cohere.Client.chat = self.original_create - cohere.Client.chat_stream = self.original_create_stream - cohere.AsyncClient.chat = self.original_create_async - - def __init__(self, client): - super().__init__(client) - - def handle_response(self, response, kwargs, init_timestamp, session: Optional[Session] = None): - """Handle responses for Cohere versions >v5.4.0""" - from cohere.types.streamed_chat_response import ( - StreamedChatResponse_CitationGeneration, - StreamedChatResponse_SearchQueriesGeneration, - StreamedChatResponse_SearchResults, - StreamedChatResponse_StreamEnd, - StreamedChatResponse_StreamStart, - StreamedChatResponse_TextGeneration, - StreamedChatResponse_ToolCallsGeneration, - ) - - # from cohere.types.chat import ChatGenerationChunk - # NOTE: Cohere only returns one message and its role will be CHATBOT which we are coercing to "assistant" - llm_event = LLMEvent(init_timestamp=init_timestamp, params=kwargs) - if session is not None: - llm_event.session_id = session.session_id - - self.action_events = {} - - def handle_stream_chunk(chunk, session: Optional[Session] = None): - # We take the first chunk and accumulate the deltas from all subsequent chunks to build one full chat completion - if isinstance(chunk, StreamedChatResponse_StreamStart): - llm_event.returns = chunk - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.model = kwargs.get("model", "command-r-plus") - llm_event.prompt = kwargs["message"] - llm_event.completion = "" - return - - try: - if isinstance(chunk, StreamedChatResponse_StreamEnd): - # StreamedChatResponse_TextGeneration = LLMEvent - llm_event.completion = { - "role": "assistant", - "content": chunk.response.text, - } - llm_event.end_timestamp = get_ISO_time() - self._safe_record(session, llm_event) - - # StreamedChatResponse_SearchResults = ActionEvent - search_results = chunk.response.search_results - if search_results: - for search_result in search_results: - query = search_result.search_query - if query.generation_id in self.action_events: - action_event = self.action_events[query.generation_id] - search_result_dict = search_result.dict() - del search_result_dict["search_query"] - action_event.returns = search_result_dict - action_event.end_timestamp = get_ISO_time() - - # StreamedChatResponse_CitationGeneration = ActionEvent - if chunk.response.documents: - documents = {doc["id"]: doc for doc in chunk.response.documents} - citations = chunk.response.citations - for citation in citations: - citation_id = f"{citation.start}.{citation.end}" - if citation_id in self.action_events: - action_event = self.action_events[citation_id] - citation_dict = citation.dict() - # Replace document_ids with the actual documents - citation_dict["documents"] = [ - documents[doc_id] for doc_id in citation_dict["document_ids"] if doc_id in documents - ] - del citation_dict["document_ids"] - - action_event.returns = citation_dict - action_event.end_timestamp = get_ISO_time() - - for key, action_event in self.action_events.items(): - self._safe_record(session, action_event) - - elif isinstance(chunk, StreamedChatResponse_TextGeneration): - llm_event.completion += chunk.text - elif isinstance(chunk, StreamedChatResponse_ToolCallsGeneration): - pass - elif isinstance(chunk, StreamedChatResponse_CitationGeneration): - for citation in chunk.citations: - self.action_events[f"{citation.start}.{citation.end}"] = ActionEvent( - action_type="citation", - init_timestamp=get_ISO_time(), - params=citation.text, - ) - elif isinstance(chunk, StreamedChatResponse_SearchQueriesGeneration): - for query in chunk.search_queries: - self.action_events[query.generation_id] = ActionEvent( - action_type="search_query", - init_timestamp=get_ISO_time(), - params=query.text, - ) - elif isinstance(chunk, StreamedChatResponse_SearchResults): - pass - - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - - kwargs_str = pprint.pformat(kwargs) - chunk = pprint.pformat(chunk) - logger.warning( - f"Unable to parse a chunk for LLM call. Skipping upload to AgentOps\n" - f"chunk:\n {chunk}\n" - f"kwargs:\n {kwargs_str}\n" - ) - raise e - - # NOTE: As of Cohere==5.x.x, async is not supported - # if the response is a generator, decorate the generator - if inspect.isasyncgen(response): - - async def async_generator(): - async for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return async_generator() - - elif inspect.isgenerator(response): - - def generator(): - for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return generator() - - # TODO: we should record if they pass a chat.connectors, because it means they intended to call a tool - # Not enough to record StreamedChatResponse_ToolCallsGeneration because the tool may have not gotten called - - try: - llm_event.returns = response - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.prompt = [] - if response.chat_history: - role_map = {"USER": "user", "CHATBOT": "assistant", "SYSTEM": "system"} - - for i in range(len(response.chat_history) - 1): - message = response.chat_history[i] - llm_event.prompt.append( - { - "role": role_map.get(message.role, message.role), - "content": message.message, - } - ) - - last_message = response.chat_history[-1] - llm_event.completion = { - "role": role_map.get(last_message.role, last_message.role), - "content": last_message.message, - } - llm_event.prompt_tokens = int(response.meta.tokens.input_tokens) - llm_event.completion_tokens = int(response.meta.tokens.output_tokens) - llm_event.model = kwargs.get("model", "command-r-plus") - - self._safe_record(session, llm_event) - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - kwargs_str = pprint.pformat(kwargs) - response = pprint.pformat(response) - logger.warning( - f"Unable to parse response for LLM call. Skipping upload to AgentOps\n" - f"response:\n {response}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - return response - - def _override_chat(self): - import cohere - - self.original_create = cohere.Client.chat - - def patched_function(*args, **kwargs): - # Call the original function with its original arguments - init_timestamp = get_ISO_time() - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - result = self.original_create(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=session) - - # Override the original method with the patched one - cohere.Client.chat = patched_function - - def _override_async_chat(self): - import cohere.types - - self.original_create_async = cohere.AsyncClient.chat - - async def patched_function(*args, **kwargs): - # Call the original function with its original arguments - init_timestamp = get_ISO_time() - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - result = await self.original_create_async(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=session) - - # Override the original method with the patched one - cohere.AsyncClient.chat = patched_function - - def _override_chat_stream(self): - import cohere - - self.original_create_stream = cohere.Client.chat_stream - - def patched_function(*args, **kwargs): - # Call the original function with its original arguments - init_timestamp = get_ISO_time() - result = self.original_create_stream(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp) - - # Override the original method with the patched one - cohere.Client.chat_stream = patched_function diff --git a/agentops/llms/providers/gemini.py b/agentops/llms/providers/gemini.py deleted file mode 100644 index 6ca96c3eb..000000000 --- a/agentops/llms/providers/gemini.py +++ /dev/null @@ -1,194 +0,0 @@ -from typing import Optional, Any, Dict, Union - -from agentops.llms.providers.base import BaseProvider -from agentops.event import LLMEvent, ErrorEvent -from agentops.session import Session -from agentops.helpers import get_ISO_time, check_call_stack_for_agent_id -from agentops.log_config import logger -from agentops.singleton import singleton - - -@singleton -class GeminiProvider(BaseProvider): - original_generate_content = None - original_generate_content_async = None - - """Provider for Google's Gemini API. - - This provider is automatically detected and initialized when agentops.init() - is called and the google.generativeai package is imported. No manual - initialization is required.""" - - def __init__(self, client=None): - """Initialize the Gemini provider. - - Args: - client: Optional client instance. If not provided, will be set during override. - """ - super().__init__(client) - self._provider_name = "Gemini" - - def handle_response(self, response, kwargs, init_timestamp, session: Optional[Session] = None) -> dict: - """Handle responses from Gemini API for both sync and streaming modes. - - Args: - response: The response from the Gemini API - kwargs: The keyword arguments passed to generate_content - init_timestamp: The timestamp when the request was initiated - session: Optional AgentOps session for recording events - - Returns: - For sync responses: The original response object - For streaming responses: A generator yielding response chunks - """ - llm_event = LLMEvent(init_timestamp=init_timestamp, params=kwargs) - if session is not None: - llm_event.session_id = session.session_id - - accumulated_content = "" - - def handle_stream_chunk(chunk): - nonlocal llm_event, accumulated_content - try: - if llm_event.returns is None: - llm_event.returns = chunk - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.model = getattr(chunk, "model", None) or "gemini-1.5-flash" - llm_event.prompt = kwargs.get("prompt", kwargs.get("contents", None)) or [] - - # Accumulate text from chunk - if hasattr(chunk, "text") and chunk.text: - accumulated_content += chunk.text - - # Extract token counts if available - if hasattr(chunk, "usage_metadata"): - llm_event.prompt_tokens = getattr(chunk.usage_metadata, "prompt_token_count", None) - llm_event.completion_tokens = getattr(chunk.usage_metadata, "candidates_token_count", None) - - # If this is the last chunk - if hasattr(chunk, "finish_reason") and chunk.finish_reason: - llm_event.completion = accumulated_content - llm_event.end_timestamp = get_ISO_time() - self._safe_record(session, llm_event) - - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - logger.warning( - f"Unable to parse chunk for Gemini LLM call. Error: {str(e)}\n" - f"Response: {chunk}\n" - f"Arguments: {kwargs}\n" - ) - - # For streaming responses - if kwargs.get("stream", False): - - def generator(): - for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return generator() - - # For synchronous responses - try: - llm_event.returns = response - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.prompt = kwargs.get("prompt", kwargs.get("contents", None)) or [] - llm_event.completion = response.text - llm_event.model = getattr(response, "model", None) or "gemini-1.5-flash" - - # Extract token counts from usage metadata if available - if hasattr(response, "usage_metadata"): - llm_event.prompt_tokens = getattr(response.usage_metadata, "prompt_token_count", None) - llm_event.completion_tokens = getattr(response.usage_metadata, "candidates_token_count", None) - - llm_event.end_timestamp = get_ISO_time() - self._safe_record(session, llm_event) - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - logger.warning( - f"Unable to parse response for Gemini LLM call. Error: {str(e)}\n" - f"Response: {response}\n" - f"Arguments: {kwargs}\n" - ) - - return response - - def override(self): - """Override Gemini's generate_content method to track LLM events.""" - self._override_gemini_generate_content() - self._override_gemini_generate_content_async() - - def _override_gemini_generate_content(self): - """Override synchronous generate_content method""" - import google.generativeai as genai - - # Store original method if not already stored - if self.original_generate_content is None: - self.original_generate_content = genai.GenerativeModel.generate_content - - provider = self # Store provider instance for closure - - def patched_function(model_self, *args, **kwargs): - init_timestamp = get_ISO_time() - session = kwargs.pop("session", None) - - # Handle positional prompt argument - event_kwargs = kwargs.copy() - if args and len(args) > 0: - prompt = args[0] - if "contents" not in kwargs: - kwargs["contents"] = prompt - event_kwargs["prompt"] = prompt - args = args[1:] - - result = provider.original_generate_content(model_self, *args, **kwargs) - return provider.handle_response(result, event_kwargs, init_timestamp, session=session) - - # Override the method at class level - genai.GenerativeModel.generate_content = patched_function - - def _override_gemini_generate_content_async(self): - """Override asynchronous generate_content method""" - import google.generativeai as genai - - # Store original async method if not already stored - if self.original_generate_content_async is None: - self.original_generate_content_async = genai.GenerativeModel.generate_content_async - - provider = self # Store provider instance for closure - - async def patched_function(model_self, *args, **kwargs): - init_timestamp = get_ISO_time() - session = kwargs.pop("session", None) - - # Handle positional prompt argument - event_kwargs = kwargs.copy() - if args and len(args) > 0: - prompt = args[0] - if "contents" not in kwargs: - kwargs["contents"] = prompt - event_kwargs["prompt"] = prompt - args = args[1:] - - result = await provider.original_generate_content_async(model_self, *args, **kwargs) - return provider.handle_response(result, event_kwargs, init_timestamp, session=session) - - # Override the async method at class level - genai.GenerativeModel.generate_content_async = patched_function - - def undo_override(self): - """Restore original Gemini methods. - - Note: - This method is called automatically by AgentOps during cleanup. - Users should not call this method directly.""" - import google.generativeai as genai - - if self.original_generate_content is not None: - genai.GenerativeModel.generate_content = self.original_generate_content - self.original_generate_content = None - - if self.original_generate_content_async is not None: - genai.GenerativeModel.generate_content_async = self.original_generate_content_async - self.original_generate_content_async = None diff --git a/agentops/llms/providers/groq.py b/agentops/llms/providers/groq.py deleted file mode 100644 index 4f07a9fd9..000000000 --- a/agentops/llms/providers/groq.py +++ /dev/null @@ -1,175 +0,0 @@ -import pprint -from typing import Optional - -from .base import BaseProvider -from agentops.event import ErrorEvent, LLMEvent -from agentops.session import Session -from agentops.log_config import logger -from agentops.helpers import get_ISO_time, check_call_stack_for_agent_id -from agentops.singleton import singleton - - -@singleton -class GroqProvider(BaseProvider): - original_create = None - original_async_create = None - - def __init__(self, client): - super().__init__(client) - self.client = client - - def override(self): - self._override_chat() - self._override_async_chat() - - def undo_override(self): - if self.original_create is not None and self.original_async_create is not None: - from groq.resources.chat import completions - - completions.Completions.create = self.original_create - completions.AsyncCompletions.create = self.original_create - - def handle_response(self, response, kwargs, init_timestamp, session: Optional[Session] = None): - """Handle responses for OpenAI versions >v1.0.0""" - from groq import AsyncStream, Stream - from groq.resources.chat import AsyncCompletions - from groq.types.chat import ChatCompletionChunk - - llm_event = LLMEvent(init_timestamp=init_timestamp, params=kwargs) - if session is not None: - llm_event.session_id = session.session_id - - def handle_stream_chunk(chunk: ChatCompletionChunk): - # NOTE: prompt/completion usage not returned in response when streaming - # We take the first ChatCompletionChunk and accumulate the deltas from all subsequent chunks to build one full chat completion - if llm_event.returns == None: - llm_event.returns = chunk - - try: - accumulated_delta = llm_event.returns.choices[0].delta - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.model = chunk.model - llm_event.prompt = kwargs["messages"] - - # NOTE: We assume for completion only choices[0] is relevant - choice = chunk.choices[0] - - if choice.delta.content: - accumulated_delta.content += choice.delta.content - - if choice.delta.role: - accumulated_delta.role = choice.delta.role - - if choice.delta.tool_calls: - accumulated_delta.tool_calls = choice.delta.tool_calls - - if choice.delta.function_call: - accumulated_delta.function_call = choice.delta.function_call - - if choice.finish_reason: - # Streaming is done. Record LLMEvent - llm_event.returns.choices[0].finish_reason = choice.finish_reason - llm_event.completion = { - "role": accumulated_delta.role, - "content": accumulated_delta.content, - "function_call": accumulated_delta.function_call, - "tool_calls": accumulated_delta.tool_calls, - } - llm_event.end_timestamp = get_ISO_time() - - self._safe_record(session, llm_event) - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - - kwargs_str = pprint.pformat(kwargs) - chunk = pprint.pformat(chunk) - logger.warning( - f"Unable to parse a chunk for LLM call. Skipping upload to AgentOps\n" - f"chunk:\n {chunk}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - # if the response is a generator, decorate the generator - if isinstance(response, Stream): - - def generator(): - for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return generator() - - # For asynchronous AsyncStream - elif isinstance(response, AsyncStream): - - async def async_generator(): - async for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return async_generator() - - # For async AsyncCompletion - elif isinstance(response, AsyncCompletions): - - async def async_generator(): - async for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return async_generator() - - # v1.0.0+ responses are objects - try: - llm_event.returns = response.model_dump() - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.prompt = kwargs["messages"] - llm_event.prompt_tokens = response.usage.prompt_tokens - llm_event.completion = response.choices[0].message.model_dump() - llm_event.completion_tokens = response.usage.completion_tokens - llm_event.model = response.model - - self._safe_record(session, llm_event) - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - - kwargs_str = pprint.pformat(kwargs) - response = pprint.pformat(response) - logger.warning( - f"Unable to parse response for LLM call. Skipping upload to AgentOps\n" - f"response:\n {response}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - return response - - def _override_chat(self): - from groq.resources.chat import completions - - self.original_create = completions.Completions.create - - def patched_function(*args, **kwargs): - # Call the original function with its original arguments - init_timestamp = get_ISO_time() - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - result = self.original_create(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=session) - - # Override the original method with the patched one - completions.Completions.create = patched_function - - def _override_async_chat(self): - from groq.resources.chat import completions - - self.original_async_create = completions.AsyncCompletions.create - - async def patched_function(*args, **kwargs): - # Call the original function with its original arguments - init_timestamp = get_ISO_time() - result = await self.original_async_create(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp) - - # Override the original method with the patched one - completions.AsyncCompletions.create = patched_function diff --git a/agentops/llms/providers/litellm.py b/agentops/llms/providers/litellm.py deleted file mode 100644 index 488a94b9b..000000000 --- a/agentops/llms/providers/litellm.py +++ /dev/null @@ -1,231 +0,0 @@ -import pprint -from typing import Optional - -from agentops.log_config import logger -from agentops.event import LLMEvent, ErrorEvent -from agentops.session import Session -from agentops.helpers import get_ISO_time, check_call_stack_for_agent_id -from agentops.llms.providers.base import BaseProvider -from agentops.time_travel import fetch_completion_override_from_time_travel_cache -from agentops.singleton import singleton - - -@singleton -class LiteLLMProvider(BaseProvider): - original_create = None - original_create_async = None - original_oai_create = None - original_oai_create_async = None - - def __init__(self, client): - super().__init__(client) - - def override(self): - self._override_async_completion() - self._override_completion() - - def undo_override(self): - if ( - self.original_create is not None - and self.original_create_async is not None - and self.original_oai_create is not None - and self.original_oai_create_async is not None - ): - import litellm - from openai.resources.chat import completions - - litellm.acompletion = self.original_create_async - litellm.completion = self.original_create - - completions.Completions.create = self.original_oai_create - completions.AsyncCompletions.create = self.original_oai_create_async - - def handle_response(self, response, kwargs, init_timestamp, session: Optional[Session] = None) -> dict: - """Handle responses for OpenAI versions >v1.0.0""" - from openai import AsyncStream, Stream - from openai.resources import AsyncCompletions - from openai.types.chat import ChatCompletionChunk - from litellm.utils import CustomStreamWrapper - - llm_event = LLMEvent(init_timestamp=init_timestamp, params=kwargs) - if session is not None: - llm_event.session_id = session.session_id - - def handle_stream_chunk(chunk: ChatCompletionChunk): - # NOTE: prompt/completion usage not returned in response when streaming - # We take the first ChatCompletionChunk and accumulate the deltas from all subsequent chunks to build one full chat completion - if llm_event.returns == None: - llm_event.returns = chunk - - try: - accumulated_delta = llm_event.returns.choices[0].delta - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.model = chunk.model - llm_event.prompt = kwargs["messages"] - - # NOTE: We assume for completion only choices[0] is relevant - choice = chunk.choices[0] - - if choice.delta.content: - accumulated_delta.content += choice.delta.content - - if choice.delta.role: - accumulated_delta.role = choice.delta.role - - if choice.delta.tool_calls: - accumulated_delta.tool_calls = choice.delta.tool_calls - - if choice.delta.function_call: - accumulated_delta.function_call = choice.delta.function_call - - if choice.finish_reason: - # Streaming is done. Record LLMEvent - llm_event.returns.choices[0].finish_reason = choice.finish_reason - llm_event.completion = { - "role": accumulated_delta.role, - "content": accumulated_delta.content, - "function_call": accumulated_delta.function_call, - "tool_calls": accumulated_delta.tool_calls, - } - llm_event.end_timestamp = get_ISO_time() - - self._safe_record(session, llm_event) - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - - kwargs_str = pprint.pformat(kwargs) - chunk = pprint.pformat(chunk) - logger.warning( - f"Unable to parse a chunk for LLM call. Skipping upload to AgentOps\n" - f"chunk:\n {chunk}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - # if the response is a generator, decorate the generator - if isinstance(response, Stream): - - def generator(): - for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return generator() - - # litellm uses a CustomStreamWrapper - if isinstance(response, CustomStreamWrapper): - - def generator(): - for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return generator() - - # For asynchronous AsyncStream - elif isinstance(response, AsyncStream): - - async def async_generator(): - async for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return async_generator() - - # For async AsyncCompletion - elif isinstance(response, AsyncCompletions): - - async def async_generator(): - async for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return async_generator() - - # v1.0.0+ responses are objects - try: - llm_event.returns = response - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.prompt = kwargs["messages"] - llm_event.prompt_tokens = response.usage.prompt_tokens - llm_event.completion = response.choices[0].message.model_dump() - llm_event.completion_tokens = response.usage.completion_tokens - llm_event.model = response.model - - self._safe_record(session, llm_event) - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - - kwargs_str = pprint.pformat(kwargs) - response = pprint.pformat(response) - logger.warning( - f"Unable to parse response for LLM call. Skipping upload to AgentOps\n" - f"response:\n {response}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - return response - - def _override_completion(self): - import litellm - from openai.types.chat import ( - ChatCompletion, - ) # Note: litellm calls all LLM APIs using the OpenAI format - from openai.resources.chat import completions - - self.original_create = litellm.completion - self.original_oai_create = completions.Completions.create - - def patched_function(*args, **kwargs): - init_timestamp = get_ISO_time() - - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - - completion_override = fetch_completion_override_from_time_travel_cache(kwargs) - if completion_override: - result_model = ChatCompletion.model_validate_json(completion_override) - return self.handle_response(result_model, kwargs, init_timestamp, session=session) - - # prompt_override = fetch_prompt_override_from_time_travel_cache(kwargs) - # if prompt_override: - # kwargs["messages"] = prompt_override["messages"] - - # Call the original function with its original arguments - result = self.original_create(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=session) - - litellm.completion = patched_function - - def _override_async_completion(self): - import litellm - from openai.types.chat import ( - ChatCompletion, - ) # Note: litellm calls all LLM APIs using the OpenAI format - from openai.resources.chat import completions - - self.original_create_async = litellm.acompletion - self.original_oai_create_async = completions.AsyncCompletions.create - - async def patched_function(*args, **kwargs): - init_timestamp = get_ISO_time() - - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - - completion_override = fetch_completion_override_from_time_travel_cache(kwargs) - if completion_override: - result_model = ChatCompletion.model_validate_json(completion_override) - return self.handle_response(result_model, kwargs, init_timestamp, session=session) - - # prompt_override = fetch_prompt_override_from_time_travel_cache(kwargs) - # if prompt_override: - # kwargs["messages"] = prompt_override["messages"] - - # Call the original function with its original arguments - result = await self.original_create_async(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=session) - - # Override the original method with the patched one - litellm.acompletion = patched_function diff --git a/agentops/llms/providers/llama_stack_client.py b/agentops/llms/providers/llama_stack_client.py deleted file mode 100644 index 0f7601536..000000000 --- a/agentops/llms/providers/llama_stack_client.py +++ /dev/null @@ -1,295 +0,0 @@ -import inspect -import pprint -from typing import Any, AsyncGenerator, Dict, Optional, List, Union -import logging - -from agentops.event import LLMEvent, ErrorEvent, ToolEvent -from agentops.session import Session -from agentops.log_config import logger -from agentops.helpers import get_ISO_time, check_call_stack_for_agent_id -from agentops.llms.providers.base import BaseProvider - - -class LlamaStackClientProvider(BaseProvider): - original_complete = None - original_create_turn = None - - def __init__(self, client): - super().__init__(client) - self._provider_name = "LlamaStack" - - def handle_response( - self, response, kwargs, init_timestamp, session: Optional[Session] = None, metadata: Optional[Dict] = {} - ) -> dict: - """Handle responses for LlamaStack""" - - try: - stack = [] - accum_delta = None - accum_tool_delta = None - # tool_event = None - # llm_event = None - - def handle_stream_chunk(chunk: dict): - nonlocal stack - - # NOTE: prompt/completion usage not returned in response when streaming - - try: - nonlocal accum_delta - - if chunk.event.event_type == "start": - llm_event = LLMEvent(init_timestamp=get_ISO_time(), params=kwargs) - stack.append({"event_type": "start", "event": llm_event}) - accum_delta = chunk.event.delta - elif chunk.event.event_type == "progress": - accum_delta += chunk.event.delta - elif chunk.event.event_type == "complete": - if ( - stack[-1]["event_type"] == "start" - ): # check if the last event in the stack is a step start event - llm_event = stack.pop().get("event") - llm_event.prompt = [ - {"content": message.content, "role": message.role} for message in kwargs["messages"] - ] - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.model = kwargs["model_id"] - llm_event.prompt_tokens = None - llm_event.completion = accum_delta or kwargs["completion"] - llm_event.completion_tokens = None - llm_event.end_timestamp = get_ISO_time() - self._safe_record(session, llm_event) - - except Exception as e: - llm_event = LLMEvent(init_timestamp=init_timestamp, end_timestamp=get_ISO_time(), params=kwargs) - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - - kwargs_str = pprint.pformat(kwargs) - chunk = pprint.pformat(chunk) - logger.warning( - f"Unable to parse a chunk for LLM call. Skipping upload to AgentOps\n" - f"chunk:\n {chunk}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - def handle_stream_agent(chunk: dict): - # NOTE: prompt/completion usage not returned in response when streaming - - # nonlocal llm_event - nonlocal stack - - if session is not None: - llm_event.session_id = session.session_id - - try: - if chunk.event.payload.event_type == "turn_start": - logger.debug("turn_start") - stack.append({"event_type": chunk.event.payload.event_type, "event": None}) - elif chunk.event.payload.event_type == "step_start": - logger.debug("step_start") - llm_event = LLMEvent(init_timestamp=get_ISO_time(), params=kwargs) - stack.append({"event_type": chunk.event.payload.event_type, "event": llm_event}) - elif chunk.event.payload.event_type == "step_progress": - if ( - chunk.event.payload.step_type == "inference" - and chunk.event.payload.text_delta_model_response - ): - nonlocal accum_delta - delta = chunk.event.payload.text_delta_model_response - - if accum_delta: - accum_delta += delta - else: - accum_delta = delta - elif chunk.event.payload.step_type == "inference" and chunk.event.payload.tool_call_delta: - if chunk.event.payload.tool_call_delta.parse_status == "started": - logger.debug("tool_started") - tool_event = ToolEvent(init_timestamp=get_ISO_time(), params=kwargs) - tool_event.name = "tool_started" - - stack.append({"event_type": "tool_started", "event": tool_event}) - - elif chunk.event.payload.tool_call_delta.parse_status == "in_progress": - nonlocal accum_tool_delta - delta = chunk.event.payload.tool_call_delta.content - if accum_tool_delta: - accum_tool_delta += delta - else: - accum_tool_delta = delta - elif chunk.event.payload.tool_call_delta.parse_status == "success": - logger.debug("ToolExecution - success") - if ( - stack[-1]["event_type"] == "tool_started" - ): # check if the last event in the stack is a tool execution event - tool_event = stack.pop().get("event") - tool_event.end_timestamp = get_ISO_time() - tool_event.params["completion"] = accum_tool_delta - self._safe_record(session, tool_event) - elif chunk.event.payload.tool_call_delta.parse_status == "failure": - logger.warning("ToolExecution - failure") - if stack[-1]["event_type"] == "ToolExecution - started": - tool_event = stack.pop().get("event") - tool_event.end_timestamp = get_ISO_time() - tool_event.params["completion"] = accum_tool_delta - self._safe_record( - session, - ErrorEvent( - trigger_event=tool_event, exception=Exception("ToolExecution - failure") - ), - ) - - elif chunk.event.payload.event_type == "step_complete": - logger.debug("Step complete event received") - - if chunk.event.payload.step_type == "inference": - logger.debug("Step complete inference") - - if stack[-1]["event_type"] == "step_start": - llm_event = stack.pop().get("event") - llm_event.prompt = [ - {"content": message["content"], "role": message["role"]} - for message in kwargs["messages"] - ] - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.model = metadata.get("model_id", "Unable to identify model") - llm_event.prompt_tokens = None - llm_event.completion = accum_delta or kwargs["completion"] - llm_event.completion_tokens = None - llm_event.end_timestamp = get_ISO_time() - self._safe_record(session, llm_event) - else: - logger.warning("Unexpected event stack state for inference step complete") - elif chunk.event.payload.step_type == "tool_execution": - if stack[-1]["event_type"] == "tool_started": - logger.debug("tool_complete") - tool_event = stack.pop().get("event") - tool_event.name = "tool_complete" - tool_event.params["completion"] = accum_tool_delta - self._safe_record(session, tool_event) - elif chunk.event.payload.event_type == "turn_complete": - if stack[-1]["event_type"] == "turn_start": - logger.debug("turn_start") - pass - - except Exception as e: - llm_event = LLMEvent(init_timestamp=init_timestamp, end_timestamp=get_ISO_time(), params=kwargs) - - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - - kwargs_str = pprint.pformat(kwargs) - chunk = pprint.pformat(chunk) - logger.warning( - f"Unable to parse a chunk for LLM call. Skipping upload to AgentOps\n" - f"chunk:\n {chunk}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - if kwargs.get("stream", False): - - def generator(): - for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return generator() - elif inspect.isasyncgen(response): - - async def agent_generator(): - async for chunk in response: - handle_stream_agent(chunk) - yield chunk - - return agent_generator() - elif inspect.isgenerator(response): - - def agent_generator(): - for chunk in response: - handle_stream_agent(chunk) - yield chunk - - return agent_generator() - else: - llm_event = LLMEvent(init_timestamp=init_timestamp, params=kwargs) - if session is not None: - llm_event.session_id = session.session_id - - llm_event.returns = response - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.model = kwargs["model_id"] - llm_event.prompt = [ - {"content": message.content, "role": message.role} for message in kwargs["messages"] - ] - llm_event.prompt_tokens = None - llm_event.completion = response.completion_message.content - llm_event.completion_tokens = None - llm_event.end_timestamp = get_ISO_time() - - self._safe_record(session, llm_event) - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - kwargs_str = pprint.pformat(kwargs) - response = pprint.pformat(response) - logger.warning( - f"Unable to parse response for LLM call. Skipping upload to AgentOps\n" - f"response:\n {response}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - return response - - def _override_complete(self): - from llama_stack_client.resources import InferenceResource - - global original_complete - original_complete = InferenceResource.chat_completion - - def patched_function(*args, **kwargs): - # Call the original function with its original arguments - init_timestamp = get_ISO_time() - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - result = original_complete(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=session) - - # Override the original method with the patched one - InferenceResource.chat_completion = patched_function - - def _override_create_turn(self): - from llama_stack_client.lib.agents.agent import Agent - - self.original_create_turn = Agent.create_turn - - def patched_function(*args, **kwargs): - # Call the original function with its original arguments - init_timestamp = get_ISO_time() - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - - result = self.original_create_turn(*args, **kwargs) - return self.handle_response( - result, - kwargs, - init_timestamp, - session=session, - metadata={"model_id": args[0].agent_config.get("model")}, - ) - - # Override the original method with the patched one - Agent.create_turn = patched_function - - def override(self): - self._override_complete() - self._override_create_turn() - - def undo_override(self): - if self.original_complete is not None: - from llama_stack_client.resources import InferenceResource - - InferenceResource.chat_completion = self.original_complete - - if self.original_create_turn is not None: - from llama_stack_client.lib.agents.agent import Agent - - Agent.create_turn = self.original_create_turn diff --git a/agentops/llms/providers/mistral.py b/agentops/llms/providers/mistral.py deleted file mode 100644 index 83f090cf0..000000000 --- a/agentops/llms/providers/mistral.py +++ /dev/null @@ -1,215 +0,0 @@ -import inspect -import pprint -import sys -from typing import Optional - -from agentops.event import LLMEvent, ErrorEvent -from agentops.session import Session -from agentops.log_config import logger -from agentops.helpers import get_ISO_time, check_call_stack_for_agent_id -from .base import BaseProvider - - -class MistralProvider(BaseProvider): - original_complete = None - original_complete_async = None - original_stream = None - original_stream_async = None - - def __init__(self, client): - super().__init__(client) - self._provider_name = "Mistral" - - def handle_response(self, response, kwargs, init_timestamp, session: Optional[Session] = None) -> dict: - """Handle responses for Mistral""" - from mistralai import Chat - from mistralai.types import UNSET, UNSET_SENTINEL - - llm_event = LLMEvent(init_timestamp=init_timestamp, params=kwargs) - if session is not None: - llm_event.session_id = session.session_id - - def handle_stream_chunk(chunk: dict): - # NOTE: prompt/completion usage not returned in response when streaming - # We take the first ChatCompletionChunk and accumulate the deltas from all subsequent chunks to build one full chat completion - if llm_event.returns is None: - llm_event.returns = chunk.data - - try: - accumulated_delta = llm_event.returns.choices[0].delta - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.model = "mistral/" + chunk.data.model - llm_event.prompt = kwargs["messages"] - - # NOTE: We assume for completion only choices[0] is relevant - choice = chunk.data.choices[0] - - if choice.delta.content: - accumulated_delta.content += choice.delta.content - - if choice.delta.role: - accumulated_delta.role = choice.delta.role - - # Check if tool_calls is Unset and set to None if it is - if choice.delta.tool_calls in (UNSET, UNSET_SENTINEL): - accumulated_delta.tool_calls = None - elif choice.delta.tool_calls: - accumulated_delta.tool_calls = choice.delta.tool_calls - - if choice.finish_reason: - # Streaming is done. Record LLMEvent - llm_event.returns.choices[0].finish_reason = choice.finish_reason - llm_event.completion = { - "role": accumulated_delta.role, - "content": accumulated_delta.content, - "tool_calls": accumulated_delta.tool_calls, - } - llm_event.prompt_tokens = chunk.data.usage.prompt_tokens - llm_event.completion_tokens = chunk.data.usage.completion_tokens - llm_event.end_timestamp = get_ISO_time() - self._safe_record(session, llm_event) - - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - - kwargs_str = pprint.pformat(kwargs) - chunk = pprint.pformat(chunk) - logger.warning( - f"Unable to parse a chunk for LLM call. Skipping upload to AgentOps\n" - f"chunk:\n {chunk}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - # if the response is a generator, decorate the generator - if inspect.isgenerator(response): - - def generator(): - for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return generator() - - elif inspect.isasyncgen(response): - - async def async_generator(): - async for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return async_generator() - - try: - llm_event.returns = response - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.model = "mistral/" + response.model - llm_event.prompt = kwargs["messages"] - llm_event.prompt_tokens = response.usage.prompt_tokens - llm_event.completion = response.choices[0].message.model_dump() - llm_event.completion_tokens = response.usage.completion_tokens - llm_event.end_timestamp = get_ISO_time() - - self._safe_record(session, llm_event) - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - kwargs_str = pprint.pformat(kwargs) - response = pprint.pformat(response) - logger.warning( - f"Unable to parse response for LLM call. Skipping upload to AgentOps\n" - f"response:\n {response}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - return response - - def _override_complete(self): - from mistralai import Chat - - global original_complete - original_complete = Chat.complete - - def patched_function(*args, **kwargs): - # Call the original function with its original arguments - init_timestamp = get_ISO_time() - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - result = original_complete(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=session) - - # Override the original method with the patched one - Chat.complete = patched_function - - def _override_complete_async(self): - from mistralai import Chat - - global original_complete_async - original_complete_async = Chat.complete_async - - async def patched_function(*args, **kwargs): - # Call the original function with its original arguments - init_timestamp = get_ISO_time() - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - result = await original_complete_async(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=session) - - # Override the original method with the patched one - Chat.complete_async = patched_function - - def _override_stream(self): - from mistralai import Chat - - global original_stream - original_stream = Chat.stream - - def patched_function(*args, **kwargs): - # Call the original function with its original arguments - init_timestamp = get_ISO_time() - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - result = original_stream(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=session) - - # Override the original method with the patched one - Chat.stream = patched_function - - def _override_stream_async(self): - from mistralai import Chat - - global original_stream_async - original_stream_async = Chat.stream_async - - async def patched_function(*args, **kwargs): - # Call the original function with its original arguments - init_timestamp = get_ISO_time() - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - result = await original_stream_async(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=session) - - # Override the original method with the patched one - Chat.stream_async = patched_function - - def override(self): - self._override_complete() - self._override_complete_async() - self._override_stream() - self._override_stream_async() - - def undo_override(self): - if ( - self.original_complete is not None - and self.original_complete_async is not None - and self.original_stream is not None - and self.original_stream_async is not None - ): - from mistralai import Chat - - Chat.complete = self.original_complete - Chat.complete_async = self.original_complete_async - Chat.stream = self.original_stream - Chat.stream_async = self.original_stream_async diff --git a/agentops/llms/providers/ollama.py b/agentops/llms/providers/ollama.py deleted file mode 100644 index ce2a7fc8b..000000000 --- a/agentops/llms/providers/ollama.py +++ /dev/null @@ -1,126 +0,0 @@ -import inspect -import sys -from typing import Optional - -from agentops.event import LLMEvent -from agentops.session import Session -from agentops.helpers import get_ISO_time, check_call_stack_for_agent_id -from .base import BaseProvider -from agentops.singleton import singleton - -original_func = {} - - -@singleton -class OllamaProvider(BaseProvider): - original_create = None - original_create_async = None - - def handle_response(self, response, kwargs, init_timestamp, session: Optional[Session] = None) -> dict: - llm_event = LLMEvent(init_timestamp=init_timestamp, params=kwargs) - if session is not None: - llm_event.session_id = session.session_id - - def handle_stream_chunk(chunk: dict): - message = chunk.get("message", {"role": None, "content": ""}) - - if chunk.get("done"): - llm_event.end_timestamp = get_ISO_time() - llm_event.model = f"ollama/{chunk.get('model')}" - llm_event.returns = chunk - llm_event.returns["message"] = llm_event.completion - llm_event.prompt = kwargs["messages"] - llm_event.agent_id = check_call_stack_for_agent_id() - self._safe_record(session, llm_event) - - if llm_event.completion is None: - llm_event.completion = { - "role": message.get("role"), - "content": message.get("content", ""), - "tool_calls": None, - "function_call": None, - } - else: - llm_event.completion["content"] += message.get("content", "") - - if inspect.isgenerator(response): - - def generator(): - for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return generator() - - llm_event.end_timestamp = get_ISO_time() - llm_event.model = f"ollama/{response['model']}" - llm_event.returns = response - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.prompt = kwargs["messages"] - llm_event.completion = { - "role": response["message"].get("role"), - "content": response["message"].get("content", ""), - "tool_calls": None, - "function_call": None, - } - self._safe_record(session, llm_event) - return response - - def override(self): - self._override_chat_client() - self._override_chat() - self._override_chat_async_client() - - def undo_override(self): - if original_func is not None and original_func != {}: - import ollama - - ollama.chat = original_func["ollama.chat"] - ollama.Client.chat = original_func["ollama.Client.chat"] - ollama.AsyncClient.chat = original_func["ollama.AsyncClient.chat"] - - def __init__(self, client): - super().__init__(client) - - def _override_chat(self): - import ollama - - original_func["ollama.chat"] = ollama.chat - - def patched_function(*args, **kwargs): - # Call the original function with its original arguments - init_timestamp = get_ISO_time() - result = original_func["ollama.chat"](*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=kwargs.get("session", None)) - - # Override the original method with the patched one - ollama.chat = patched_function - - def _override_chat_client(self): - from ollama import Client - - original_func["ollama.Client.chat"] = Client.chat - - def patched_function(*args, **kwargs): - # Call the original function with its original arguments - init_timestamp = get_ISO_time() - result = original_func["ollama.Client.chat"](*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=kwargs.get("session", None)) - - # Override the original method with the patched one - Client.chat = patched_function - - def _override_chat_async_client(self): - from ollama import AsyncClient - - original_func = {} - original_func["ollama.AsyncClient.chat"] = AsyncClient.chat - - async def patched_function(*args, **kwargs): - # Call the original function with its original arguments - init_timestamp = get_ISO_time() - result = await original_func["ollama.AsyncClient.chat"](*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=kwargs.get("session", None)) - - # Override the original method with the patched one - AsyncClient.chat = patched_function diff --git a/agentops/llms/providers/openai.py b/agentops/llms/providers/openai.py deleted file mode 100644 index 171b39fe1..000000000 --- a/agentops/llms/providers/openai.py +++ /dev/null @@ -1,344 +0,0 @@ -import pprint -from typing import Optional - -from agentops.llms.providers.base import BaseProvider -from agentops.time_travel import fetch_completion_override_from_time_travel_cache - -from agentops.event import ActionEvent, ErrorEvent, LLMEvent -from agentops.session import Session -from agentops.log_config import logger -from agentops.helpers import check_call_stack_for_agent_id, get_ISO_time -from agentops.singleton import singleton - - -@singleton -class OpenAiProvider(BaseProvider): - original_create = None - original_create_async = None - original_assistant_methods = None - assistants_run_steps = {} - - def __init__(self, client): - super().__init__(client) - self._provider_name = "OpenAI" - - def handle_response(self, response, kwargs, init_timestamp, session: Optional[Session] = None) -> dict: - """Handle responses for OpenAI versions >v1.0.0""" - from openai import AsyncStream, Stream - from openai.resources import AsyncCompletions - from openai.types.chat import ChatCompletionChunk - - llm_event = LLMEvent(init_timestamp=init_timestamp, params=kwargs) - if session is not None: - llm_event.session_id = session.session_id - - def handle_stream_chunk(chunk: ChatCompletionChunk): - # NOTE: prompt/completion usage not returned in response when streaming - # We take the first ChatCompletionChunk and accumulate the deltas from all subsequent chunks to build one full chat completion - if llm_event.returns == None: - llm_event.returns = chunk - - try: - accumulated_delta = llm_event.returns.choices[0].delta - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.model = chunk.model - llm_event.prompt = kwargs["messages"] - - # NOTE: We assume for completion only choices[0] is relevant - choice = chunk.choices[0] - - if choice.delta.content: - accumulated_delta.content += choice.delta.content - - if choice.delta.role: - accumulated_delta.role = choice.delta.role - - if choice.delta.tool_calls: - accumulated_delta.tool_calls = choice.delta.tool_calls - - if choice.delta.function_call: - accumulated_delta.function_call = choice.delta.function_call - - if choice.finish_reason: - # Streaming is done. Record LLMEvent - llm_event.returns.choices[0].finish_reason = choice.finish_reason - llm_event.completion = { - "role": accumulated_delta.role, - "content": accumulated_delta.content, - "function_call": accumulated_delta.function_call, - "tool_calls": accumulated_delta.tool_calls, - } - llm_event.end_timestamp = get_ISO_time() - - self._safe_record(session, llm_event) - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - - kwargs_str = pprint.pformat(kwargs) - chunk = pprint.pformat(chunk) - logger.warning( - f"Unable to parse a chunk for LLM call. Skipping upload to AgentOps\n" - f"chunk:\n {chunk}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - # if the response is a generator, decorate the generator - if isinstance(response, Stream): - - def generator(): - for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return generator() - - # For asynchronous AsyncStream - elif isinstance(response, AsyncStream): - - async def async_generator(): - async for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return async_generator() - - # For async AsyncCompletion - elif isinstance(response, AsyncCompletions): - - async def async_generator(): - async for chunk in response: - handle_stream_chunk(chunk) - yield chunk - - return async_generator() - - # v1.0.0+ responses are objects - try: - llm_event.returns = response - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.prompt = kwargs["messages"] - llm_event.prompt_tokens = response.usage.prompt_tokens - llm_event.completion = response.choices[0].message.model_dump() - llm_event.completion_tokens = response.usage.completion_tokens - llm_event.model = response.model - - self._safe_record(session, llm_event) - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=llm_event, exception=e)) - - kwargs_str = pprint.pformat(kwargs) - response = pprint.pformat(response) - logger.warning( - f"Unable to parse response for LLM call. Skipping upload to AgentOps\n" - f"response:\n {response}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - return response - - def handle_assistant_response(self, response, kwargs, init_timestamp, session: Optional[Session] = None) -> dict: - """Handle response based on return type""" - from openai.pagination import BasePage - - action_event = ActionEvent(init_timestamp=init_timestamp, params=kwargs) - if session is not None: - action_event.session_id = session.session_id - - try: - # Set action type and returns - action_event.action_type = ( - response.__class__.__name__.split("[")[1][:-1] - if isinstance(response, BasePage) - else response.__class__.__name__ - ) - action_event.returns = response.model_dump() if hasattr(response, "model_dump") else response - action_event.end_timestamp = get_ISO_time() - self._safe_record(session, action_event) - - # Create LLMEvent if usage data exists - response_dict = response.model_dump() if hasattr(response, "model_dump") else {} - - if "id" in response_dict and response_dict.get("id").startswith("run"): - if response_dict["id"] not in self.assistants_run_steps: - self.assistants_run_steps[response_dict.get("id")] = {"model": response_dict.get("model")} - - if "usage" in response_dict and response_dict["usage"] is not None: - llm_event = LLMEvent(init_timestamp=init_timestamp, params=kwargs) - if session is not None: - llm_event.session_id = session.session_id - - llm_event.model = response_dict.get("model") - llm_event.prompt_tokens = response_dict["usage"]["prompt_tokens"] - llm_event.completion_tokens = response_dict["usage"]["completion_tokens"] - llm_event.end_timestamp = get_ISO_time() - self._safe_record(session, llm_event) - - elif "data" in response_dict: - for item in response_dict["data"]: - if "usage" in item and item["usage"] is not None: - llm_event = LLMEvent(init_timestamp=init_timestamp, params=kwargs) - if session is not None: - llm_event.session_id = session.session_id - - llm_event.model = self.assistants_run_steps[item["run_id"]]["model"] - llm_event.prompt_tokens = item["usage"]["prompt_tokens"] - llm_event.completion_tokens = item["usage"]["completion_tokens"] - llm_event.end_timestamp = get_ISO_time() - self._safe_record(session, llm_event) - - except Exception as e: - self._safe_record(session, ErrorEvent(trigger_event=action_event, exception=e)) - - kwargs_str = pprint.pformat(kwargs) - response = pprint.pformat(response) - logger.warning( - f"Unable to parse response for Assistants API. Skipping upload to AgentOps\n" - f"response:\n {response}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - return response - - def override(self): - self._override_openai_v1_completion() - self._override_openai_v1_async_completion() - self._override_openai_assistants_beta() - - def _override_openai_v1_completion(self): - from openai.resources.chat import completions - from openai.types.chat import ChatCompletion, ChatCompletionChunk - - # Store the original method - self.original_create = completions.Completions.create - - def patched_function(*args, **kwargs): - init_timestamp = get_ISO_time() - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - - completion_override = fetch_completion_override_from_time_travel_cache(kwargs) - if completion_override: - result_model = None - pydantic_models = (ChatCompletion, ChatCompletionChunk) - for pydantic_model in pydantic_models: - try: - result_model = pydantic_model.model_validate_json(completion_override) - break - except Exception as e: - pass - - if result_model is None: - logger.error( - f"Time Travel: Pydantic validation failed for {pydantic_models} \n" - f"Time Travel: Completion override was:\n" - f"{pprint.pformat(completion_override)}" - ) - return None - return self.handle_response(result_model, kwargs, init_timestamp, session=session) - - # prompt_override = fetch_prompt_override_from_time_travel_cache(kwargs) - # if prompt_override: - # kwargs["messages"] = prompt_override["messages"] - - # Call the original function with its original arguments - result = self.original_create(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=session) - - # Override the original method with the patched one - completions.Completions.create = patched_function - - def _override_openai_v1_async_completion(self): - from openai.resources.chat import completions - from openai.types.chat import ChatCompletion, ChatCompletionChunk - - # Store the original method - self.original_create_async = completions.AsyncCompletions.create - - async def patched_function(*args, **kwargs): - init_timestamp = get_ISO_time() - - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - - completion_override = fetch_completion_override_from_time_travel_cache(kwargs) - if completion_override: - result_model = None - pydantic_models = (ChatCompletion, ChatCompletionChunk) - for pydantic_model in pydantic_models: - try: - result_model = pydantic_model.model_validate_json(completion_override) - break - except Exception as e: - pass - - if result_model is None: - logger.error( - f"Time Travel: Pydantic validation failed for {pydantic_models} \n" - f"Time Travel: Completion override was:\n" - f"{pprint.pformat(completion_override)}" - ) - return None - return self.handle_response(result_model, kwargs, init_timestamp, session=session) - - # prompt_override = fetch_prompt_override_from_time_travel_cache(kwargs) - # if prompt_override: - # kwargs["messages"] = prompt_override["messages"] - - # Call the original function with its original arguments - result = await self.original_create_async(*args, **kwargs) - return self.handle_response(result, kwargs, init_timestamp, session=session) - - # Override the original method with the patched one - completions.AsyncCompletions.create = patched_function - - def _override_openai_assistants_beta(self): - """Override OpenAI Assistants API methods""" - from openai._legacy_response import LegacyAPIResponse - from openai.resources import beta - - def create_patched_function(original_func): - def patched_function(*args, **kwargs): - init_timestamp = get_ISO_time() - - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - - response = original_func(*args, **kwargs) - if isinstance(response, LegacyAPIResponse): - return response - - return self.handle_assistant_response(response, kwargs, init_timestamp, session=session) - - return patched_function - - # Store and patch Assistant API methods - assistant_api_methods = { - beta.Assistants: ["create", "retrieve", "update", "delete", "list"], - beta.Threads: ["create", "retrieve", "update", "delete"], - beta.threads.Messages: ["create", "retrieve", "update", "list"], - beta.threads.Runs: ["create", "retrieve", "update", "list", "submit_tool_outputs", "cancel"], - beta.threads.runs.steps.Steps: ["retrieve", "list"], - } - - self.original_assistant_methods = { - (cls, method): getattr(cls, method) for cls, methods in assistant_api_methods.items() for method in methods - } - - # Override methods and verify - for (cls, method), original_func in self.original_assistant_methods.items(): - patched_function = create_patched_function(original_func) - setattr(cls, method, patched_function) - - def undo_override(self): - if self.original_create is not None and self.original_create_async is not None: - from openai.resources.chat import completions - - completions.AsyncCompletions.create = self.original_create_async - completions.Completions.create = self.original_create - - if self.original_assistant_methods is not None: - for (cls, method), original in self.original_assistant_methods.items(): - setattr(cls, method, original) diff --git a/agentops/llms/providers/taskweaver.py b/agentops/llms/providers/taskweaver.py deleted file mode 100644 index ae2fda1c0..000000000 --- a/agentops/llms/providers/taskweaver.py +++ /dev/null @@ -1,146 +0,0 @@ -import pprint -from typing import Optional -import json - -from agentops.event import ErrorEvent, LLMEvent, ActionEvent -from agentops.session import Session -from agentops.log_config import logger -from agentops.helpers import get_ISO_time, check_call_stack_for_agent_id -from agentops.llms.providers.base import BaseProvider -from agentops.singleton import singleton - - -@singleton -class TaskWeaverProvider(BaseProvider): - original_chat_completion = None - - def __init__(self, client): - super().__init__(client) - self._provider_name = "TaskWeaver" - self.client.add_default_tags(["taskweaver"]) - - def handle_response(self, response, kwargs, init_timestamp, session: Optional[Session] = None) -> dict: - """Handle responses for TaskWeaver""" - llm_event = LLMEvent(init_timestamp=init_timestamp, params=kwargs) - action_event = ActionEvent(init_timestamp=init_timestamp) - - try: - response_dict = response.get("response", {}) - - action_event.params = kwargs.get("json_schema", None) - action_event.returns = response_dict - action_event.end_timestamp = get_ISO_time() - self._safe_record(session, action_event) - except Exception as e: - error_event = ErrorEvent( - trigger_event=action_event, exception=e, details={"response": str(response), "kwargs": str(kwargs)} - ) - self._safe_record(session, error_event) - kwargs_str = pprint.pformat(kwargs) - response_str = pprint.pformat(response) - logger.error( - f"Unable to parse response for Action call. Skipping upload to AgentOps\n" - f"response:\n {response_str}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - try: - llm_event.init_timestamp = init_timestamp - llm_event.params = kwargs - llm_event.returns = response_dict - llm_event.agent_id = check_call_stack_for_agent_id() - llm_event.model = kwargs.get("model", "unknown") - llm_event.prompt = kwargs.get("messages") - llm_event.completion = response_dict.get("message", "") - llm_event.end_timestamp = get_ISO_time() - self._safe_record(session, llm_event) - - except Exception as e: - error_event = ErrorEvent( - trigger_event=llm_event, exception=e, details={"response": str(response), "kwargs": str(kwargs)} - ) - self._safe_record(session, error_event) - kwargs_str = pprint.pformat(kwargs) - response_str = pprint.pformat(response) - logger.error( - f"Unable to parse response for LLM call. Skipping upload to AgentOps\n" - f"response:\n {response_str}\n" - f"kwargs:\n {kwargs_str}\n" - ) - - return response - - def override(self): - """Override TaskWeaver's chat completion methods""" - try: - from taskweaver.llm import llm_completion_config_map - - def create_patched_chat_completion(original_method): - """Create a new patched chat_completion function with bound original method""" - - def patched_chat_completion(service, *args, **kwargs): - init_timestamp = get_ISO_time() - session = kwargs.get("session", None) - if "session" in kwargs.keys(): - del kwargs["session"] - - result = original_method(service, *args, **kwargs) - kwargs.update( - { - "model": self._get_model_name(service), - "messages": args[0], - "stream": args[1], - "temperature": args[2], - "max_tokens": args[3], - "top_p": args[4], - "stop": args[5], - } - ) - - if kwargs["stream"]: - accumulated_content = "" - for chunk in result: - if isinstance(chunk, dict) and "content" in chunk: - accumulated_content += chunk["content"] - else: - accumulated_content += chunk - yield chunk - accumulated_content = json.loads(accumulated_content) - return self.handle_response(accumulated_content, kwargs, init_timestamp, session=session) - else: - return self.handle_response(result, kwargs, init_timestamp, session=session) - - return patched_chat_completion - - for service_name, service_class in llm_completion_config_map.items(): - if not hasattr(service_class, "_original_chat_completion"): - service_class._original_chat_completion = service_class.chat_completion - service_class.chat_completion = create_patched_chat_completion( - service_class._original_chat_completion - ) - - except Exception as e: - logger.error(f"Failed to patch method: {str(e)}", exc_info=True) - - def undo_override(self): - """Restore original TaskWeaver chat completion methods""" - try: - from taskweaver.llm import llm_completion_config_map - - for service_name, service_class in llm_completion_config_map.items(): - service_class.chat_completion = service_class._original_chat_completion - delattr(service_class, "_original_chat_completion") - - except Exception as e: - logger.error(f"Failed to restore original method: {str(e)}", exc_info=True) - - def _get_model_name(self, service) -> str: - """Extract model name from service instance""" - model_name = "unknown" - if hasattr(service, "config"): - config = service.config - if hasattr(config, "model"): - model_name = config.model or "unknown" - elif hasattr(config, "llm_module_config") and hasattr(config.llm_module_config, "model"): - model_name = config.llm_module_config.model or "unknown" - return model_name diff --git a/agentops/llms/tracker.py b/agentops/llms/tracker.py deleted file mode 100644 index 4ce5b9841..000000000 --- a/agentops/llms/tracker.py +++ /dev/null @@ -1,285 +0,0 @@ -import inspect -import sys -from importlib import import_module -from importlib.metadata import version - -from packaging.version import Version, parse - -from ..log_config import logger - -from .providers.cohere import CohereProvider -from .providers.groq import GroqProvider -from .providers.litellm import LiteLLMProvider -from .providers.ollama import OllamaProvider -from .providers.openai import OpenAiProvider -from .providers.anthropic import AnthropicProvider -from .providers.mistral import MistralProvider -from .providers.ai21 import AI21Provider -from .providers.llama_stack_client import LlamaStackClientProvider -from .providers.taskweaver import TaskWeaverProvider -from .providers.gemini import GeminiProvider - -original_func = {} -original_create = None -original_create_async = None - - -class LlmTracker: - SUPPORTED_APIS = { - "google.generativeai": { - "0.1.0": ("GenerativeModel.generate_content", "GenerativeModel.generate_content_stream"), - }, - "litellm": {"1.3.1": ("openai_chat_completions.completion",)}, - "openai": { - "1.0.0": ( - "chat.completions.create", - # Assistants - "beta.assistants.create", - "beta.assistants.retrieve", - "beta.assistants.update", - "beta.assistants.delete", - "beta.assistants.list", - "beta.assistants.files.create", - "beta.assistants.files.retrieve", - "beta.assistants.files.delete", - "beta.assistants.files.list", - # Threads - "beta.threads.create", - "beta.threads.retrieve", - "beta.threads.update", - "beta.threads.delete", - # Messages - "beta.threads.messages.create", - "beta.threads.messages.retrieve", - "beta.threads.messages.update", - "beta.threads.messages.list", - "beta.threads.messages.files.retrieve", - "beta.threads.messages.files.list", - # Runs - "beta.threads.runs.create", - "beta.threads.runs.retrieve", - "beta.threads.runs.update", - "beta.threads.runs.list", - "beta.threads.runs.cancel", - "beta.threads.runs.submit_tool_outputs", - # Run Steps - "beta.threads.runs.steps.Steps.retrieve", - "beta.threads.runs.steps.Steps.list", - ), - "0.0.0": ( - "ChatCompletion.create", - "ChatCompletion.acreate", - ), - }, - "cohere": { - "5.4.0": ("chat", "chat_stream"), - }, - "ollama": {"0.0.1": ("chat", "Client.chat", "AsyncClient.chat")}, - "llama_stack_client": { - "0.0.53": ("resources.InferenceResource.chat_completion", "lib.agents.agent.Agent.create_turn"), - }, - "groq": { - "0.9.0": ("Client.chat", "AsyncClient.chat"), - }, - "anthropic": { - "0.32.0": ("completions.create",), - }, - "mistralai": { - "1.0.1": ("chat.complete", "chat.stream"), - }, - "ai21": { - "2.0.0": ( - "chat.completions.create", - "client.answer.create", - ), - }, - "taskweaver": { - "0.0.1": ("chat_completion", "chat_completion_stream"), - }, - } - - def __init__(self, client): - self.client = client - self.litellm_initialized = False - - def _is_litellm_call(self): - """ - Detects if the API call originated from LiteLLM. - - **Issue We Are Addressing:** - - When using LiteLLM, it internally calls OpenAI methods, which results in OpenAI being initialized by default. - - This creates an issue where OpenAI is tracked as the primary provider, even when the request was routed via LiteLLM. - - We need to ensure that OpenAI is only tracked if it was explicitly used and **not** invoked indirectly through LiteLLM. - - **How This Works:** - - The function checks the call stack (execution history) to determine the order in which modules were called. - - If LiteLLM appears in the call stack **before** OpenAI, then OpenAI was invoked via LiteLLM, meaning we should ignore OpenAI. - - If OpenAI appears first without LiteLLM, then OpenAI was used directly, and we should track it as expected. - - **Return Value:** - - Returns `True` if the API call originated from LiteLLM. - - Returns `False` if OpenAI was directly called without going through LiteLLM. - """ - - stack = inspect.stack() - - litellm_seen = False # Track if LiteLLM was encountered in the stack - openai_seen = False # Track if OpenAI was encountered in the stack - - for frame in stack: - module = inspect.getmodule(frame.frame) - - module_name = module.__name__ if module else None - - filename = frame.filename.lower() - - if module_name and "litellm" in module_name or "litellm" in filename: - litellm_seen = True - - if module_name and "openai" in module_name or "openai" in filename: - openai_seen = True - - # If OpenAI is seen **before** LiteLLM, it means OpenAI was used directly, so return False - if not litellm_seen: - return False - - # If LiteLLM was seen at any point before OpenAI, return True (indicating an indirect OpenAI call via LiteLLM) - return litellm_seen - - def override_api(self): - """ - Overrides key methods of the specified API to record events. - """ - for api in self.SUPPORTED_APIS: - if api in sys.modules: - module = import_module(api) - - if api == "litellm": - module_version = version(api) - if module_version is None: - logger.warning("Cannot determine LiteLLM version. Only LiteLLM>=1.3.1 supported.") - - if Version(module_version) >= parse("1.3.1"): - provider = LiteLLMProvider(self.client) - provider.override() - self.litellm_initialized = True - else: - logger.warning(f"Only LiteLLM>=1.3.1 supported. v{module_version} found.") - - if api == "openai": - # Patch openai v1.0.0+ methods - # Ensure OpenAI is only initialized if it was NOT called inside LiteLLM - if not self._is_litellm_call(): - if hasattr(module, "__version__"): - module_version = parse(module.__version__) - if module_version >= parse("1.0.0"): - provider = OpenAiProvider(self.client) - provider.override() - else: - raise DeprecationWarning( - "OpenAI versions < 0.1 are no longer supported by AgentOps. Please upgrade OpenAI or " - "downgrade AgentOps to <=0.3.8." - ) - - if api == "cohere": - # Patch cohere v5.4.0+ methods - module_version = version(api) - if module_version is None: - logger.warning("Cannot determine Cohere version. Only Cohere>=5.4.0 supported.") - - if Version(module_version) >= parse("5.4.0"): - provider = CohereProvider(self.client) - provider.override() - else: - logger.warning(f"Only Cohere>=5.4.0 supported. v{module_version} found.") - - if api == "ollama": - module_version = version(api) - - if Version(module_version) >= parse("0.0.1"): - provider = OllamaProvider(self.client) - provider.override() - else: - logger.warning(f"Only Ollama>=0.0.1 supported. v{module_version} found.") - - if api == "groq": - module_version = version(api) - - if Version(module_version) >= parse("0.9.0"): - provider = GroqProvider(self.client) - provider.override() - else: - logger.warning(f"Only Groq>=0.9.0 supported. v{module_version} found.") - - if api == "anthropic": - module_version = version(api) - - if module_version is None: - logger.warning("Cannot determine Anthropic version. Only Anthropic>=0.32.0 supported.") - - if Version(module_version) >= parse("0.32.0"): - provider = AnthropicProvider(self.client) - provider.override() - else: - logger.warning(f"Only Anthropic>=0.32.0 supported. v{module_version} found.") - - if api == "mistralai": - module_version = version(api) - - if Version(module_version) >= parse("1.0.1"): - provider = MistralProvider(self.client) - provider.override() - else: - logger.warning(f"Only MistralAI>=1.0.1 supported. v{module_version} found.") - - if api == "ai21": - module_version = version(api) - - if module_version is None: - logger.warning("Cannot determine AI21 version. Only AI21>=2.0.0 supported.") - - if Version(module_version) >= parse("2.0.0"): - provider = AI21Provider(self.client) - provider.override() - else: - logger.warning(f"Only AI21>=2.0.0 supported. v{module_version} found.") - - if api == "llama_stack_client": - module_version = version(api) - - if Version(module_version) >= parse("0.0.53"): - provider = LlamaStackClientProvider(self.client) - provider.override() - else: - logger.warning(f"Only LlamaStackClient>=0.0.53 supported. v{module_version} found.") - - if api == "taskweaver": - module_version = version(api) - - if Version(module_version) >= parse("0.0.1"): - provider = TaskWeaverProvider(self.client) - provider.override() - else: - logger.warning(f"Only TaskWeaver>=0.0.1 supported. v{module_version} found.") - - if api == "google.generativeai": - module_version = version(api) - - if Version(module_version) >= parse("0.1.0"): - provider = GeminiProvider(self.client) - provider.override() - else: - logger.warning(f"Only google.generativeai>=0.1.0 supported. v{module_version} found.") - - def stop_instrumenting(self): - OpenAiProvider(self.client).undo_override() - GroqProvider(self.client).undo_override() - CohereProvider(self.client).undo_override() - LiteLLMProvider(self.client).undo_override() - OllamaProvider(self.client).undo_override() - AnthropicProvider(self.client).undo_override() - MistralProvider(self.client).undo_override() - AI21Provider(self.client).undo_override() - LlamaStackClientProvider(self.client).undo_override() - TaskWeaverProvider(self.client).undo_override() - GeminiProvider(self.client).undo_override() diff --git a/agentops/log_config.py b/agentops/log_config.py deleted file mode 100644 index 584cfb381..000000000 --- a/agentops/log_config.py +++ /dev/null @@ -1,53 +0,0 @@ -import logging -import os -import re - - -class AgentOpsLogFormatter(logging.Formatter): - blue = "\x1b[34m" - bold_red = "\x1b[31;1m" - reset = "\x1b[0m" - prefix = "🖇 AgentOps: " - - FORMATS = { - logging.DEBUG: f"(DEBUG) {prefix}%(message)s", - logging.INFO: f"{prefix}%(message)s", - logging.WARNING: f"{prefix}%(message)s", - logging.ERROR: f"{bold_red}{prefix}%(message)s{reset}", - logging.CRITICAL: f"{bold_red}{prefix}%(message)s{reset}", - } - - def format(self, record): - log_fmt = self.FORMATS.get(record.levelno, self.FORMATS[logging.INFO]) - formatter = logging.Formatter(log_fmt) - return formatter.format(record) - - -logger = logging.getLogger("agentops") -logger.propagate = False -logger.setLevel(logging.CRITICAL) - -# Streaming Handler -stream_handler = logging.StreamHandler() -stream_handler.setLevel(logging.DEBUG) -stream_handler.setFormatter(AgentOpsLogFormatter()) -logger.addHandler(stream_handler) - - -# File Handler -class AgentOpsLogFileFormatter(logging.Formatter): - def format(self, record): - # Remove ANSI escape codes from the message - record.msg = ANSI_ESCAPE_PATTERN.sub("", str(record.msg)) - return super().format(record) - - -ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-9;]*m") -log_to_file = os.environ.get("AGENTOPS_LOGGING_TO_FILE", "False").lower() == "true" -if log_to_file: - file_handler = logging.FileHandler("agentops.log", mode="w") - file_handler.setLevel(logging.DEBUG) - formatter = AgentOpsLogFileFormatter("%(asctime)s - %(levelname)s - %(message)s") - file_handler.setFormatter(formatter) - file_handler.setFormatter(formatter) - logger.addHandler(file_handler) diff --git a/agentops/logging/__init__.py b/agentops/logging/__init__.py new file mode 100644 index 000000000..2e0aa5fc3 --- /dev/null +++ b/agentops/logging/__init__.py @@ -0,0 +1,3 @@ +from .config import configure_logging, logger + +__all__ = ['logger', 'configure_logging'] diff --git a/agentops/logging/config.py b/agentops/logging/config.py new file mode 100644 index 000000000..df401a608 --- /dev/null +++ b/agentops/logging/config.py @@ -0,0 +1,51 @@ +import logging +import os +from typing import Optional + +from .formatters import AgentOpsLogFileFormatter, AgentOpsLogFormatter + +# Create the logger at module level +logger = logging.getLogger("agentops") +logger.propagate = False +logger.setLevel(logging.CRITICAL) + +def configure_logging(config=None): # Remove type hint temporarily to avoid circular import + """Configure the AgentOps logger with console and optional file handlers. + + Args: + config: Optional Config instance. If not provided, a new Config instance will be created. + """ + # Defer the Config import to avoid circular dependency + if config is None: + from agentops.config import Config + config = Config() + + # Use env var as override if present, otherwise use config + log_level_env = os.environ.get("AGENTOPS_LOG_LEVEL", "").upper() + if log_level_env and hasattr(logging, log_level_env): + log_level = getattr(logging, log_level_env) + else: + log_level = config.log_level if isinstance(config.log_level, int) else logging.CRITICAL + + logger.setLevel(log_level) + + # Remove existing handlers + for handler in logger.handlers[:]: + logger.removeHandler(handler) + + # Configure console logging + stream_handler = logging.StreamHandler() + stream_handler.setLevel(logging.DEBUG) + stream_handler.setFormatter(AgentOpsLogFormatter()) + logger.addHandler(stream_handler) + + # Configure file logging if enabled + log_to_file = os.environ.get("AGENTOPS_LOGGING_TO_FILE", "True").lower() == "true" + if log_to_file: + file_handler = logging.FileHandler("agentops.log", mode="w") + file_handler.setLevel(logging.DEBUG) + formatter = AgentOpsLogFileFormatter("%(asctime)s - %(levelname)s - %(message)s") + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + return logger diff --git a/agentops/logging/formatters.py b/agentops/logging/formatters.py new file mode 100644 index 000000000..277883041 --- /dev/null +++ b/agentops/logging/formatters.py @@ -0,0 +1,31 @@ +import logging +import re + +class AgentOpsLogFormatter(logging.Formatter): + """Formatter for console logging with colors and prefix.""" + blue = "\x1b[34m" + bold_red = "\x1b[31;1m" + reset = "\x1b[0m" + prefix = "🖇 AgentOps: " + + FORMATS = { + logging.DEBUG: f"(DEBUG) {prefix}%(message)s", + logging.INFO: f"{prefix}%(message)s", + logging.WARNING: f"{prefix}%(message)s", + logging.ERROR: f"{bold_red}{prefix}%(message)s{reset}", + logging.CRITICAL: f"{bold_red}{prefix}%(message)s{reset}", + } + + def format(self, record): + log_fmt = self.FORMATS.get(record.levelno, self.FORMATS[logging.INFO]) + formatter = logging.Formatter(log_fmt) + return formatter.format(record) + + +class AgentOpsLogFileFormatter(logging.Formatter): + """Formatter for file logging that removes ANSI escape codes.""" + ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-9;]*m") + + def format(self, record): + record.msg = self.ANSI_ESCAPE_PATTERN.sub("", str(record.msg)) + return super().format(record) \ No newline at end of file diff --git a/agentops/meta_client.py b/agentops/meta_client.py deleted file mode 100644 index 6cc7ed2ef..000000000 --- a/agentops/meta_client.py +++ /dev/null @@ -1,64 +0,0 @@ -from .log_config import logger -import traceback - -from .host_env import get_host_env -from .http_client import HttpClient -from .helpers import safe_serialize, get_agentops_version - -from os import environ - - -class MetaClient(type): - """Metaclass to automatically decorate methods with exception handling and provide a shared exception handler.""" - - def __new__(cls, name, bases, dct): - # Wrap each method with the handle_exceptions decorator - for method_name, method in dct.items(): - if (callable(method) and not method_name.startswith("__")) or method_name == "__init__": - dct[method_name] = handle_exceptions(method) - - return super().__new__(cls, name, bases, dct) - - def send_exception_to_server(cls, exception, api_key, session): - """Class method to send exception to server.""" - if api_key: - exception_type = type(exception).__name__ - exception_message = str(exception) - exception_traceback = traceback.format_exc() - developer_error = { - "sdk_version": get_agentops_version(), - "type": exception_type, - "message": exception_message, - "stack_trace": exception_traceback, - "host_env": get_host_env(), - } - - if session: - developer_error["session_id"] = session.session_id - try: - HttpClient.post( - "https://api.agentops.ai/v2/developer_errors", - safe_serialize(developer_error).encode("utf-8"), - api_key=api_key, - ) - except: - pass - - -def handle_exceptions(method): - """Decorator within the metaclass to wrap method execution in try-except block.""" - - def wrapper(self, *args, **kwargs): - try: - return method(self, *args, **kwargs) - except Exception as e: - logger.warning(f"Error: {e}") - config = getattr(self, "config", None) - if config is not None: - session = None - if len(self._sessions) > 0: - session = self._sessions[0] - type(self).send_exception_to_server(e, self.config._api_key, session) - raise e - - return wrapper diff --git a/agentops/partners/__init__.py b/agentops/partners/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/agentops/partners/autogen_logger.py b/agentops/partners/autogen_logger.py deleted file mode 100644 index 9fc85fb28..000000000 --- a/agentops/partners/autogen_logger.py +++ /dev/null @@ -1,119 +0,0 @@ -from __future__ import annotations - -import logging -import threading -import uuid -from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Union, TypeVar, Callable - -import agentops -from openai import AzureOpenAI, OpenAI -from openai.types.chat import ChatCompletion - -from autogen.logger.base_logger import BaseLogger, LLMConfig - -from agentops.session import EndState -from agentops.helpers import get_ISO_time - -from agentops import LLMEvent, ToolEvent, ActionEvent -from uuid import uuid4 - -if TYPE_CHECKING: - from autogen import Agent, ConversableAgent, OpenAIWrapper - -logger = logging.getLogger(__name__) -lock = threading.Lock() - -__all__ = ("AutogenLogger",) - -F = TypeVar("F", bound=Callable[..., Any]) - - -class AutogenLogger(BaseLogger): - agent_store: [{"agentops_id": str, "autogen_id": str}] = [] - - def __init__(self): - pass - - def start(self) -> str: - pass - - def _get_agentops_id_from_agent(self, autogen_id: str) -> str: - for agent in self.agent_store: - if agent["autogen_id"] == autogen_id: - return agent["agentops_id"] - - def log_chat_completion( - self, - invocation_id: uuid.UUID, - client_id: int, - wrapper_id: int, - agent: Union[str, Agent], - request: Dict[str, Union[float, str, List[Dict[str, str]]]], - response: Union[str, ChatCompletion], - is_cached: int, - cost: float, - start_time: str, - ) -> None: - """Records an LLMEvent to AgentOps session""" - - completion = response.choices[len(response.choices) - 1] - - # Note: Autogen tokens are not included in the request and function call tokens are not counted in the completion - llm_event = LLMEvent( - prompt=request["messages"], - completion=completion.message.to_dict(), - model=response.model, - cost=cost, - returns=completion.message.to_json(), - ) - llm_event.init_timestamp = start_time - llm_event.end_timestamp = get_ISO_time() - llm_event.agent_id = self._get_agentops_id_from_agent(str(id(agent))) - agentops.record(llm_event) - - def log_new_agent(self, agent: ConversableAgent, init_args: Dict[str, Any]) -> None: - """Calls agentops.create_agent""" - ao_agent_id = agentops.create_agent(agent.name, str(uuid4())) - self.agent_store.append({"agentops_id": ao_agent_id, "autogen_id": str(id(agent))}) - - def log_event(self, source: Union[str, Agent], name: str, **kwargs: Dict[str, Any]) -> None: - """Records an ActionEvent to AgentOps session""" - event = ActionEvent(action_type=name) - agentops_id = self._get_agentops_id_from_agent(str(id(source))) - event.agent_id = agentops_id - event.params = kwargs - agentops.record(event) - - def log_function_use(self, source: Union[str, Agent], function: F, args: Dict[str, Any], returns: any): - """Records a ToolEvent to AgentOps session""" - event = ToolEvent() - agentops_id = self._get_agentops_id_from_agent(str(id(source))) - event.agent_id = agentops_id - event.function = function # TODO: this is not a parameter - event.params = args - event.returns = returns - event.name = getattr(function, "__name__") - agentops.record(event) - - def log_new_wrapper( - self, - wrapper: OpenAIWrapper, - init_args: Dict[str, Union[LLMConfig, List[LLMConfig]]], - ) -> None: - pass - - def log_new_client( - self, - client: Union[AzureOpenAI, OpenAI], - wrapper: OpenAIWrapper, - init_args: Dict[str, Any], - ) -> None: - pass - - def stop(self) -> None: - """Ends AgentOps session""" - agentops.end_session(end_state=EndState.INDETERMINATE.value) - - def get_connection(self) -> None: - """Method intentionally left blank""" - pass diff --git a/agentops/partners/langchain_callback_handler.py b/agentops/partners/langchain_callback_handler.py deleted file mode 100644 index 768097dcf..000000000 --- a/agentops/partners/langchain_callback_handler.py +++ /dev/null @@ -1,883 +0,0 @@ -from typing import Dict, Any, List, Optional, Sequence, Union -from collections import defaultdict -from uuid import UUID -import logging -import os - -from tenacity import RetryCallState - -from langchain_core.agents import AgentFinish, AgentAction -from langchain_core.documents import Document -from langchain_core.outputs import ChatGenerationChunk, GenerationChunk, LLMResult -from langchain_core.callbacks.base import BaseCallbackHandler, AsyncCallbackHandler -from langchain_core.messages import BaseMessage - -from agentops import Client as AOClient -from agentops import ActionEvent, LLMEvent, ToolEvent, ErrorEvent -from agentops.helpers import get_ISO_time, debug_print_function_params -from ..log_config import logger - - -def get_model_from_kwargs(kwargs: any) -> str: - if "model" in kwargs["invocation_params"]: - return kwargs["invocation_params"]["model"] - elif "_type" in kwargs["invocation_params"]: - return kwargs["invocation_params"]["_type"] - else: - return "unknown_model" - - -class Events: - llm: Dict[str, LLMEvent] = {} - tool: Dict[str, ToolEvent] = {} - chain: Dict[str, ActionEvent] = {} - retriever: Dict[str, ActionEvent] = {} - error: Dict[str, ErrorEvent] = {} - - -class LangchainCallbackHandler(BaseCallbackHandler): - """Callback handler for Langchain agents.""" - - def __init__( - self, - api_key: Optional[str] = None, - endpoint: Optional[str] = None, - max_wait_time: Optional[int] = None, - max_queue_size: Optional[int] = None, - default_tags: List[str] = ["langchain", "sync"], - ): - logging_level = os.getenv("AGENTOPS_LOGGING_LEVEL") - log_levels = { - "CRITICAL": logging.CRITICAL, - "ERROR": logging.ERROR, - "INFO": logging.INFO, - "WARNING": logging.WARNING, - "DEBUG": logging.DEBUG, - } - logger.setLevel(log_levels.get(logging_level or "INFO", "INFO")) - - client_params: Dict[str, Any] = { - "api_key": api_key, - "endpoint": endpoint, - "max_wait_time": max_wait_time, - "max_queue_size": max_queue_size, - "default_tags": default_tags, - } - - self.ao_client = AOClient() - if self.ao_client.session_count == 0: - self.ao_client.configure( - **{k: v for k, v in client_params.items() if v is not None}, - instrument_llm_calls=False, - ) - - if not self.ao_client.is_initialized: - self.ao_client.initialize() - - self.agent_actions: Dict[UUID, List[ActionEvent]] = defaultdict(list) - self.events = Events() - - @debug_print_function_params - def on_llm_start( - self, - serialized: Dict[str, Any], - prompts: List[str], - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - tags: Optional[List[str]] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs: Any, - ) -> Any: - self.events.llm[str(run_id)] = LLMEvent( - params={ - "serialized": serialized, - "metadata": ({} if metadata is None else metadata), - "kwargs": kwargs, - "run_id": run_id, - "parent_run_id": parent_run_id, - "tags": tags, - }, - model=get_model_from_kwargs(kwargs), - prompt=prompts[0], - ) - - @debug_print_function_params - def on_chat_model_start( - self, - serialized: Dict[str, Any], - messages: List[List[BaseMessage]], - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - tags: Optional[List[str]] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs: Any, - ) -> Any: - """Run when a chat model starts running.""" - parsed_messages = [ - {"role": message.type, "content": message.content} - for message in messages[0] - if message.type in ["system", "human"] - ] - - action_event = ActionEvent( - params={ - "serialized": serialized, - "metadata": ({} if metadata is None else metadata), - "kwargs": kwargs, - "run_id": run_id, - "parent_run_id": parent_run_id, - "tags": tags, - "messages": parsed_messages, - }, - action_type="on_chat_model_start", - ) - self.ao_client.record(action_event) - - # Initialize LLMEvent here since on_llm_start isn't called for chat models - self.events.llm[str(run_id)] = LLMEvent( - params={ - "serialized": serialized, - "messages": messages, - "run_id": run_id, - "parent_run_id": parent_run_id, - "tags": tags, - "metadata": ({} if metadata is None else metadata), - "kwargs": kwargs, - }, - model=get_model_from_kwargs(kwargs), - prompt=parsed_messages, - completion="", - returns={}, - ) - - @debug_print_function_params - def on_llm_error( - self, - error: BaseException, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> Any: - llm_event: LLMEvent = self.events.llm[str(run_id)] - error_event = ErrorEvent( - trigger_event=llm_event, - exception=error, - details={"run_id": run_id, "parent_run_id": parent_run_id, "kwargs": kwargs}, - ) - self.ao_client.record(error_event) - - @debug_print_function_params - def on_llm_end( - self, - response: LLMResult, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> Any: - llm_event: LLMEvent = self.events.llm[str(run_id)] - llm_event.returns = response - llm_event.end_timestamp = get_ISO_time() - - if len(response.generations) == 0: - error_event = ErrorEvent( - trigger_event=self.events.llm[str(run_id)], - error_type="NoGenerations", - details={"run_id": run_id, "parent_run_id": parent_run_id, "kwargs": kwargs}, - ) - self.ao_client.record(error_event) - else: - for generation in response.generations[0]: - if ( - generation.message.type == "AIMessage" - and generation.text - and llm_event.completion != generation.text - ): - llm_event.completion = generation.text - elif ( - generation.message.type == "AIMessageChunk" - and generation.message.content - and llm_event.completion != generation.message.content - ): - llm_event.completion += generation.message.content - - if response.llm_output is not None: - llm_event.prompt_tokens = response.llm_output["token_usage"]["prompt_tokens"] - llm_event.completion_tokens = response.llm_output["token_usage"]["completion_tokens"] - self.ao_client.record(llm_event) - - @debug_print_function_params - def on_chain_start( - self, - serialized: Dict[str, Any], - inputs: Dict[str, Any], - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - tags: Optional[List[str]] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs: Any, - ) -> Any: - # Initialize with empty dicts if None - serialized = serialized or {} - inputs = inputs or {} - metadata = metadata or {} - - self.events.chain[str(run_id)] = ActionEvent( - params={ - "serialized": serialized, - "inputs": inputs, - "metadata": ({} if metadata is None else metadata), - "kwargs": kwargs, - "run_id": run_id, - "parent_run_id": parent_run_id, - "tags": tags, - **kwargs, - }, - action_type="on_chain_start", - ) - - @debug_print_function_params - def on_chain_end( - self, - outputs: Dict[str, Any], - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> Any: - action_event: ActionEvent = self.events.chain[str(run_id)] - action_event.returns = outputs - action_event.end_timestamp = get_ISO_time() - self.ao_client.record(action_event) - - @debug_print_function_params - def on_chain_error( - self, - error: BaseException, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> Any: - # Create a new ActionEvent if one doesn't exist for this run_id - if str(run_id) not in self.events.chain: - self.events.chain[str(run_id)] = ActionEvent(params=kwargs, action_type="on_chain_error") - - action_event = self.events.chain[str(run_id)] - error_event = ErrorEvent(trigger_event=action_event, exception=error) - self.ao_client.record(error_event) - - @debug_print_function_params - def on_tool_start( - self, - serialized: Dict[str, Any], - input_str: str, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - tags: Optional[List[str]] = None, - metadata: Optional[Dict[str, Any]] = None, - inputs: Optional[Dict[str, Any]] = None, - **kwargs: Any, - ) -> Any: - self.events.tool[str(run_id)] = ToolEvent( - params=inputs, - name=serialized.get("name"), - logs={ - "serialized": serialized, - "input_str": input_str, - "metadata": ({} if metadata is None else metadata), - "kwargs": kwargs, - "run_id": run_id, - "parent_run_id": parent_run_id, - "tags": tags, - }, - ) - - @debug_print_function_params - def on_tool_end( - self, - output: str, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> Any: - tool_event: ToolEvent = self.events.tool[str(run_id)] - tool_event.end_timestamp = get_ISO_time() - tool_event.returns = output - - if kwargs.get("name") == "_Exception": - error_event = ErrorEvent( - trigger_event=tool_event, - error_type="LangchainToolException", - details=output, - ) - self.ao_client.record(error_event) - else: - self.ao_client.record(tool_event) - - @debug_print_function_params - def on_tool_error( - self, - error: BaseException, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> Any: - tool_event: ToolEvent = self.events.tool[str(run_id)] - error_event = ErrorEvent(trigger_event=tool_event, exception=error) - self.ao_client.record(error_event) - - @debug_print_function_params - def on_retriever_start( - self, - serialized: Dict[str, Any], - query: str, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - tags: Optional[List[str]] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs: Any, - ) -> Any: - self.events.retriever[str(run_id)] = ActionEvent( - params={ - "serialized": serialized, - "query": query, - "metadata": ({} if metadata is None else metadata), - "kwargs": kwargs, - "run_id": run_id, - "parent_run_id": parent_run_id, - "tags": tags, - }, - action_type="on_retriever_start", - ) - - @debug_print_function_params - def on_retriever_end( - self, - documents: Sequence[Document], - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - tags: Optional[List[str]] = None, - **kwargs: Any, - ) -> Any: - action_event: ActionEvent = self.events.retriever[str(run_id)] - action_event.returns = documents - action_event.end_timestamp = get_ISO_time() - self.ao_client.record(action_event) - - @debug_print_function_params - def on_retriever_error( - self, - error: BaseException, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - tags: Optional[List[str]] = None, - **kwargs: Any, - ) -> Any: - action_event: ActionEvent = self.events.retriever[str(run_id)] - error_event = ErrorEvent(trigger_event=action_event, exception=error) - self.ao_client.record(error_event) - - @debug_print_function_params - def on_agent_action( - self, - action: AgentAction, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> Any: - self.agent_actions[run_id].append( - ActionEvent(params={"action": action, **kwargs}, action_type="on_agent_action") - ) - - @debug_print_function_params - def on_agent_finish( - self, - finish: AgentFinish, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> Any: - self.agent_actions[run_id][-1].returns = finish.to_json() - for agentAction in self.agent_actions[run_id]: - self.ao_client.record(agentAction) - - @debug_print_function_params - def on_retry( - self, - retry_state: RetryCallState, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> Any: - action_event = ActionEvent( - params={ - "retry_state": retry_state, - "run_id": run_id, - "parent_run_id": parent_run_id, - "kwargs": kwargs, - }, - action_type="on_retry", - ) - self.ao_client.record(action_event) - - @debug_print_function_params - def on_llm_new_token( - self, - token: str, - *, - chunk: Optional[Union[GenerationChunk, ChatGenerationChunk]] = None, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - tags: Optional[List[str]] = None, - **kwargs: Any, - ) -> Any: - """Run on new LLM token. Only available when streaming is enabled.""" - if str(run_id) not in self.events.llm: - self.events.llm[str(run_id)] = LLMEvent(params=kwargs) - self.events.llm[str(run_id)].completion = "" - - llm_event = self.events.llm[str(run_id)] - # Always append the new token to the existing completion - llm_event.completion += token - - @property - def current_session_ids(self): - return self.ao_client.current_session_ids - - -class AsyncLangchainCallbackHandler(AsyncCallbackHandler): - """Callback handler for Langchain agents.""" - - def __init__( - self, - api_key: Optional[str] = None, - endpoint: Optional[str] = None, - max_wait_time: Optional[int] = None, - max_queue_size: Optional[int] = None, - default_tags: List[str] = ["langchain", "async"], - ): - logging_level = os.getenv("AGENTOPS_LOGGING_LEVEL") - log_levels = { - "CRITICAL": logging.CRITICAL, - "ERROR": logging.ERROR, - "INFO": logging.INFO, - "WARNING": logging.WARNING, - "DEBUG": logging.DEBUG, - } - logger.setLevel(log_levels.get(logging_level or "INFO", "INFO")) - - client_params: Dict[str, Any] = { - "api_key": api_key, - "endpoint": endpoint, - "max_wait_time": max_wait_time, - "max_queue_size": max_queue_size, - "default_tags": default_tags, - } - - self.ao_client = AOClient() - if self.ao_client.session_count == 0: - self.ao_client.configure( - **{k: v for k, v in client_params.items() if v is not None}, - instrument_llm_calls=False, - default_tags=["langchain"], - ) - - if not self.ao_client.is_initialized: - self.ao_client.initialize() - - self.agent_actions: Dict[UUID, List[ActionEvent]] = defaultdict(list) - self.events = Events() - - @debug_print_function_params - async def on_llm_start( - self, - serialized: Dict[str, Any], - prompts: List[str], - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - tags: Optional[List[str]] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs: Any, - ) -> None: - self.events.llm[str(run_id)] = LLMEvent( - params={ - "serialized": serialized, - "metadata": ({} if metadata is None else metadata), - "kwargs": kwargs, - "run_id": run_id, - "parent_run_id": parent_run_id, - "tags": tags, - }, - model=get_model_from_kwargs(kwargs), - prompt=prompts[0], - ) - - @debug_print_function_params - async def on_chat_model_start( - self, - serialized: Dict[str, Any], - messages: List[List[BaseMessage]], - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - tags: Optional[List[str]] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs: Any, - ) -> None: - """Run when a chat model starts running.""" - parsed_messages = [ - {"role": message.type, "content": message.content} - for message in messages[0] - if message.type in ["system", "human"] - ] - - action_event = ActionEvent( - params={ - "serialized": serialized, - "metadata": ({} if metadata is None else metadata), - "kwargs": kwargs, - "run_id": run_id, - "parent_run_id": parent_run_id, - "tags": tags, - "messages": parsed_messages, - }, - action_type="on_chat_model_start", - ) - self.ao_client.record(action_event) - - # Initialize LLMEvent here since on_llm_start isn't called for chat models - self.events.llm[str(run_id)] = LLMEvent( - params={ - "serialized": serialized, - "messages": messages, - "run_id": run_id, - "parent_run_id": parent_run_id, - "tags": tags, - "metadata": ({} if metadata is None else metadata), - "kwargs": kwargs, - }, - model=get_model_from_kwargs(kwargs), - prompt=parsed_messages, - completion="", - returns={}, - ) - - @debug_print_function_params - async def on_llm_new_token( - self, - token: str, - *, - chunk: Optional[Union[GenerationChunk, ChatGenerationChunk]] = None, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - tags: Optional[List[str]] = None, - **kwargs: Any, - ) -> None: - """Run on new LLM token. Only available when streaming is enabled.""" - if str(run_id) not in self.events.llm: - self.events.llm[str(run_id)] = LLMEvent(params=kwargs) - self.events.llm[str(run_id)].completion = "" - - llm_event = self.events.llm[str(run_id)] - # Always append the new token to the existing completion - llm_event.completion += token - - @debug_print_function_params - async def on_llm_error( - self, - error: BaseException, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> None: - llm_event: LLMEvent = self.events.llm[str(run_id)] - error_event = ErrorEvent( - trigger_event=llm_event, - exception=error, - details={"run_id": run_id, "parent_run_id": parent_run_id, "kwargs": kwargs}, - ) - self.ao_client.record(error_event) - - @debug_print_function_params - async def on_llm_end( - self, - response: LLMResult, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> None: - llm_event: LLMEvent = self.events.llm[str(run_id)] - llm_event.returns = response - llm_event.end_timestamp = get_ISO_time() - - if len(response.generations) == 0: - error_event = ErrorEvent( - trigger_event=self.events.llm[str(run_id)], - error_type="NoGenerations", - details={"run_id": run_id, "parent_run_id": parent_run_id, "kwargs": kwargs}, - ) - self.ao_client.record(error_event) - else: - for generation in response.generations[0]: - if ( - generation.message.type == "AIMessage" - and generation.text - and llm_event.completion != generation.text - ): - llm_event.completion = generation.text - elif ( - generation.message.type == "AIMessageChunk" - and generation.message.content - and llm_event.completion != generation.message.content - ): - llm_event.completion += generation.message.content - - if response.llm_output is not None: - llm_event.prompt_tokens = response.llm_output["token_usage"]["prompt_tokens"] - llm_event.completion_tokens = response.llm_output["token_usage"]["completion_tokens"] - self.ao_client.record(llm_event) - - @debug_print_function_params - async def on_chain_start( - self, - serialized: Dict[str, Any], - inputs: Dict[str, Any], - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - tags: Optional[List[str]] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs: Any, - ) -> None: - # Initialize with empty dicts if None - serialized = serialized or {} - inputs = inputs or {} - metadata = metadata or {} - - self.events.chain[str(run_id)] = ActionEvent( - params={ - "serialized": serialized, - "inputs": inputs, - "metadata": ({} if metadata is None else metadata), - "kwargs": kwargs, - "run_id": run_id, - "parent_run_id": parent_run_id, - "tags": tags, - }, - action_type="on_chain_start", - ) - - @debug_print_function_params - async def on_chain_end( - self, - outputs: Dict[str, Any], - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> None: - action_event: ActionEvent = self.events.chain[str(run_id)] - action_event.returns = outputs - action_event.end_timestamp = get_ISO_time() - self.ao_client.record(action_event) - - @debug_print_function_params - async def on_chain_error( - self, - error: BaseException, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> None: - # Create a new ActionEvent if one doesn't exist for this run_id - if str(run_id) not in self.events.chain: - self.events.chain[str(run_id)] = ActionEvent(params=kwargs, action_type="on_chain_error") - - action_event = self.events.chain[str(run_id)] - error_event = ErrorEvent(trigger_event=action_event, exception=error) - self.ao_client.record(error_event) - - @debug_print_function_params - async def on_tool_start( - self, - serialized: Dict[str, Any], - input_str: str, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - tags: Optional[List[str]] = None, - metadata: Optional[Dict[str, Any]] = None, - inputs: Optional[Dict[str, Any]] = None, - **kwargs: Any, - ) -> None: - self.events.tool[str(run_id)] = ToolEvent( - params=inputs, - name=serialized.get("name"), - logs={ - "serialized": serialized, - "input_str": input_str, - "metadata": ({} if metadata is None else metadata), - "kwargs": kwargs, - "run_id": run_id, - "parent_run_id": parent_run_id, - "tags": tags, - }, - ) - - @debug_print_function_params - async def on_tool_end( - self, - output: str, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> None: - tool_event: ToolEvent = self.events.tool[str(run_id)] - tool_event.end_timestamp = get_ISO_time() - tool_event.returns = output - - if kwargs.get("name") == "_Exception": - error_event = ErrorEvent( - trigger_event=tool_event, - error_type="LangchainToolException", - details=output, - ) - self.ao_client.record(error_event) - else: - self.ao_client.record(tool_event) - - @debug_print_function_params - async def on_tool_error( - self, - error: BaseException, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> None: - tool_event: ToolEvent = self.events.tool[str(run_id)] - error_event = ErrorEvent(trigger_event=tool_event, exception=error) - self.ao_client.record(error_event) - - @debug_print_function_params - async def on_retriever_start( - self, - serialized: Dict[str, Any], - query: str, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - tags: Optional[List[str]] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs: Any, - ) -> None: - self.events.retriever[str(run_id)] = ActionEvent( - params={ - "serialized": serialized, - "query": query, - "metadata": ({} if metadata is None else metadata), - "kwargs": kwargs, - "run_id": run_id, - "parent_run_id": parent_run_id, - "tags": tags, - }, - action_type="on_retriever_start", - ) - - @debug_print_function_params - async def on_retriever_end( - self, - documents: Sequence[Document], - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - tags: Optional[List[str]] = None, - **kwargs: Any, - ) -> None: - action_event: ActionEvent = self.events.retriever[str(run_id)] - action_event.returns = documents - action_event.end_timestamp = get_ISO_time() - self.ao_client.record(action_event) - - @debug_print_function_params - async def on_retriever_error( - self, - error: BaseException, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - tags: Optional[List[str]] = None, - **kwargs: Any, - ) -> None: - action_event: ActionEvent = self.events.retriever[str(run_id)] - error_event = ErrorEvent(trigger_event=action_event, exception=error) - self.ao_client.record(error_event) - - @debug_print_function_params - async def on_agent_action( - self, - action: AgentAction, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> None: - self.agent_actions[run_id].append( - ActionEvent(params={"action": action, **kwargs}, action_type="on_agent_action") - ) - - @debug_print_function_params - async def on_agent_finish( - self, - finish: AgentFinish, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> None: - self.agent_actions[run_id][-1].returns = finish.to_json() - for agentAction in self.agent_actions[run_id]: - self.ao_client.record(agentAction) - - @debug_print_function_params - async def on_retry( - self, - retry_state: RetryCallState, - *, - run_id: UUID, - parent_run_id: Optional[UUID] = None, - **kwargs: Any, - ) -> None: - action_event = ActionEvent( - params={ - "retry_state": retry_state, - "run_id": run_id, - "parent_run_id": parent_run_id, - "kwargs": kwargs, - }, - action_type="on_retry", - ) - self.ao_client.record(action_event) - - @property - def current_session_ids(self): - return self.ao_client.current_session_ids diff --git a/agentops/partners/taskweaver_event_handler.py b/agentops/partners/taskweaver_event_handler.py deleted file mode 100755 index 18a179d7c..000000000 --- a/agentops/partners/taskweaver_event_handler.py +++ /dev/null @@ -1,191 +0,0 @@ -from taskweaver.module.event_emitter import ( - SessionEventHandlerBase, - SessionEventType, - RoundEventType, - PostEventType, -) -import agentops -from agentops.event import ActionEvent, ErrorEvent, ToolEvent -from datetime import datetime, timezone -from typing import Dict, Any -from agentops.log_config import logger - -ATTACHMENT_TOOLS = [ - "thought", - "reply_type", - "reply_content", - "verification", - "code_error", - "execution_status", - "execution_result", - "artifact_paths", - "revise_message", - "function", - "web_exploring_plan", - "web_exploring_screenshot", - "web_exploring_link", -] - - -class TaskWeaverEventHandler(SessionEventHandlerBase): - def __init__(self): - super().__init__() - self._message_buffer: Dict[str, Dict[str, Any]] = {} - self._attachment_buffer: Dict[str, Dict[str, Any]] = {} - self._active_agents: Dict[str, str] = {} - - def _get_or_create_agent(self, role: str): - """Get existing agent ID or create new agent for role+round combination""" - if role not in self._active_agents: - agent_id = agentops.create_agent(name=role) - if agent_id: - self._active_agents[role] = agent_id - return self._active_agents.get(role) - - def handle_session(self, type: SessionEventType, msg: str, extra: Any, **kwargs: Any): - agentops.record(ActionEvent(action_type=type.value, params={"extra": extra, "message": msg})) - - def handle_round(self, type: RoundEventType, msg: str, extra: Any, round_id: str, **kwargs: Any): - if type == RoundEventType.round_error: - agentops.record( - ErrorEvent(error_type=type.value, details={"round_id": round_id, "message": msg, "extra": extra}) - ) - logger.error(f"Could not record the Round event: {msg}") - self.cleanup_round() - else: - agentops.record( - ActionEvent( - action_type=type.value, - params={"round_id": round_id, "extra": extra}, - returns=msg, - ) - ) - if type == RoundEventType.round_end: - self.cleanup_round() - - def handle_post(self, type: PostEventType, msg: str, extra: Any, post_id: str, round_id: str, **kwargs: Any): - role = extra.get("role", "Planner") - agent_id = self._get_or_create_agent(role=role) - - if type == PostEventType.post_error: - agentops.record( - ErrorEvent( - error_type=type.value, - details={"post_id": post_id, "round_id": round_id, "message": msg, "extra": extra}, - ) - ) - logger.error(f"Could not record the Post event: {msg}") - - elif type == PostEventType.post_start or type == PostEventType.post_end: - agentops.record( - ActionEvent( - action_type=type.value, - params={"post_id": post_id, "round_id": round_id, "extra": extra}, - returns=msg, - agent_id=agent_id, - ) - ) - - elif type == PostEventType.post_status_update: - agentops.record( - ActionEvent( - action_type=type.value, - params={"post_id": post_id, "round_id": round_id, "extra": extra}, - returns=msg, - agent_id=agent_id, - ) - ) - - elif type == PostEventType.post_attachment_update: - attachment_id = extra["id"] - attachment_type = extra["type"].value - is_end = extra["is_end"] - - if attachment_id not in self._attachment_buffer: - self._attachment_buffer[attachment_id] = { - "role": attachment_type, - "content": [], - "init_timestamp": datetime.now(timezone.utc).isoformat(), - "end_timestamp": None, - } - - self._attachment_buffer[attachment_id]["content"].append(str(msg)) - - if is_end: - self._attachment_buffer[attachment_id]["end_timestamp"] = datetime.now(timezone.utc).isoformat() - complete_message = "".join(self._attachment_buffer[attachment_id]["content"]) - - if attachment_type in ATTACHMENT_TOOLS: - agentops.record( - ToolEvent( - name=type.value, - init_timestamp=self._attachment_buffer[attachment_id]["init_timestamp"], - end_timestamp=self._attachment_buffer[attachment_id]["end_timestamp"], - params={ - "post_id": post_id, - "round_id": round_id, - "attachment_id": attachment_id, - "attachment_type": self._attachment_buffer[attachment_id]["role"], - "extra": extra, - }, - returns=complete_message, - agent_id=agent_id, - ) - ) - else: - agentops.record( - ActionEvent( - action_type=type.value, - init_timestamp=self._attachment_buffer[attachment_id]["init_timestamp"], - end_timestamp=self._attachment_buffer[attachment_id]["end_timestamp"], - params={ - "post_id": post_id, - "round_id": round_id, - "attachment_id": attachment_id, - "attachment_type": self._attachment_buffer[attachment_id]["role"], - "extra": extra, - }, - returns=complete_message, - agent_id=agent_id, - ) - ) - - self._attachment_buffer.pop(attachment_id, None) - - elif type == PostEventType.post_message_update: - is_end = extra["is_end"] - - if post_id not in self._message_buffer: - self._message_buffer[post_id] = { - "content": [], - "init_timestamp": datetime.now(timezone.utc).isoformat(), - "end_timestamp": None, - } - - self._message_buffer[post_id]["content"].append(str(msg)) - - if is_end: - self._message_buffer[post_id]["end_timestamp"] = datetime.now(timezone.utc).isoformat() - complete_message = "".join(self._message_buffer[post_id]["content"]) - agentops.record( - ActionEvent( - action_type=type.value, - init_timestamp=self._message_buffer[post_id]["init_timestamp"], - end_timestamp=self._message_buffer[post_id]["end_timestamp"], - params={ - "post_id": post_id, - "round_id": round_id, - "extra": extra, - }, - returns=complete_message, - agent_id=agent_id, - ) - ) - - self._message_buffer.pop(post_id, None) - - def cleanup_round(self): - """Cleanup agents and buffers for a completed round""" - self._active_agents.clear() - self._message_buffer.clear() - self._attachment_buffer.clear() diff --git a/agentops/sdk/descriptors/classproperty.py b/agentops/sdk/descriptors/classproperty.py new file mode 100644 index 000000000..1a731dcd9 --- /dev/null +++ b/agentops/sdk/descriptors/classproperty.py @@ -0,0 +1,30 @@ + + +class ClassPropertyDescriptor(object): + + def __init__(self, fget, fset=None): + self.fget = fget + self.fset = fset + + def __get__(self, obj, klass=None): + if klass is None: + klass = type(obj) + return self.fget.__get__(obj, klass)() + + def __set__(self, obj, value): + if not self.fset: + raise AttributeError("can't set attribute") + type_ = type(obj) + return self.fset.__get__(obj, type_)(value) + + def setter(self, func): + if not isinstance(func, (classmethod, staticmethod)): + func = classmethod(func) + self.fset = func + return self + +def classproperty(func): + if not isinstance(func, (classmethod, staticmethod)): + func = classmethod(func) + + return ClassPropertyDescriptor(func) diff --git a/agentops/sdk/types.py b/agentops/sdk/types.py new file mode 100644 index 000000000..aa6f755f6 --- /dev/null +++ b/agentops/sdk/types.py @@ -0,0 +1,3 @@ +from typing import Annotated + +ISOTimeStamp = Annotated[str, "ISO 8601 formatted timestamp string (e.g. '2023-04-15T12:30:45.123456+00:00')"] diff --git a/agentops/semconv/__init__.py b/agentops/semconv/__init__.py new file mode 100644 index 000000000..710166bee --- /dev/null +++ b/agentops/semconv/__init__.py @@ -0,0 +1,30 @@ +"""AgentOps semantic conventions for spans.""" + +from .span_kinds import SpanKind +from .core import CoreAttributes +from .agent import AgentAttributes +from .tool import ToolAttributes +from .status import ToolStatus +from .workflow import WorkflowAttributes +from .instrumentation import InstrumentationAttributes +from .llm import LLMAttributes +from .enum import LLMRequestTypeValues +from .span_attributes import SpanAttributes +from .meters import Meters +from .span_kinds import AgentOpsSpanKindValues +SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY = "suppress_language_model_instrumentation" +__all__ = [ + "SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY", + "SpanKind", + "CoreAttributes", + "AgentAttributes", + "ToolAttributes", + "ToolStatus", + "WorkflowAttributes", + "InstrumentationAttributes", + "LLMAttributes", + "LLMRequestTypeValues", + "SpanAttributes", + "Meters", + "AgentOpsSpanKindValues" +] diff --git a/agentops/semconv/agent.py b/agentops/semconv/agent.py new file mode 100644 index 000000000..fe1fbe398 --- /dev/null +++ b/agentops/semconv/agent.py @@ -0,0 +1,21 @@ +"""Attributes specific to agent spans.""" + +class AgentAttributes: + """Attributes specific to agent spans.""" + + # Identity + AGENT_ID = "agent.id" # Unique identifier for the agent + AGENT_NAME = "agent.name" # Name of the agent + AGENT_ROLE = "agent.role" # Role of the agent + + # Capabilities + AGENT_TOOLS = "agent.tools" # Tools available to the agent + AGENT_MODELS = "agent.models" # Models available to the agent + + TOOLS = "tools" + HANDOFFS = "handoffs" + FROM_AGENT = "from_agent" + TO_AGENT = "to_agent" + + AGENT_REASONING = "agent.reasoning" + diff --git a/agentops/semconv/core.py b/agentops/semconv/core.py new file mode 100644 index 000000000..50f396253 --- /dev/null +++ b/agentops/semconv/core.py @@ -0,0 +1,18 @@ +"""Core attributes applicable to all spans.""" + +class CoreAttributes: + """Core attributes applicable to all spans.""" + + # Status attributes + ERROR_TYPE = "error.type" # Type of error if status is error + ERROR_MESSAGE = "error.message" # Error message if status is error + + WORKFLOW_NAME = "workflow.name" # Name of the workflow + TRACE_ID = "trace.id" # Trace ID + SPAN_ID = "span.id" # Span ID + PARENT_SPAN_ID = "parent.span.id" # Parent span ID + PARENT_TRACE_ID = "parent.trace.id" # Parent trace ID + PARENT_SPAN_KIND = "parent.span.kind" # Parent span kind + PARENT_SPAN_NAME = "parent.span.name" # Parent span name + PARENT_ID = "parent.id" # Parent ID + diff --git a/agentops/semconv/enum.py b/agentops/semconv/enum.py new file mode 100644 index 000000000..f3dd95c16 --- /dev/null +++ b/agentops/semconv/enum.py @@ -0,0 +1,9 @@ +"""Enum for LLM request types.""" +from enum import Enum + +class LLMRequestTypeValues(Enum): + COMPLETION = "completion" + CHAT = "chat" + RERANK = "rerank" + EMBEDDING = "embedding" + UNKNOWN = "unknown" \ No newline at end of file diff --git a/agentops/semconv/instrumentation.py b/agentops/semconv/instrumentation.py new file mode 100644 index 000000000..da8e5a0e4 --- /dev/null +++ b/agentops/semconv/instrumentation.py @@ -0,0 +1,14 @@ +"""Attributes specific to instrumentation.""" +class InstrumentationAttributes: + """Instrumentation specific attributes.""" + + NAME = "instrumentation.name" # Name of the instrumentation + VERSION = "instrumentation.version" # Version of the instrumentation + + LIBRARY_NAME = "library.name" # Name of the library + LIBRARY_VERSION = "library.version" # Version of the library + + INSTRUMENTATION_TYPE = "instrumentation.type" # Type of instrumentation + INSTRUMENTATION_PROVIDER = "instrumentation.provider" # Provider of the instrumentation + + diff --git a/agentops/semconv/llm.py b/agentops/semconv/llm.py new file mode 100644 index 000000000..3b1e42b73 --- /dev/null +++ b/agentops/semconv/llm.py @@ -0,0 +1,35 @@ +"""Attributes specific to LLM spans.""" + +class LLMAttributes: + """Attributes specific to LLM spans.""" + + # Identity + MODEL_NAME = "llm.model.name" # Name of the LLM model + + # Usage metrics + INPUT_TOKENS = "llm.usage.input_tokens" # Number of input tokens + OUTPUT_TOKENS = "llm.usage.output_tokens" # Number of output tokens + TOTAL_TOKENS = "llm.usage.total_tokens" # Total number of tokens + + # Content + PROMPT = "llm.prompt" # Prompt sent to the LLM + COMPLETION = "llm.completion" # Completion returned by the LLM + INPUT = "llm.input" # Input sent to the LLM + OUTPUT = "llm.output" # Output returned by the LLM + + # Request parameters + TEMPERATURE = "llm.request.temperature" # Temperature parameter + TOP_P = "llm.request.top_p" # Top-p parameter + MAX_TOKENS = "llm.request.max_tokens" # Maximum tokens to generate + FREQUENCY_PENALTY = "llm.request.frequency_penalty" # Frequency penalty + PRESENCE_PENALTY = "llm.request.presence_penalty" # Presence penalty + + # Request metadata + REQUEST_TYPE = "llm.request.type" # Type of request (chat, completion, etc.) + IS_STREAMING = "llm.request.is_streaming" # Whether the request is streaming + + # Response metadata + FINISH_REASON = "llm.response.finish_reason" # Reason for finishing generation + + # System + SYSTEM = "llm.system" # System message or context \ No newline at end of file diff --git a/agentops/semconv/meters.py b/agentops/semconv/meters.py new file mode 100644 index 000000000..bff3bc894 --- /dev/null +++ b/agentops/semconv/meters.py @@ -0,0 +1,21 @@ +"""Metrics for OpenTelemetry semantic conventions.""" + +class Meters: + LLM_GENERATION_CHOICES = "gen_ai.client.generation.choices" + LLM_TOKEN_USAGE = "gen_ai.client.token.usage" + LLM_OPERATION_DURATION = "gen_ai.client.operation.duration" + LLM_COMPLETIONS_EXCEPTIONS = "llm.openai.chat_completions.exceptions" + LLM_STREAMING_TIME_TO_FIRST_TOKEN = ( + "llm.openai.chat_completions.streaming_time_to_first_token" + ) + LLM_STREAMING_TIME_TO_GENERATE = ( + "llm.openai.chat_completions.streaming_time_to_generate" + ) + LLM_EMBEDDINGS_EXCEPTIONS = "llm.openai.embeddings.exceptions" + LLM_EMBEDDINGS_VECTOR_SIZE = "llm.openai.embeddings.vector_size" + LLM_IMAGE_GENERATIONS_EXCEPTIONS = "llm.openai.image_generations.exceptions" + LLM_ANTHROPIC_COMPLETION_EXCEPTIONS = "llm.anthropic.completion.exceptions" + AGENT_RUNS = "agent.runs" + AGENT_TURNS = "agent.turns" + AGENT_EXECUTION_TIME = "agent.execution_time" + LLM_TOKEN_USAGE = "llm.token_usage" \ No newline at end of file diff --git a/agentops/semconv/span_attributes.py b/agentops/semconv/span_attributes.py new file mode 100644 index 000000000..fe9fc097a --- /dev/null +++ b/agentops/semconv/span_attributes.py @@ -0,0 +1,53 @@ +"""Span attributes for OpenTelemetry semantic conventions.""" + +class SpanAttributes: + # Semantic Conventions for LLM requests, this needs to be removed after + # OpenTelemetry Semantic Conventions support Gen AI. + # Issue at https://github.com/open-telemetry/opentelemetry-python/issues/3868 + # Refer to https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-spans.md + # for more detail for LLM spans from OpenTelemetry Community. + LLM_SYSTEM = "gen_ai.system" + LLM_REQUEST_MODEL = "gen_ai.request.model" + LLM_REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens" + LLM_REQUEST_TEMPERATURE = "gen_ai.request.temperature" + LLM_REQUEST_TOP_P = "gen_ai.request.top_p" + LLM_PROMPTS = "gen_ai.prompt" + LLM_COMPLETIONS = "gen_ai.completion" + LLM_RESPONSE_MODEL = "gen_ai.response.model" + LLM_USAGE_COMPLETION_TOKENS = "gen_ai.usage.completion_tokens" + LLM_USAGE_PROMPT_TOKENS = "gen_ai.usage.prompt_tokens" + LLM_USAGE_CACHE_CREATION_INPUT_TOKENS = "gen_ai.usage.cache_creation_input_tokens" + LLM_USAGE_CACHE_READ_INPUT_TOKENS = "gen_ai.usage.cache_read_input_tokens" + LLM_TOKEN_TYPE = "gen_ai.token.type" + # To be added + # LLM_RESPONSE_ID = "gen_ai.response.id" + + # LLM + LLM_REQUEST_TYPE = "llm.request.type" + LLM_USAGE_TOTAL_TOKENS = "llm.usage.total_tokens" + LLM_USAGE_TOKEN_TYPE = "llm.usage.token_type" + LLM_USER = "llm.user" + LLM_HEADERS = "llm.headers" + LLM_IS_STREAMING = "llm.is_streaming" + LLM_FREQUENCY_PENALTY = "llm.frequency_penalty" + LLM_PRESENCE_PENALTY = "llm.presence_penalty" + LLM_REQUEST_FUNCTIONS = "llm.request.functions" + LLM_RESPONSE_FINISH_REASON = "llm.response.finish_reason" + LLM_RESPONSE_STOP_REASON = "llm.response.stop_reason" + LLM_CONTENT_COMPLETION_CHUNK = "llm.content.completion.chunk" + + # OpenAI + LLM_OPENAI_RESPONSE_SYSTEM_FINGERPRINT = "gen_ai.openai.system_fingerprint" + LLM_OPENAI_API_BASE = "gen_ai.openai.api_base" + LLM_OPENAI_API_VERSION = "gen_ai.openai.api_version" + LLM_OPENAI_API_TYPE = "gen_ai.openai.api_type" + + + AGENTOPS_ENTITY_OUTPUT = "agentops.entity.output" + AGENTOPS_ENTITY_INPUT = "agentops.entity.input" + AGENTOPS_SPAN_KIND = "agentops.span.kind" + AGENTOPS_ENTITY_NAME = "agentops.entity.name" + + # Haystack + HAYSTACK_OPENAI_CHAT = "haystack.openai.chat" + HAYSTACK_OPENAI_COMPLETION = "haystack.openai.completion" \ No newline at end of file diff --git a/agentops/semconv/span_kinds.py b/agentops/semconv/span_kinds.py new file mode 100644 index 000000000..7d4cae999 --- /dev/null +++ b/agentops/semconv/span_kinds.py @@ -0,0 +1,24 @@ +"""Span kinds for AgentOps.""" +from enum import Enum +class SpanKind: + """Defines the kinds of spans in AgentOps.""" + # Agent action kinds + AGENT_ACTION = "agent.action" # Agent performing an action + AGENT_THINKING = "agent.thinking" # Agent reasoning/planning + AGENT_DECISION = "agent.decision" # Agent making a decision + + # LLM interaction kinds + LLM_CALL = "llm.call" # LLM API call + LLM_STREAM = "llm.stream" # Streaming LLM response + + # Workflow kinds + WORKFLOW_STEP = "workflow.step" # Step in a workflow + + +class AgentOpsSpanKindValues(Enum): + WORKFLOW = "workflow" + TASK = "task" + AGENT = "agent" + TOOL = "tool" + LLM = "llm" + UNKNOWN = "unknown" diff --git a/agentops/semconv/status.py b/agentops/semconv/status.py new file mode 100644 index 000000000..2bc865e96 --- /dev/null +++ b/agentops/semconv/status.py @@ -0,0 +1,8 @@ +"""Status enumerations for spans.""" +from enum import Enum +class ToolStatus(Enum): + """Tool status values.""" + + EXECUTING = "executing" + SUCCEEDED = "succeeded" + FAILED = "failed" diff --git a/agentops/semconv/tool.py b/agentops/semconv/tool.py new file mode 100644 index 000000000..1b72e760a --- /dev/null +++ b/agentops/semconv/tool.py @@ -0,0 +1,14 @@ +"""Attributes specific to tool spans.""" + +class ToolAttributes: + """Attributes specific to tool spans.""" + + # Identity + TOOL_ID = "tool.id" # Unique identifier for the tool + TOOL_NAME = "tool.name" # Name of the tool + TOOL_DESCRIPTION = "tool.description" # Description of the tool + + # Execution + TOOL_PARAMETERS = "tool.parameters" # Parameters passed to the tool + TOOL_RESULT = "tool.result" # Result returned by the tool + TOOL_STATUS = "tool.status" # Status of tool execution diff --git a/agentops/semconv/workflow.py b/agentops/semconv/workflow.py new file mode 100644 index 000000000..22f2f40db --- /dev/null +++ b/agentops/semconv/workflow.py @@ -0,0 +1,19 @@ +"""Attributes specific to workflow spans.""" + +class WorkflowAttributes: + """Workflow specific attributes.""" + + WORKFLOW_NAME = "workflow.name" # Name of the workflow + WORKFLOW_TYPE = "workflow.type" # Type of workflow + WORKFLOW_INPUT = "workflow.input" # Input to the workflow + WORKFLOW_OUTPUT = "workflow.output" # Output from the workflow + MAX_TURNS = "workflow.max_turns" # Maximum number of turns in a workflow + FINAL_OUTPUT = "workflow.final_output" # Final output of the workflow + + WORKFLOW_STEP_TYPE = "workflow.step.type" # Type of workflow step + WORKFLOW_STEP_NAME = "workflow.step.name" # Name of the workflow step + WORKFLOW_STEP_INPUT = "workflow.step.input" # Input to the workflow step + WORKFLOW_STEP_OUTPUT = "workflow.step.output" # Output from the workflow step + WORKFLOW_STEP_STATUS = "workflow.step.status" # Status of the workflow step + WORKFLOW_STEP_ERROR = "workflow.step.error" # Error from the workflow step + diff --git a/agentops/session.py b/agentops/session.py deleted file mode 100644 index 95d1fba15..000000000 --- a/agentops/session.py +++ /dev/null @@ -1,672 +0,0 @@ -from __future__ import annotations - -import asyncio -import functools -import json -import threading -from datetime import datetime, timezone -from decimal import ROUND_HALF_UP, Decimal -from enum import Enum -from typing import Any, Dict, List, Optional, Sequence, Union -from uuid import UUID, uuid4 - -from opentelemetry import trace -from opentelemetry.context import attach, detach, set_value -from opentelemetry.sdk.resources import SERVICE_NAME, Resource -from opentelemetry.sdk.trace import ReadableSpan, TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter, SpanExporter, SpanExportResult -from termcolor import colored - -from .config import Configuration -from .event import ErrorEvent, Event -from .exceptions import ApiServerException -from .helpers import filter_unjsonable, get_ISO_time, safe_serialize -from .http_client import HttpClient, Response -from .log_config import logger - -""" -OTEL Guidelines: - - - -- Maintain a single TracerProvider for the application runtime - - Have one global TracerProvider in the Client class - -- According to the OpenTelemetry Python documentation, Resource should be initialized once per application and shared across all telemetry (traces, metrics, logs). -- Each Session gets its own Tracer (with session-specific context) -- Allow multiple sessions to share the provider while maintaining their own context - - - -:: Resource - - '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' - Captures information about the entity producing telemetry as Attributes. - For example, a process producing telemetry that is running in a container - on Kubernetes has a process name, a pod name, a namespace, and possibly - a deployment name. All these attributes can be included in the Resource. - '''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''' - - The key insight from the documentation is: - - - Resource represents the entity producing telemetry - in our case, that's the AgentOps SDK application itself - - Session-specific information should be attributes on the spans themselves - - A Resource is meant to identify the service/process/application1 - - Sessions are units of work within that application - - The documentation example about "process name, pod name, namespace" refers to where the code is running, not the work it's doing - -""" - - -class EndState(Enum): - """ - Enum representing the possible end states of a session. - - Attributes: - SUCCESS: Indicates the session ended successfully. - FAIL: Indicates the session failed. - INDETERMINATE (default): Indicates the session ended with an indeterminate state. - This is the default state if not specified, e.g. if you forget to call end_session() - at the end of your program or don't pass it the end_state parameter - """ - - SUCCESS = "Success" - FAIL = "Fail" - INDETERMINATE = "Indeterminate" # Default - - -class SessionExporter(SpanExporter): - """ - Manages publishing events for Session - """ - - def __init__(self, session: Session, **kwargs): - self.session = session - self._shutdown = threading.Event() - self._export_lock = threading.Lock() - super().__init__(**kwargs) - - @property - def endpoint(self): - return f"{self.session.config.endpoint}/v2/create_events" - - def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: - if self._shutdown.is_set(): - return SpanExportResult.SUCCESS - - with self._export_lock: - try: - # Skip if no spans to export - if not spans: - return SpanExportResult.SUCCESS - - events = [] - for span in spans: - event_data = json.loads(span.attributes.get("event.data", "{}")) - - # Format event data based on event type - if span.name == "actions": - formatted_data = { - "action_type": event_data.get("action_type", event_data.get("name", "unknown_action")), - "params": event_data.get("params", {}), - "returns": event_data.get("returns"), - } - elif span.name == "tools": - formatted_data = { - "name": event_data.get("name", event_data.get("tool_name", "unknown_tool")), - "params": event_data.get("params", {}), - "returns": event_data.get("returns"), - } - else: - formatted_data = event_data - - formatted_data = {**event_data, **formatted_data} - # Get timestamps, providing defaults if missing - current_time = datetime.now(timezone.utc).isoformat() - init_timestamp = span.attributes.get("event.timestamp") - end_timestamp = span.attributes.get("event.end_timestamp") - - # Handle missing timestamps - if init_timestamp is None: - init_timestamp = current_time - if end_timestamp is None: - end_timestamp = current_time - - # Get event ID, generate new one if missing - event_id = span.attributes.get("event.id") - if event_id is None: - event_id = str(uuid4()) - - events.append( - { - "id": event_id, - "event_type": span.name, - "init_timestamp": init_timestamp, - "end_timestamp": end_timestamp, - **formatted_data, - "session_id": str(self.session.session_id), - } - ) - - # Only make HTTP request if we have events and not shutdown - if events: - try: - res = HttpClient.post( - self.endpoint, - json.dumps({"events": events}).encode("utf-8"), - api_key=self.session.config.api_key, - jwt=self.session.jwt, - ) - return SpanExportResult.SUCCESS if res.code == 200 else SpanExportResult.FAILURE - except Exception as e: - logger.error(f"Failed to send events: {e}") - return SpanExportResult.FAILURE - - return SpanExportResult.SUCCESS - - except Exception as e: - logger.error(f"Failed to export spans: {e}") - return SpanExportResult.FAILURE - - def force_flush(self, timeout_millis: Optional[int] = None) -> bool: - return True - - def shutdown(self) -> None: - """Handle shutdown gracefully""" - self._shutdown.set() - # Don't call session.end_session() here to avoid circular dependencies - - -class Session: - """ - Represents a session of events, with a start and end state. - - Args: - session_id (UUID): The session id is used to record particular runs. - config (Configuration): The configuration object for the session. - tags (List[str], optional): Tags that can be used for grouping or sorting later. Examples could be ["GPT-4"]. - host_env (dict, optional): A dictionary containing host and environment data. - - Attributes: - init_timestamp (str): The ISO timestamp for when the session started. - end_timestamp (str, optional): The ISO timestamp for when the session ended. Only set after end_session is called. - end_state (str, optional): The final state of the session. Options: "Success", "Fail", "Indeterminate". Defaults to "Indeterminate". - end_state_reason (str, optional): The reason for ending the session. - session_id (UUID): Unique identifier for the session. - tags (List[str]): List of tags associated with the session for grouping and filtering. - video (str, optional): URL to a video recording of the session. - host_env (dict, optional): Dictionary containing host and environment data. - config (Configuration): Configuration object containing settings for the session. - jwt (str, optional): JSON Web Token for authentication with the AgentOps API. - token_cost (Decimal): Running total of token costs for the session. - event_counts (dict): Counter for different types of events: - - llms: Number of LLM calls - - tools: Number of tool calls - - actions: Number of actions - - errors: Number of errors - - apis: Number of API calls - session_url (str, optional): URL to view the session in the AgentOps dashboard. - is_running (bool): Flag indicating if the session is currently active. - """ - - def __init__( - self, - session_id: UUID, - config: Configuration, - tags: Optional[List[str]] = None, - host_env: Optional[dict] = None, - ): - self.end_timestamp = None - self.end_state: Optional[str] = "Indeterminate" - self.session_id = session_id - self.init_timestamp = get_ISO_time() - self.tags: List[str] = tags or [] - self.video: Optional[str] = None - self.end_state_reason: Optional[str] = None - self.host_env = host_env - self.config = config - self.jwt = None - self._lock = threading.Lock() - self._end_session_lock = threading.Lock() - self.token_cost: Decimal = Decimal(0) - self._session_url: str = "" - self.event_counts = { - "llms": 0, - "tools": 0, - "actions": 0, - "errors": 0, - "apis": 0, - } - # self.session_url: Optional[str] = None - - # Start session first to get JWT - self.is_running = self._start_session() - if not self.is_running: - return - - # Initialize OTEL components with a more controlled processor - self._tracer_provider = TracerProvider() - self._otel_tracer = self._tracer_provider.get_tracer( - f"agentops.session.{str(session_id)}", - ) - self._otel_exporter = SessionExporter(session=self) - - # Use smaller batch size and shorter delay to reduce buffering - self._span_processor = BatchSpanProcessor( - self._otel_exporter, - max_queue_size=self.config.max_queue_size, - schedule_delay_millis=self.config.max_wait_time, - max_export_batch_size=min( - max(self.config.max_queue_size // 20, 1), - min(self.config.max_queue_size, 32), - ), - export_timeout_millis=20000, - ) - - self._tracer_provider.add_span_processor(self._span_processor) - - def set_video(self, video: str) -> None: - """ - Sets a url to the video recording of the session. - - Args: - video (str): The url of the video recording - """ - self.video = video - - def _flush_spans(self) -> bool: - """ - Flush pending spans for this specific session with timeout. - Returns True if flush was successful, False otherwise. - """ - if not hasattr(self, "_span_processor"): - return True - - try: - success = self._span_processor.force_flush(timeout_millis=self.config.max_wait_time) - if not success: - logger.warning("Failed to flush all spans before session end") - return success - except Exception as e: - logger.warning(f"Error flushing spans: {e}") - return False - - def end_session( - self, - end_state: str = "Indeterminate", - end_state_reason: Optional[str] = None, - video: Optional[str] = None, - ) -> Union[Decimal, None]: - with self._end_session_lock: - if not self.is_running: - return None - - if not any(end_state == state.value for state in EndState): - logger.warning("Invalid end_state. Please use one of the EndState") - return None - - try: - # Force flush any pending spans before ending session - if hasattr(self, "_span_processor"): - self._span_processor.force_flush(timeout_millis=5000) - - # 1. Set shutdown flag on exporter first - if hasattr(self, "_otel_exporter"): - self._otel_exporter.shutdown() - - # 2. Set session end state - self.end_timestamp = get_ISO_time() - self.end_state = end_state - self.end_state_reason = end_state_reason - if video is not None: - self.video = video - - # 3. Mark session as not running before cleanup - self.is_running = False - - # 4. Clean up OTEL components - if hasattr(self, "_span_processor"): - try: - # Force flush any pending spans - self._span_processor.force_flush(timeout_millis=5000) - # Shutdown the processor - self._span_processor.shutdown() - except Exception as e: - logger.warning(f"Error during span processor cleanup: {e}") - finally: - del self._span_processor - - # 5. Final session update - if not (analytics_stats := self.get_analytics()): - return None - - analytics = ( - f"Session Stats - " - f"{colored('Duration:', attrs=['bold'])} {analytics_stats['Duration']} | " - f"{colored('Cost:', attrs=['bold'])} ${analytics_stats['Cost']} | " - f"{colored('LLMs:', attrs=['bold'])} {analytics_stats['LLM calls']} | " - f"{colored('Tools:', attrs=['bold'])} {analytics_stats['Tool calls']} | " - f"{colored('Actions:', attrs=['bold'])} {analytics_stats['Actions']} | " - f"{colored('Errors:', attrs=['bold'])} {analytics_stats['Errors']}" - ) - logger.info(analytics) - - except Exception as e: - logger.exception(f"Error during session end: {e}") - finally: - active_sessions.remove(self) # First thing, get rid of the session - - logger.info( - colored( - f"\x1b[34mSession Replay: {self.session_url}\x1b[0m", - "blue", - ) - ) - return self.token_cost - - def add_tags(self, tags: List[str]) -> None: - """ - Append to session tags at runtime. - """ - if not self.is_running: - return - - if not (isinstance(tags, list) and all(isinstance(item, str) for item in tags)): - if isinstance(tags, str): - tags = [tags] - - # Initialize tags if None - if self.tags is None: - self.tags = [] - - # Add new tags that don't exist - for tag in tags: - if tag not in self.tags: - self.tags.append(tag) - - # Update session state immediately - self._update_session() - - def set_tags(self, tags): - """Set session tags, replacing any existing tags""" - if not self.is_running: - return - - if not (isinstance(tags, list) and all(isinstance(item, str) for item in tags)): - if isinstance(tags, str): - tags = [tags] - - # Set tags directly - self.tags = tags.copy() # Make a copy to avoid reference issues - - # Update session state immediately - self._update_session() - - def record(self, event: Union[Event, ErrorEvent], flush_now=False): - """Record an event using OpenTelemetry spans""" - if not self.is_running: - return - - # Ensure event has all required base attributes - if not hasattr(event, "id"): - event.id = uuid4() - if not hasattr(event, "init_timestamp"): - event.init_timestamp = get_ISO_time() - if not hasattr(event, "end_timestamp") or event.end_timestamp is None: - event.end_timestamp = get_ISO_time() - - # Create session context - token = set_value("session.id", str(self.session_id)) - - try: - token = attach(token) - - # Create a copy of event data to modify - event_data = dict(filter_unjsonable(event.__dict__)) - - # Add required fields based on event type - if isinstance(event, ErrorEvent): - event_data["error_type"] = getattr(event, "error_type", event.event_type) - elif event.event_type == "actions": - # Ensure action events have action_type - if "action_type" not in event_data: - event_data["action_type"] = event_data.get("name", "unknown_action") - if "name" not in event_data: - event_data["name"] = event_data.get("action_type", "unknown_action") - elif event.event_type == "tools": - # Ensure tool events have name - if "name" not in event_data: - event_data["name"] = event_data.get("tool_name", "unknown_tool") - if "tool_name" not in event_data: - event_data["tool_name"] = event_data.get("name", "unknown_tool") - - with self._otel_tracer.start_as_current_span( - name=event.event_type, - attributes={ - "event.id": str(event.id), - "event.type": event.event_type, - "event.timestamp": event.init_timestamp or get_ISO_time(), - "event.end_timestamp": event.end_timestamp or get_ISO_time(), - "session.id": str(self.session_id), - "session.tags": ",".join(self.tags) if self.tags else "", - "event.data": json.dumps(event_data), - }, - ) as span: - if event.event_type in self.event_counts: - self.event_counts[event.event_type] += 1 - - if isinstance(event, ErrorEvent): - span.set_attribute("error", True) - if hasattr(event, "trigger_event") and event.trigger_event: - span.set_attribute("trigger_event.id", str(event.trigger_event.id)) - span.set_attribute("trigger_event.type", event.trigger_event.event_type) - - if flush_now and hasattr(self, "_span_processor"): - self._span_processor.force_flush() - finally: - detach(token) - - def _send_event(self, event): - """Direct event sending for testing""" - try: - payload = { - "events": [ - { - "id": str(event.id), - "event_type": event.event_type, - "init_timestamp": event.init_timestamp, - "end_timestamp": event.end_timestamp, - "data": filter_unjsonable(event.__dict__), - } - ] - } - - HttpClient.post( - f"{self.config.endpoint}/v2/create_events", - json.dumps(payload).encode("utf-8"), - jwt=self.jwt, - ) - except Exception as e: - logger.error(f"Failed to send event: {e}") - - def _reauthorize_jwt(self) -> Union[str, None]: - with self._lock: - payload = {"session_id": self.session_id} - serialized_payload = json.dumps(filter_unjsonable(payload)).encode("utf-8") - res = HttpClient.post( - f"{self.config.endpoint}/v2/reauthorize_jwt", - serialized_payload, - self.config.api_key, - ) - - logger.debug(res.body) - - if res.code != 200: - return None - - jwt = res.body.get("jwt", None) - self.jwt = jwt - return jwt - - def _start_session(self): - with self._lock: - payload = {"session": self.__dict__} - serialized_payload = json.dumps(filter_unjsonable(payload)).encode("utf-8") - - try: - res = HttpClient.post( - f"{self.config.endpoint}/v2/create_session", - serialized_payload, - api_key=self.config.api_key, - parent_key=self.config.parent_key, - ) - except ApiServerException as e: - return logger.error(f"Could not start session - {e}") - - logger.debug(res.body) - - if res.code != 200: - return False - - jwt = res.body.get("jwt", None) - self.jwt = jwt - if jwt is None: - return False - - logger.info( - colored( - f"\x1b[34mSession Replay: {self.session_url}\x1b[0m", - "blue", - ) - ) - - return True - - def _update_session(self) -> None: - """Update session state on the server""" - if not self.is_running: - return - - # TODO: Determine whether we really need to lock here: are incoming calls coming from other threads? - with self._lock: - payload = {"session": self.__dict__} - - try: - res = HttpClient.post( - f"{self.config.endpoint}/v2/update_session", - json.dumps(filter_unjsonable(payload)).encode("utf-8"), - # self.config.api_key, - jwt=self.jwt, - ) - except ApiServerException as e: - return logger.error(f"Could not update session - {e}") - - def create_agent(self, name, agent_id): - if not self.is_running: - return - if agent_id is None: - agent_id = str(uuid4()) - - payload = { - "id": agent_id, - "name": name, - } - - serialized_payload = safe_serialize(payload).encode("utf-8") - try: - HttpClient.post( - f"{self.config.endpoint}/v2/create_agent", - serialized_payload, - api_key=self.config.api_key, - jwt=self.jwt, - ) - except ApiServerException as e: - return logger.error(f"Could not create agent - {e}") - - return agent_id - - def patch(self, func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - kwargs["session"] = self - return func(*args, **kwargs) - - return wrapper - - def _get_response(self) -> Optional[Response]: - payload = {"session": self.__dict__} - try: - response = HttpClient.post( - f"{self.config.endpoint}/v2/update_session", - json.dumps(filter_unjsonable(payload)).encode("utf-8"), - api_key=self.config.api_key, - jwt=self.jwt, - ) - except ApiServerException as e: - return logger.error(f"Could not end session - {e}") - - logger.debug(response.body) - return response - - def _format_duration(self, start_time, end_time) -> str: - start = datetime.fromisoformat(start_time.replace("Z", "+00:00")) - end = datetime.fromisoformat(end_time.replace("Z", "+00:00")) - duration = end - start - - hours, remainder = divmod(duration.total_seconds(), 3600) - minutes, seconds = divmod(remainder, 60) - - parts = [] - if hours > 0: - parts.append(f"{int(hours)}h") - if minutes > 0: - parts.append(f"{int(minutes)}m") - parts.append(f"{seconds:.1f}s") - - return " ".join(parts) - - def _get_token_cost(self, response: Response) -> Decimal: - token_cost = response.body.get("token_cost", "unknown") - if token_cost == "unknown" or token_cost is None: - return Decimal(0) - return Decimal(token_cost) - - def _format_token_cost(self, token_cost: Decimal) -> str: - return ( - "{:.2f}".format(token_cost) - if token_cost == 0 - else "{:.6f}".format(token_cost.quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)) - ) - - def get_analytics(self) -> Optional[Dict[str, Any]]: - if not self.end_timestamp: - self.end_timestamp = get_ISO_time() - - formatted_duration = self._format_duration(self.init_timestamp, self.end_timestamp) - - if (response := self._get_response()) is None: - return None - - self.token_cost = self._get_token_cost(response) - - return { - "LLM calls": self.event_counts["llms"], - "Tool calls": self.event_counts["tools"], - "Actions": self.event_counts["actions"], - "Errors": self.event_counts["errors"], - "Duration": formatted_duration, - "Cost": self._format_token_cost(self.token_cost), - } - - @property - def session_url(self) -> str: - """Returns the URL for this session in the AgentOps dashboard.""" - assert self.session_id, "Session ID is required to generate a session URL" - return f"https://app.agentops.ai/drilldown?session_id={self.session_id}" - - # @session_url.setter - # def session_url(self, url: str): - # pass - - -active_sessions: List[Session] = [] diff --git a/agentops/session/__init__.py b/agentops/session/__init__.py new file mode 100755 index 000000000..96da2dc33 --- /dev/null +++ b/agentops/session/__init__.py @@ -0,0 +1,87 @@ +"""Session management module for AgentOps. + +A session represents a single execution lifecycle of an agent or application, providing +tracking and monitoring capabilities. Sessions are the core building block for observability +in AgentOps. They can be configured to instrument LLM calls and other events, enhancing +observability through integration with instrumentation modules. + +Key concepts: + - A session begins when your application starts and ends when it completes + - Multiple sessions can run concurrently + - Each session has a unique ID and maintains its own state + - Sessions track various metrics like LLM calls, tool usage, and errors + - Sessions can be configured to instrument LLM calls, providing detailed analytics + +Session States: + - INITIALIZING: Session is being created and configured + - RUNNING: Session is actively executing + - SUCCEEDED: Session completed successfully + - FAILED: Session ended with an error + - INDETERMINATE: Session ended in an unclear state + +Example usage: + ```python + from agentops import Session, Config + + # Create and start a new session + config = Config(api_key="your-key") + session = Session(session_id=uuid4(), config=config) + + # Add custom tags + session.add_tags(["experiment-1", "production"]) + + # Session automatically tracks events + + # End the session with a state + session.end(SessionState.SUCCEEDED) + ``` + +Working with multiple sessions: + - Use get_active_sessions() to list all running sessions + - Each session operates independently with its own state and metrics + - Sessions can be retrieved by ID using get_session_by_id() + - The default session (when only one exists) can be accessed via get_default_session() + +Integration with Instrumentation: + - Sessions can be configured to instrument LLM calls and other events + - Integration with OpenTelemetry for enhanced tracing and observability + +Context Management: + - Sessions can be used as context managers with the 'with' statement + - This ensures proper cleanup even if exceptions occur + - Example: + ```python + with Session(config=config) as session: + # Your code here + # Session will be automatically ended when the block exits + ``` + +Garbage Collection: + - Sessions implement __del__ to ensure proper cleanup during garbage collection + - This prevents data loss when a session is no longer referenced + - The session will be automatically ended with INDETERMINATE state + +See also: + - Session class for detailed session management + - SessionState enum for possible session states + - Registry functions for managing multiple sessions +""" + +from typing import Optional + +from .registry import get_active_sessions, get_default_session, add_session, remove_session + +# Then import core components +from .session import Session, SessionState + +__all__ = [ + "Session", + "SessionState", + "get_active_sessions", + "add_session", + "remove_session", + "current", +] + +# Alias for backward compatibility +current = Session.current diff --git a/agentops/session/base.py b/agentops/session/base.py new file mode 100644 index 000000000..68d7a059f --- /dev/null +++ b/agentops/session/base.py @@ -0,0 +1,53 @@ +from abc import ABC, abstractmethod +from typing import List +from uuid import UUID + +from agentops.config import Config, default_config +from agentops.helpers import get_host_env + + +class SessionBase(ABC): + """Base class for Session""" + + auto_start: bool + host_env: dict + config: Config + tags: List[str] + + def __init__(self, *args, **kwargs): + # Set default values in kwargs + kwargs.setdefault("host_env", get_host_env()) + kwargs.setdefault("config", default_config()) + kwargs.setdefault("auto_start", True) + kwargs.setdefault("tags", []) + + # Assign instance attributes from kwargs + self.host_env = kwargs["host_env"] + self.config = kwargs["config"] + self.auto_start = kwargs["auto_start"] + self.tags = kwargs["tags"] + + @property + def session_url(self) -> str: + """URL to view this trace in the dashboard""" + return f"{self.config.endpoint}/drilldown?session_id={self.session_id}" + + # -------------------------------------------------------------------------- + + @abstractmethod + def start(self): + raise NotImplementedError + + @abstractmethod + def end(self): + raise NotImplementedError + + @property + def session_id(self) -> UUID: + raise NotImplementedError + + def dict(self) -> dict: + raise NotImplementedError + + def json(self) -> str: + raise NotImplementedError diff --git a/agentops/session/exporters.py b/agentops/session/exporters.py new file mode 100644 index 000000000..defb0b590 --- /dev/null +++ b/agentops/session/exporters.py @@ -0,0 +1,89 @@ +# Define a separate class for the authenticated OTLP exporter +# This is imported conditionally to avoid dependency issues +from typing import Dict, Optional, Sequence + +import requests +from opentelemetry.exporter.otlp.proto.http import Compression +from opentelemetry.exporter.otlp.proto.http.trace_exporter import \ + OTLPSpanExporter +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export import SpanExportResult + +from agentops.client.http.http_client import HttpClient +from agentops.exceptions import (AgentOpsApiJwtExpiredException, + ApiServerException) +from agentops.logging import logger + + +class AuthenticatedOTLPExporter(OTLPSpanExporter): + """ + OTLP exporter with JWT authentication support. + + This exporter automatically handles JWT authentication and token refresh + for telemetry data sent to the AgentOps API using a dedicated HTTP session + with authentication retry logic built in. + """ + + def __init__( + self, + endpoint: str, + api_key: str, + headers: Optional[Dict[str, str]] = None, + timeout: Optional[int] = None, + compression: Optional[Compression] = None, + ): + self.api_key = api_key + self._auth_headers = headers or {} + + # Create a dedicated session with authentication handling + self._session = HttpClient.get_authenticated_session(endpoint, api_key) + + # Initialize the parent class + super().__init__( + endpoint=endpoint, + headers=self._auth_headers, # Base headers + timeout=timeout, + compression=compression, + session=self._session, # Use our authenticated session + ) + + def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult: + """ + Export spans with automatic authentication handling + + The authentication and retry logic is now handled by the underlying + HTTP session adapter, so we just need to call the parent export method. + + Args: + spans: The list of spans to export + + Returns: + The result of the export + """ + try: + return super().export(spans) + except AgentOpsApiJwtExpiredException as e: + # Authentication token expired or invalid + logger.warning(f"Authentication error during span export: {e}") + return SpanExportResult.FAILURE + except ApiServerException as e: + # Server-side error + logger.error(f"API server error during span export: {e}") + return SpanExportResult.FAILURE + except requests.RequestException as e: + # Network or HTTP error + logger.error(f"Network error during span export: {e}") + return SpanExportResult.FAILURE + except Exception as e: + # Any other error + logger.error(f"Unexpected error during span export: {e}") + return SpanExportResult.FAILURE + + def clear(self): + """ + Clear any stored spans. + + This method is added for compatibility with test fixtures. + The OTLP exporter doesn't store spans, so this is a no-op. + """ + pass diff --git a/agentops/session/helpers.py b/agentops/session/helpers.py new file mode 100644 index 000000000..431017ec6 --- /dev/null +++ b/agentops/session/helpers.py @@ -0,0 +1,57 @@ +from uuid import UUID + +from opentelemetry.util.types import Attributes, AttributeValue + + +def dict_to_span_attributes(data: dict, prefix: str = "") -> Attributes: + """Convert a dictionary to OpenTelemetry span attributes. + + Follows OpenTelemetry AttributeValue type constraints: + - str + - bool + - int + - float + - Sequence[str] + - Sequence[bool] + - Sequence[int] + - Sequence[float] + + Args: + data: Dictionary to convert + prefix: Optional prefix for attribute names (e.g. "session.") + + Returns: + Dictionary of span attributes with flattened structure + """ + attributes: dict[str, AttributeValue] = {} + + def _flatten(obj, parent_key=""): + if isinstance(obj, dict): + for key, value in obj.items(): + new_key = f"{parent_key}.{key}" if parent_key else key + if prefix: + new_key = f"{prefix}{new_key}" + + if isinstance(value, dict): + _flatten(value, new_key) + elif isinstance(value, (str, bool, int, float)): + attributes[new_key] = value + elif isinstance(value, (list, tuple)): + # Only include sequences if they contain valid types + if value and all(isinstance(x, str) for x in value): + attributes[new_key] = list(value) + elif value and all(isinstance(x, bool) for x in value): + attributes[new_key] = list(value) + elif value and all(isinstance(x, int) for x in value): + attributes[new_key] = list(value) + elif value and all(isinstance(x, float) for x in value): + attributes[new_key] = list(value) + else: + # Convert mixed/unsupported sequences to string + attributes[new_key] = ",".join(str(x) for x in value) + else: + # Convert unsupported types to string + attributes[new_key] = str(value) + + _flatten(data) + return attributes diff --git a/agentops/session/mixin/analytics.py b/agentops/session/mixin/analytics.py new file mode 100644 index 000000000..8f6b21ff5 --- /dev/null +++ b/agentops/session/mixin/analytics.py @@ -0,0 +1,72 @@ +from datetime import datetime +from decimal import ROUND_HALF_UP, Decimal +from typing import Any, Dict, Optional, Union + + +def format_duration(start_time, end_time) -> str: + """Format duration between two timestamps""" + if not start_time or not end_time: + return "0.0s" + + start = datetime.fromisoformat(start_time.replace("Z", "+00:00")) + end = datetime.fromisoformat(end_time.replace("Z", "+00:00")) + duration = end - start + + hours, remainder = divmod(duration.total_seconds(), 3600) + minutes, seconds = divmod(remainder, 60) + + parts = [] + if hours > 0: + parts.append(f"{int(hours)}h") + if minutes > 0: + parts.append(f"{int(minutes)}m") + parts.append(f"{seconds:.1f}s") + + return " ".join(parts) + + +class AnalyticsSessionMixin: + """ + Mixin that adds presentation features to a session + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) if hasattr(super(), '__init__') else None + self.event_counts = {"llms": 0, "tools": 0, "actions": 0, "errors": 0, "apis": 0} + + # ------------------------------------------------------------------------------------------ + @property + def token_cost(self) -> str: + """ + Processes token cost based on the last response from the API. + """ + try: + # Get token cost from either response or direct value + cost = Decimal(0) + if self.api and self.api.last_response is not None: + cost_value = self.api.last_response.json().get("token_cost", "unknown") + if cost_value != "unknown" and cost_value is not None: + cost = Decimal(str(cost_value)) + + # Format the cost + return ( + "{:.2f}".format(cost) + if cost == 0 + else "{:.6f}".format(cost.quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)) + ) + except (ValueError, AttributeError): + return "0.00" + + @property + def analytics(self) -> Optional[Dict[str, Union[int, str]]]: + """Get session analytics""" + formatted_duration = format_duration(self.init_timestamp, self.end_timestamp) + + return { + "LLM calls": self.event_counts["llms"], + "Tool calls": self.event_counts["tools"], + "Actions": self.event_counts["actions"], + "Errors": self.event_counts["errors"], + "Duration": formatted_duration, + "Cost": self.token_cost, + } diff --git a/agentops/session/mixin/registry.py b/agentops/session/mixin/registry.py new file mode 100644 index 000000000..5c9a11641 --- /dev/null +++ b/agentops/session/mixin/registry.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + +from agentops.logging import logger +from agentops.session.registry import add_session, remove_session, set_current_session, get_current_session +from agentops.session.base import SessionBase + +if TYPE_CHECKING: + from agentops.session.session import Session + + +class SessionRegistryMixin(SessionBase): + """ + Mixin that adds registry management functionality to a session. + + This mixin encapsulates the logic for registering and unregistering sessions + from the global session registry, as well as managing the current session context. + """ + + def __init__(self, *args, **kwargs): + """Initialize the registry mixin.""" + # Call parent init + super().__init__(*args, **kwargs) + + def start(self) -> None: + """Register this session in the global registry and set as current.""" + # Register this session for cleanup + add_session(self) + + # Set as current session + set_current_session(self) + + logger.debug(f"[{self.session_id}] Session registered in registry") + + def end(self) -> None: + """Unregister this session from the global registry.""" + # Unregister from cleanup + remove_session(self) + + logger.debug(f"[{self.session_id}] Session unregistered from registry") + + @classmethod + def get_current(cls) -> Optional["Session"]: + """Get the current active session from the registry.""" + return get_current_session() diff --git a/agentops/session/mixin/telemetry.py b/agentops/session/mixin/telemetry.py new file mode 100644 index 000000000..ab50bddaf --- /dev/null +++ b/agentops/session/mixin/telemetry.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Generator, Optional, List +from uuid import UUID + +from opentelemetry.trace import Span, Status, StatusCode + +from agentops.session.base import SessionBase +from agentops.session.state import SessionState +from agentops.session.tracer import SessionTracer + + +def trace_id_to_uuid(trace_id: int) -> UUID: + # Convert the trace_id to a 32-character hex string + trace_id_hex = format(trace_id, "032x") + + # Format as UUID string (8-4-4-4-12) + uuid_str = ( + f"{trace_id_hex[0:8]}-{trace_id_hex[8:12]}-{trace_id_hex[12:16]}-{trace_id_hex[16:20]}-{trace_id_hex[20:32]}" + ) + + # Create UUID object + return UUID(uuid_str) + + +class TracedSession(SessionBase): + _span: Optional[Span] + telemetry: SessionTracer + + @property + def session_id(self): + """Returns the Trace ID as a UUID""" + if self.span: + return trace_id_to_uuid(self.span.get_span_context().trace_id) + return None + + +class TelemetrySessionMixin(TracedSession): + """ + Mixin that adds telemetry and span-related functionality to a session + """ + + _span: Optional[Span] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.telemetry = SessionTracer(self) + self._span = None + + def start_telemetry(self) -> None: + """Start telemetry for the session.""" + if self.telemetry: + self.telemetry.start() + + def shutdown_telemetry(self) -> None: + """Shutdown telemetry for the session.""" + if self.telemetry: + self.telemetry.shutdown() + + def set_status(self, state: SessionState, reason: Optional[str] = None) -> None: + """Update root span status based on session state.""" + if self._span is None: + return + + if state.is_terminal: + if state.name == "SUCCEEDED": + self._span.set_status(Status(StatusCode.OK)) + elif state.name == "FAILED": + self._span.set_status(Status(StatusCode.ERROR)) + else: + self._span.set_status(Status(StatusCode.UNSET)) + + if reason: + self._span.set_attribute("session.end_reason", reason) + + @staticmethod + def _ns_to_iso(ns_time: Optional[int]) -> Optional[str]: + """Convert nanosecond timestamp to ISO format.""" + if ns_time is None: + return None + seconds = ns_time / 1e9 + dt = datetime.fromtimestamp(seconds, tz=timezone.utc) + return dt.isoformat().replace("+00:00", "Z") + + @property + def init_timestamp(self) -> Optional[str]: + """Get the initialization timestamp from the span if available.""" + try: + if self._span and self._span.init_time: + return self._ns_to_iso(self._span.init_time) # type: ignore + except AttributeError: + return None + + @property + def end_timestamp(self) -> Optional[str]: + """Get the end timestamp from the span if available.""" + try: + if self._span and self._span.end_time: + return self._ns_to_iso(self._span.end_time) # type: ignore + except AttributeError: + return None + + @property + def span(self) -> Optional[Span]: + """Get the span from the session.""" + if self._span: + return self._span + return None + + @property + def spans(self) -> Generator[Any, None, None]: + """Generator that yields all spans in the trace.""" + if self.span: + yield self.span + for child in getattr(self.span, "children", []): + yield child diff --git a/agentops/session/processors.py b/agentops/session/processors.py new file mode 100644 index 000000000..fa44090e1 --- /dev/null +++ b/agentops/session/processors.py @@ -0,0 +1,130 @@ +"""Span processors for AgentOps. + +This module provides custom span processors for OpenTelemetry integration. +""" + +from __future__ import annotations + +import threading +from typing import Dict, List, Optional, Protocol + +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult, SpanProcessor + +from agentops.logging import logger + + +class LiveSpanProcessor(SpanProcessor): + """ + Adapted from Prefect's implementation. + (https://github.com/PrefectHQ/prefect/blob/main/src/prefect/telemetry/processors.py) + + Custom span processor that tracks in-flight spans and ensures they are exported + during shutdown or when explicitly requested. + """ + + def __init__(self, exporter: SpanExporter, max_export_batch_size: int = 512, schedule_delay_millis: int = 5000): + """Initialize the LiveSpanProcessor. + + Args: + exporter: The exporter to use for exporting spans + max_export_batch_size: The maximum batch size for exporting spans + schedule_delay_millis: The delay between scheduled exports in milliseconds + """ + self._exporter = exporter + self._max_export_batch_size = max_export_batch_size + self._schedule_delay_millis = schedule_delay_millis + self._lock = threading.Lock() + self._in_flight_spans: Dict[int, ReadableSpan] = {} + self._shutdown = False + + def on_start(self, span: ReadableSpan, parent_context=None) -> None: + """Called when a span starts. + + Args: + span: The span that is starting + parent_context: The parent context for the span + """ + # We don't need to do anything when a span starts + pass + + def on_end(self, span: ReadableSpan) -> None: + """Called when a span ends. Adds the span to in-flight spans. + + Args: + span: The span that is ending + """ + if self._shutdown: + return + + with self._lock: + # Use span_id as the key for the in-flight spans dictionary + self._in_flight_spans[span.context.span_id] = span + + def force_flush(self, timeout_millis: int = 30000) -> bool: + """Force flush all spans to be exported. + + Args: + timeout_millis: The maximum time to wait for the flush to complete in milliseconds + + Returns: + True if the flush was successful, False otherwise + """ + return self._process_spans(export_only=False, timeout_millis=timeout_millis) + + def _process_spans(self, export_only: bool = False, timeout_millis: int = 30000) -> bool: + """Process spans by exporting them and optionally flushing the exporter. + + Args: + export_only: If True, only export spans without flushing the exporter + timeout_millis: The maximum time to wait for the flush to complete in milliseconds + + Returns: + True if the operation was successful, False otherwise. Always returns True + for export_only=True. + """ + # Export all in-flight spans + spans_to_export = [] + with self._lock: + if self._in_flight_spans: + spans_to_export = list(self._in_flight_spans.values()) + self._in_flight_spans.clear() + + if spans_to_export: + try: + result = self._exporter.export(spans_to_export) + if result != SpanExportResult.SUCCESS: + logger.warning(f"Failed to export {len(spans_to_export)} spans: {result}") + except Exception as e: + logger.warning(f"Error exporting spans: {e}") + + # Flush the exporter if requested + if export_only: + return True + + # Try to flush the exporter + try: + return self._exporter.force_flush(timeout_millis) + except AttributeError: + # Exporter doesn't support force_flush, which is fine + return True + except Exception as e: + logger.warning(f"Error flushing exporter: {e}") + return False + + def shutdown(self) -> None: + """Shutdown the processor and export all in-flight spans.""" + with self._lock: + self._shutdown = True + spans_to_export = list(self._in_flight_spans.values()) + self._in_flight_spans.clear() + + if spans_to_export: + try: + result = self._exporter.export(spans_to_export) + if result != SpanExportResult.SUCCESS: + logger.warning(f"Failed to export {len(spans_to_export)} spans: {result}") + except Exception as e: + logger.warning(f"Error exporting spans: {e}") + + self._exporter.shutdown() diff --git a/agentops/session/registry.py b/agentops/session/registry.py new file mode 100644 index 000000000..19baa6cd1 --- /dev/null +++ b/agentops/session/registry.py @@ -0,0 +1,134 @@ +"""Registry for tracking active sessions""" + +import logging +import threading +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast +from uuid import UUID + +from opentelemetry import context, trace + +from agentops.logging import logger + +if TYPE_CHECKING: + from .session import Session + +# Dictionary to store active sessions +_active_sessions: Dict[str, "Session"] = {} +_registry_lock = threading.Lock() + +# Context key for storing the current session +CURRENT_SESSION_KEY = context.create_key("agentops-current-session") + + +def add_session(session: "Session") -> None: + """Add session to active sessions registry.""" + session_id_str = str(session.session_id) + + with _registry_lock: + if session_id_str not in _active_sessions: + _active_sessions[session_id_str] = session + logger.debug(f"[{session_id_str}] Added to registry. Active sessions: {len(_active_sessions)}") + + +def remove_session(session: "Session") -> None: + """Remove session from active sessions registry.""" + session_id_str = str(session.session_id) + + with _registry_lock: + if session_id_str in _active_sessions: + # Use pop to ensure the session is removed even if it's a different instance with the same ID + _active_sessions.pop(session_id_str, None) + logger.debug(f"Removed session {session_id_str} from registry. Active sessions: {len(_active_sessions)}") + + # If this was the current session in the context, clear it + current = get_current_session() + if current is not None and str(current.session_id) == session_id_str: + clear_current_session() + + +def clear_registry() -> None: + """Clear all sessions from registry - primarily for testing""" + with _registry_lock: + logger.debug(f"Clearing registry. Removing {len(_active_sessions)} sessions") + _active_sessions.clear() + + clear_current_session() + + +def get_active_sessions() -> List["Session"]: + """Get list of active sessions""" + with _registry_lock: + return list(_active_sessions.values()) + + +def get_session_by_id(session_id: Union[str, UUID]) -> "Session": + """Get session by ID""" + session_id_str = str(session_id) # Convert UUID to string if needed + + with _registry_lock: + if session_id_str in _active_sessions: + return _active_sessions[session_id_str] + + raise ValueError(f"Session with ID {session_id} not found") + + +def get_default_session() -> Optional["Session"]: + """Get the default session to use when none is specified. + + First tries to get the current session from context. + If no current session is set, returns the only active session if there is exactly one, + otherwise returns None. + """ + # First try to get from context + current = get_current_session() + if current is not None: + return current + + # Fall back to returning the only session if there's exactly one + with _registry_lock: + logger.debug(f"Getting default session. Active sessions: {len(_active_sessions)}") + if len(_active_sessions) == 1: + return next(iter(_active_sessions.values())) + + return None + + +def set_current_session(session: "Session") -> Any: + """Set the current session in the OpenTelemetry context. + + Returns a token that can be used to restore the previous context. + """ + # Add to registry if not already there + add_session(session) + + # Set in context + ctx = context.set_value(CURRENT_SESSION_KEY, session) + token = context.attach(ctx) + logger.debug(f"[{session.session_id}] Set as current session in context") + return token + + +def get_current_session() -> Optional["Session"]: + """Get the current session from the OpenTelemetry context.""" + value = context.get_value(CURRENT_SESSION_KEY) + if value is None: + return None + return cast("Session", value) + + +def clear_current_session() -> None: + """Clear the current session from the OpenTelemetry context.""" + ctx = context.set_value(CURRENT_SESSION_KEY, None) + context.attach(ctx) + logger.debug("Cleared current session from context") + + +# These functions can be used to create context managers for session scope +def use_session(session: "Session") -> Any: + """Context manager to use a specific session within a scope.""" + return set_current_session(session) + + +def end_session_scope(token: Any) -> None: + """End a session scope by detaching the token.""" + context.detach(token) diff --git a/agentops/session/session.py b/agentops/session/session.py new file mode 100644 index 000000000..64977a90f --- /dev/null +++ b/agentops/session/session.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import datetime +import json +import threading +from typing import TYPE_CHECKING, Optional +from uuid import UUID + +from opentelemetry.trace import Status, StatusCode +from termcolor import colored + +from agentops.exceptions import ApiServerException +from agentops.helpers import get_ISO_time +from agentops.helpers.serialization import AgentOpsJSONEncoder +from agentops.helpers.time import iso_to_unix_nano +from agentops.logging import logger +from agentops.sdk.descriptors.classproperty import classproperty + +from .base import SessionBase +from .mixin.analytics import AnalyticsSessionMixin +from .mixin.registry import SessionRegistryMixin +from .mixin.telemetry import TelemetrySessionMixin +from .state import SessionState +from .state import SessionStateDescriptor as session_state_field + +if TYPE_CHECKING: + from agentops.config import Config + + +class Session(SessionRegistryMixin, AnalyticsSessionMixin, TelemetrySessionMixin, SessionBase): + """Data container for session state with minimal public API""" + + def __init__( + self, + *, + config: Config, + **kwargs, + ): + """Initialize a Session with optional session_id.""" + # Pass the config to the base class initialization + # This ensures the config is properly set in kwargs before super().__init__ is called + kwargs["config"] = config + + # Initialize state + self._state = SessionState.INITIALIZING + + # Initialize lock + self._lock = threading.Lock() + + # Set default init_timestamp + self._init_timestamp = datetime.datetime.utcnow().isoformat() + "Z" + + # Initialize mixins and base class + super().__init__(**kwargs) + + # Initialize session only if auto_start is True + if self.auto_start: + self.start() + + def __enter__(self) -> "Session": + """Context manager entry point. + + Returns: + The session instance for use in a with statement. + """ + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + """Context manager exit point. + + Args: + exc_type: The exception type if an exception was raised, None otherwise. + exc_val: The exception value if an exception was raised, None otherwise. + exc_tb: The exception traceback if an exception was raised, None otherwise. + """ + if exc_type is not None: + # End with error state if there was an exception + self.end(SessionState.FAILED) + else: + # End with success state if no exception + self.end(SessionState.SUCCEEDED) + + def __del__(self) -> None: + """Ensure cleanup on garbage collection. + + This method is called by the garbage collector when the object is about to be destroyed. + It ensures that all resources are properly cleaned up if the session hasn't been ended. + """ + try: + # Only perform cleanup if not in a terminal state + if self._state != SessionState.SUCCEEDED and self._state != SessionState.FAILED: + logger.debug(f"[{self.session_id}] Session garbage collected before being ended") + self.end(SessionState.INDETERMINATE) + except Exception as e: + logger.warning(f"Error during Session.__del__: {e}") + + def end(self, state=SessionState.SUCCEEDED): + """End the session with the given state. + + Args: + state: The final state of the session. Defaults to SUCCEEDED. + """ + with self._lock: + # Early return if already in a terminal state + if self._state == SessionState.SUCCEEDED or self._state == SessionState.FAILED: + logger.debug(f"[{self.session_id}] Session already in terminal state: {self._state}") + return + + # Set the state + self._state = state + + # Update span status directly based on state + if self._span: + if state == SessionState.SUCCEEDED: + self._span.set_status(Status(StatusCode.OK)) + elif state == SessionState.FAILED: + self._span.set_status(Status(StatusCode.ERROR)) + else: + self._span.set_status(Status(StatusCode.UNSET)) + + # End the span directly if it hasn't been ended yet and telemetry is not available + if self._span.end_time is None and self.telemetry is None: + self._span.end() + logger.debug(f"[{self.session_id}] Ended span directly") + + # Shutdown telemetry using the mixin method + self.shutdown_telemetry() # TODO: This should be called from the mixin + + # Unregister from cleanup + super().end() + + logger.debug(f"[{self.session_id}] Session ended with state: {state}") + + def start(self): + """Start the session""" + with self._lock: + super().start() + + # Update state + self._state = SessionState.RUNNING + + # Start telemetry using the mixin method + self.start_telemetry() + + logger.debug(f"[{self.session_id}] Session started") + + # Add current function to get default session + @classproperty + def current(cls) -> Optional[Session]: + """Get the current active session. + + Returns: + The current active session if exactly one session exists, otherwise None. + """ + from .registry import get_current_session + + return get_current_session() + + @property + def init_timestamp(self) -> str: + """Get the initialization timestamp.""" + # First try to get it from the span + span_timestamp = super().init_timestamp + # If not available, use our default timestamp + return span_timestamp or self._init_timestamp + + def dict(self) -> dict: + """Convert session to dictionary, excluding private and non-serializable fields""" + return { + "session_id": str(self.session_id), # Explicitly convert UUID to string + "config": self.config.dict(), + "tags": self.tags, + "host_env": self.host_env, + "state": str(self._state), + "init_timestamp": self.init_timestamp, + "end_timestamp": self.end_timestamp, + } + + def json(self): + return json.dumps(self.dict(), cls=AgentOpsJSONEncoder) diff --git a/agentops/session/state.py b/agentops/session/state.py new file mode 100644 index 000000000..c01c179ba --- /dev/null +++ b/agentops/session/state.py @@ -0,0 +1,92 @@ +from dataclasses import field +from enum import Enum, auto +from typing import TYPE_CHECKING, Optional, Union + +from agentops.logging import logger + + +# Custom StrEnum implementation for Python < 3.11 +class StrEnum(str, Enum): + """String enum implementation for Python < 3.11""" + + def __str__(self) -> str: + return self.value + + +if TYPE_CHECKING: + from .session import Session + + +class SessionState(StrEnum): + """Session state enumeration""" + + INITIALIZING = "INITIALIZING" + RUNNING = "RUNNING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + INDETERMINATE = "INITIALIZING" # FIXME: Remove Backward compat. redundancy + + @property + def is_terminal(self) -> bool: + """Whether this is a terminal state""" + return self in (self.FAILED, self.SUCCEEDED, self.INDETERMINATE) + + @property + def is_alive(self) -> bool: + """Whether the session is still active""" + return self in (self.INITIALIZING, self.RUNNING) + + @classmethod + def from_string(cls, state: str) -> "SessionState": + """Convert string to SessionState, with simple aliases""" + state = state.upper() + if state in ("SUCCESS", "SUCCEEDED"): + return cls.SUCCEEDED + if state in ("FAIL", "FAILED"): + return cls.FAILED + try: + return cls[state] # Use direct lookup since it's a StrEnum + except KeyError: + return cls.INDETERMINATE + + +class SessionStateDescriptor: + """Descriptor for managing session state with description""" + + def __init__(self, default_state: SessionState = SessionState.INITIALIZING): + self._default = default_state + + def __set_name__(self, owner, name): + self._state_name = f"_{name}" + self._reason_name = f"_{name}_reason" + + def __get__(self, obj, objtype=None): + """Get the current state""" + if obj is None: + return self._default + + state = getattr(obj, self._state_name, self._default) + reason = getattr(obj, self._reason_name, None) + + if reason: + return f"{state}({reason})" + return state + + def __set__(self, obj: "Session", value: Union[SessionState, str]) -> None: + """Set the state and optionally update reason""" + if isinstance(value, str): + try: + state = SessionState.from_string(value) + except ValueError: + logger.warning(f"Invalid session state: {value}") + state = SessionState.INDETERMINATE + setattr(obj, self._reason_name, f"Invalid state: {value}") + else: + state = value + + setattr(obj, self._state_name, state) + + # Update span status if available + if hasattr(obj, "span"): + reason = getattr(obj, self._reason_name, None) + obj.set_status(state, reason) diff --git a/agentops/session/tracer.py b/agentops/session/tracer.py new file mode 100644 index 000000000..b3674f336 --- /dev/null +++ b/agentops/session/tracer.py @@ -0,0 +1,321 @@ +"""Session tracing module for AgentOps. + +This module provides automatic tracing capabilities for AgentOps sessions. +Each session represents a root span, with all operations within the session +tracked as child spans. +""" + +from __future__ import annotations + +import atexit +import threading +import logging +from typing import TYPE_CHECKING, Dict, Optional, Protocol, Union, Any, Set +from uuid import uuid4 +from weakref import WeakValueDictionary + +from opentelemetry import context, trace +from opentelemetry import metrics +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as gOTLPSpanExporter +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter +from opentelemetry.sdk.resources import SERVICE_NAME, Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.trace.export import BatchSpanProcessor, SimpleSpanProcessor, SpanProcessor +from opentelemetry.trace import NonRecordingSpan, Span, SpanContext, TraceFlags, Status, StatusCode + +from agentops.logging import logger +from agentops.session.base import SessionBase +from agentops.session.helpers import dict_to_span_attributes +from agentops.session.processors import LiveSpanProcessor +from agentops.config import get_config +from agentops.client.http.http_client import HttpClient + +if TYPE_CHECKING: + from agentops.session.mixin.telemetry import TracedSession + from agentops.session.session import Session + +# Dictionary to store active session tracers +_session_tracers: WeakValueDictionary[str, "SessionTracer"] = WeakValueDictionary() + +# Global TracerProvider instance +_tracer_provider: Optional[TracerProvider] = None + +# Global MeterProvider instance +_meter_provider: Optional[MeterProvider] = None + +# Thread-local storage for tokens +_thread_local = threading.local() + + +def get_tracer_provider() -> TracerProvider: + """Get or create the global TracerProvider.""" + global _tracer_provider + if _tracer_provider is None: + # Create resource attributes + resource_attributes = {SERVICE_NAME: "agentops"} + + # Add project_id to resource attributes if available + config = get_config() + if config.project_id: + resource_attributes["agentops.project.id"] = config.project_id + logger.debug(f"Added project_id to resource attributes: {config.project_id}") + elif HttpClient._project_id: + # Fallback to HttpClient._project_id if config.project_id is not set + resource_attributes["agentops.project.id"] = HttpClient._project_id + logger.debug(f"Added project_id to resource attributes from HttpClient: {HttpClient._project_id}") + + _tracer_provider = TracerProvider(resource=Resource(resource_attributes)) + trace.set_tracer_provider(_tracer_provider) + return _tracer_provider + + +def get_meter_provider() -> MeterProvider: + """Get or create the global MeterProvider.""" + global _meter_provider + if _meter_provider is None: + # Create resource attributes + resource_attributes = {SERVICE_NAME: "agentops"} + + # Add project_id to resource attributes if available + config = get_config() + if config.project_id: + resource_attributes["agentops.project.id"] = config.project_id + logger.debug(f"Added project_id to resource attributes for metrics: {config.project_id}") + elif HttpClient._project_id: + # Fallback to HttpClient._project_id if config.project_id is not set + resource_attributes["agentops.project.id"] = HttpClient._project_id + logger.debug(f"Added project_id to resource attributes for metrics from HttpClient: {HttpClient._project_id}") + + # Create a resource with our attributes + resource = Resource(resource_attributes) + + # Get the configured endpoint or use default + config = get_config() + endpoint = config.metrics_exporter_endpoint + if not endpoint: + endpoint = "https://otlp.agentops.cloud/v1/metrics" + + # Create a metric reader for exporting metrics + reader = PeriodicExportingMetricReader( + OTLPMetricExporter(endpoint=endpoint), + export_interval_millis=15000 # Export every 15 seconds + ) + + # Create the meter provider with the reader + _meter_provider = MeterProvider(resource=resource, metric_readers=[reader]) + metrics.set_meter_provider(_meter_provider) + + logger.debug(f"Created global meter provider with endpoint: {endpoint}") + + return _meter_provider + + +def get_session_tracer(session_id: str) -> Optional["SessionTracer"]: + """Get tracer for a session.""" + return _session_tracers.get(str(session_id)) + + +class SessionTracer: + """Core session tracing functionality. + + Handles the session-level tracing context and span management. + A session IS a root span - all operations within the session are automatically + tracked as child spans. + """ + + session: "TracedSession" + + @property + def session_id(self) -> str: + """Get the session ID.""" + return str(self.session.session_id) + + def __init__(self, session: "TracedSession"): + """Initialize the session tracer. + + Args: + session: The session to trace. + """ + self.session = session + self._is_ended = False + self._shutdown_lock = threading.Lock() + self._context = None + self._span_processor = None + + # Initialize thread-local storage for this tracer + if not hasattr(_thread_local, "tokens"): + _thread_local.tokens = {} + + # Use global provider + self.provider = provider = get_tracer_provider() + + # Set up processor and exporter + if session.config.processor is not None: + # Use the custom processor if provided + self._span_processor = session.config.processor + provider.add_span_processor(self._span_processor) + elif session.config.exporter is not None: + # Use the custom exporter with LiveSpanProcessor + self._span_processor = LiveSpanProcessor( + session.config.exporter, + max_export_batch_size=session.config.max_queue_size, + schedule_delay_millis=session.config.max_wait_time, + ) + provider.add_span_processor(self._span_processor) + else: + # Use default processor and exporter + endpoint = ( + session.config.exporter_endpoint + if session.config.exporter_endpoint + else "https://otlp.agentops.cloud/v1/traces" + ) + self._span_processor = LiveSpanProcessor( + OTLPSpanExporter(endpoint=endpoint), + max_export_batch_size=session.config.max_queue_size, + schedule_delay_millis=session.config.max_wait_time, + ) + provider.add_span_processor(self._span_processor) + + def start(self): + # Initialize tracer + self.tracer = self.provider.get_tracer("agentops.session") + + # Create attributes from session data + attributes = dict_to_span_attributes(self.session.dict()) + + # We need to get a proper context for the tracer to use + current_context = context.get_current() + + # Create a new recording span directly + span = self.tracer.start_span("session", attributes=attributes) + + # Manually override the trace_id and span_id inside the span to match our session_id + # Convert UUID to int by removing hyphens and converting hex to int + # session_uuid_hex = str(self.session.session_id).replace("-", "") + # trace_id = int(session_uuid_hex, 16) + # span_id = trace_id & 0xFFFFFFFFFFFFFFFF # Use lower 64 bits for span ID + # + # # Set the span's context to use our trace ID + # # This is a bit of a hack, but it ensures the trace ID matches our session ID + # span_context = span.get_span_context() + # new_context = SpanContext( + # trace_id=trace_id, + # span_id=span_id, + # is_remote=False, + # trace_flags=TraceFlags(TraceFlags.SAMPLED), + # trace_state=span_context.trace_state if hasattr(span_context, "trace_state") else None, + # ) + + # Replace the span's context with our custom context + # span._context = new_context # type: ignore + + # Store the span in the session + self.session._span = span + + # Activate the context + self._context = trace.set_span_in_context(span) + + # Store the token in thread-local storage + thread_id = threading.get_ident() + token = context.attach(self._context) + _thread_local.tokens[f"{self.session_id}_{thread_id}"] = token + + # Store for cleanup + _session_tracers[self.session_id] = self + + logger.debug( + f"[{self.session_id}] Session tracer initialized with recording span: {type(self.session._span).__name__}" + ) + + def _end_session_span(self) -> None: + """End the session span if it exists and hasn't been ended yet.""" + # Use a more direct approach with proper error handling + try: + span = self.session._span + if span is None: + return + + # Try to end the span + span.end() + logger.debug(f"[{self.session_id}] Ended session span") + except Exception as e: + # Log any other errors but don't raise them + logger.debug(f"[{self.session_id}] Note: {e}") + + def shutdown(self) -> None: + """Shutdown and cleanup resources.""" + # Use a direct approach with the lock + with self._shutdown_lock: + # Early return if already ended + if self._is_ended: + return + + logger.debug(f"[{self.session_id}] Shutting down session tracer") + + # Clean up the context if it's active + thread_id = threading.get_ident() + token_key = f"{self.session_id}_{thread_id}" + + if hasattr(_thread_local, "tokens") and token_key in _thread_local.tokens: + try: + context.detach(_thread_local.tokens[token_key]) + del _thread_local.tokens[token_key] + except ValueError as e: + # This can happen if we're in a different thread than the one that created the token + # It's safe to ignore this error as the context will be cleaned up when the thread exits + logger.debug(f"[{self.session_id}] Context token was created in a different thread: {e}") + if token_key in _thread_local.tokens: + del _thread_local.tokens[token_key] + except Exception as e: + logger.debug(f"[{self.session_id}] Error detaching context: {e}") + else: + # This is a different thread than the one that created the token + # We can't detach the token, but we can log a debug message + logger.debug(f"[{self.session_id}] No context token found for thread {thread_id}") + + # End the session span if it exists and hasn't been ended yet + try: + if self.session._span is not None: + # Check if the span has already been ended + if self.session._span.end_time is None: # type: ignore + self.session._span.end() + logger.debug(f"[{self.session_id}] Ended session span") + else: + logger.debug(f"[{self.session_id}] Session span already ended") + except AttributeError: + # Session might not have a span attribute + pass + except Exception as e: + # Log any other errors but don't raise them + logger.debug(f"[{self.session_id}] Note when ending span: {e}") + + # Flush the span processor if available + if self._span_processor: + try: + self._span_processor.force_flush() + logger.debug(f"[{self.session_id}] Flushed span processor") + except Exception as e: + logger.warning(f"[{self.session_id}] Error flushing span processor: {e}") + + # Flush the tracer provider + provider = trace.get_tracer_provider() + if isinstance(provider, TracerProvider): + try: + provider.force_flush() + logger.debug(f"[{self.session_id}] Flushed tracer provider") + except Exception as e: + logger.debug(f"[{self.session_id}] Error during flush: {e}") + + # Mark as ended + self._is_ended = True + logger.debug(f"[{self.session_id}] Session tracer shutdown complete") + + def __del__(self): + """Ensure cleanup on garbage collection.""" + try: + self.shutdown() + except Exception as e: + logger.debug(f"Error during cleanup in __del__: {e}") diff --git a/agentops/singleton.py b/agentops/singleton.py deleted file mode 100644 index b22e4edc1..000000000 --- a/agentops/singleton.py +++ /dev/null @@ -1,28 +0,0 @@ -ao_instances = {} - - -def singleton(class_): - def getinstance(*args, **kwargs): - if class_ not in ao_instances: - ao_instances[class_] = class_(*args, **kwargs) - return ao_instances[class_] - - return getinstance - - -def conditional_singleton(class_): - def getinstance(*args, **kwargs): - use_singleton = kwargs.pop("use_singleton", True) - if use_singleton: - if class_ not in ao_instances: - ao_instances[class_] = class_(*args, **kwargs) - return ao_instances[class_] - else: - return class_(*args, **kwargs) - - return getinstance - - -def clear_singletons(): - global ao_instances - ao_instances = {} diff --git a/agentops/time_travel.py b/agentops/time_travel.py deleted file mode 100644 index 55ad66629..000000000 --- a/agentops/time_travel.py +++ /dev/null @@ -1,144 +0,0 @@ -import json -import yaml -import os -from .http_client import HttpClient -from .exceptions import ApiServerException -from .singleton import singleton - -ttd_prepend_string = "🖇️ Agentops: ⏰ Time Travel |" - - -@singleton -class TimeTravel: - def __init__(self): - self._completion_overrides = {} - - script_dir = os.path.dirname(os.path.abspath(__file__)) - parent_dir = os.path.dirname(script_dir) - cache_path = os.path.join(parent_dir, "agentops_time_travel.json") - - try: - with open(cache_path, "r") as file: - time_travel_cache_json = json.load(file) - self._completion_overrides = time_travel_cache_json.get("completion_overrides") - except FileNotFoundError: - return - - -def fetch_time_travel_id(ttd_id): - try: - endpoint = os.environ.get("AGENTOPS_API_ENDPOINT", "https://api.agentops.ai") - ttd_res = HttpClient.get(f"{endpoint}/v2/ttd/{ttd_id}") - if ttd_res.code != 200: - raise Exception(f"Failed to fetch TTD with status code {ttd_res.code}") - - completion_overrides = { - "completion_overrides": { - ( - str({"messages": item["prompt"]["messages"]}) - if item["prompt"].get("type") == "chatml" - else str(item["prompt"]) - ): item["returns"] - for item in ttd_res.body # TODO: rename returns to completion_override - } - } - with open("agentops_time_travel.json", "w") as file: - json.dump(completion_overrides, file, indent=4) - - set_time_travel_active_state(True) - except ApiServerException as e: - print(f"{ttd_prepend_string} Error - {e}") - except Exception as e: - print(f"{ttd_prepend_string} Error - {e}") - - -def fetch_completion_override_from_time_travel_cache(kwargs): - if not check_time_travel_active(): - return - - if TimeTravel()._completion_overrides: - return find_cache_hit(kwargs["messages"], TimeTravel()._completion_overrides) - - -# NOTE: This is specific to the messages: [{'role': '...', 'content': '...'}, ...] format -def find_cache_hit(prompt_messages, completion_overrides): - if not isinstance(prompt_messages, (list, tuple)): - print( - f"{ttd_prepend_string} Error - unexpected type for prompt_messages. Expected 'list' or 'tuple'. Got ", - type(prompt_messages), - ) - return None - - if not isinstance(completion_overrides, dict): - print( - f"{ttd_prepend_string} Error - unexpected type for completion_overrides. Expected 'dict'. Got ", - type(completion_overrides), - ) - return None - for key, value in completion_overrides.items(): - try: - completion_override_dict = eval(key) - if not isinstance(completion_override_dict, dict): - print( - f"{ttd_prepend_string} Error - unexpected type for completion_override_dict. Expected 'dict'. Got ", - type(completion_override_dict), - ) - continue - - cached_messages = completion_override_dict.get("messages") - if not isinstance(cached_messages, list): - print( - f"{ttd_prepend_string} Error - unexpected type for cached_messages. Expected 'list'. Got ", - type(cached_messages), - ) - continue - - if len(cached_messages) != len(prompt_messages): - continue - - if all( - isinstance(a, dict) and isinstance(b, dict) and a.get("content") == b.get("content") - for a, b in zip(prompt_messages, cached_messages) - ): - return value - except (SyntaxError, ValueError, TypeError) as e: - print(f"{ttd_prepend_string} Error - Error processing completion_overrides item: {e}") - except Exception as e: - print(f"{ttd_prepend_string} Error - Unexpected error in find_cache_hit: {e}") - return None - - -def check_time_travel_active(): - script_dir = os.path.dirname(os.path.abspath(__file__)) - parent_dir = os.path.dirname(script_dir) - config_file_path = os.path.join(parent_dir, ".agentops_time_travel.yaml") - - try: - with open(config_file_path, "r") as config_file: - config = yaml.safe_load(config_file) - return config.get("Time_Travel_Debugging_Active", False) - except FileNotFoundError: - return False - - -def set_time_travel_active_state(is_active: bool): - config_path = ".agentops_time_travel.yaml" - try: - with open(config_path, "r") as config_file: - config = yaml.safe_load(config_file) or {} - except FileNotFoundError: - config = {} - - config["Time_Travel_Debugging_Active"] = is_active - - with open(config_path, "w") as config_file: - try: - yaml.dump(config, config_file) - except: - print(f"{ttd_prepend_string} Error - Unable to write to {config_path}. Time Travel not activated") - return - - if is_active: - print(f"{ttd_prepend_string} Activated") - else: - print(f"{ttd_prepend_string} Deactivated") diff --git a/examples/agents-example/README.md b/examples/agents-example/README.md new file mode 100644 index 000000000..e69820848 --- /dev/null +++ b/examples/agents-example/README.md @@ -0,0 +1,74 @@ +# OpenAI Agents and AgentOps + +AgentOps provides first party support for observing OpenAI's Agents SDK. + +Explore [OpenAI's Agents SDK](https://github.com/openai/openai-agents-python) documentation to get started. + +> [!NOTE] +> If it's your first time developing with AI agents, these examples will help you understand key concepts like agent lifecycle, tool usage, and dynamic system prompts. + +## Getting Started + +You can get OpenAI's Agents SDK working with a few lines of code! + +### 1. Import agentops and openai-agents to your environment + +```python +pip install agentops +pip install openai-agents +``` + +### 2. Setup import statements + +```python +from agents import Agent, Runner +import agentops +import os +from dotenv import load_dotenv +``` + +### 3. Set your API keys + +```python +load_dotenv() +os.environ["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY") or "" +os.environ["AGENTOPS_API_KEY"] = os.getenv("AGENTOPS_API_KEY") or "" +``` + +### 4. Initialize AgentOps + +```python +agentops.init(api_key=os.getenv("AGENTOPS_API_KEY")) +``` + +From here, you have a number of ways you can interact with the Agents SDK! + +## Examples + +> [!NOTE] +> You need to set an API key for both AgentOps and OpenAI! + +## Basic Agents Example + +This example demonstrates how AgentOps automatically instruments the Agents SDK and captures RunResult data with OpenTelemetry semantic conventions. + +Access the example [here](./agents_example.py). + +## Agent Lifecycle Example + +This example shows the complete lifecycle of agents, including: +- Custom agent hooks for monitoring events +- Tool usage with function tools +- Agent handoffs between a starter agent and a multiplier agent +- Structured output using Pydantic models + +Access the example [here](./agent_lifecycle_example.py). + +## Dynamic System Prompt Example + +This example demonstrates how to create agents with dynamic system prompts that change based on context: +- Custom context class to store style information +- Dynamic instructions that adapt based on the context +- Three different response styles: haiku, pirate, and robot + +Access the example [here](./dynamic_system_prompt.py). diff --git a/examples/agents-example/agent_lifecycle_example.py b/examples/agents-example/agent_lifecycle_example.py new file mode 100644 index 000000000..090ae5c2b --- /dev/null +++ b/examples/agents-example/agent_lifecycle_example.py @@ -0,0 +1,116 @@ +import asyncio +import random +import os +os.environ["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY") or "your-openai-api-key" +os.environ["AGENTOPS_API_KEY"] = os.getenv("AGENTOPS_API_KEY") or "your-agentops-api-key" +import agentops +agentops.init( + instrument_llm_calls=True, + log_level="DEBUG", +) +session = agentops.start_session(tags=["math-agent-test", "otel-semantic-conventions"]) + +from typing import Any + +from pydantic import BaseModel + +from agents import Agent, AgentHooks, RunContextWrapper, Runner, Tool, function_tool + + +class CustomAgentHooks(AgentHooks): + def __init__(self, display_name: str): + self.event_counter = 0 + self.display_name = display_name + + async def on_start(self, context: RunContextWrapper, agent: Agent) -> None: + self.event_counter += 1 + print(f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} started") + + async def on_end(self, context: RunContextWrapper, agent: Agent, output: Any) -> None: + self.event_counter += 1 + print( + f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} ended with output {output}" + ) + + async def on_handoff(self, context: RunContextWrapper, agent: Agent, source: Agent) -> None: + self.event_counter += 1 + print( + f"### ({self.display_name}) {self.event_counter}: Agent {source.name} handed off to {agent.name}" + ) + + async def on_tool_start(self, context: RunContextWrapper, agent: Agent, tool: Tool) -> None: + self.event_counter += 1 + print( + f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} started tool {tool.name}" + ) + + async def on_tool_end( + self, context: RunContextWrapper, agent: Agent, tool: Tool, result: str + ) -> None: + self.event_counter += 1 + print( + f"### ({self.display_name}) {self.event_counter}: Agent {agent.name} ended tool {tool.name} with result {result}" + ) + + +### + + +def random_number(max: int) -> int: + return random.randint(0, max) + + +def multiply_by_two(x: int) -> int: + return x * 2 + + +class FinalResult(BaseModel): + number: int + + +multiply_agent = Agent( + name="Multiply Agent", + instructions="Multiply the number by 2 and then return the final result.", + tools=[function_tool(multiply_by_two)], + output_type=FinalResult, + hooks=CustomAgentHooks(display_name="Multiply Agent"), +) + +start_agent = Agent( + name="Start Agent", + instructions="Generate a random number. If it's even, stop. If it's odd, hand off to the multipler agent.", + tools=[function_tool(random_number)], + output_type=FinalResult, + handoffs=[multiply_agent], + hooks=CustomAgentHooks(display_name="Start Agent"), +) + + +async def main() -> None: + user_input = input("Enter a max number: ") + await Runner.run( + start_agent, + input=f"Generate a random number between 0 and {user_input}.", + ) + + print("Done!") + + +if __name__ == "__main__": + asyncio.run(main()) +""" +$ python examples/basic/agent_lifecycle_example.py + +Enter a max number: 250 +### (Start Agent) 1: Agent Start Agent started +### (Start Agent) 2: Agent Start Agent started tool random_number +### (Start Agent) 3: Agent Start Agent ended tool random_number with result 37 +### (Start Agent) 4: Agent Start Agent started +### (Start Agent) 5: Agent Start Agent handed off to Multiply Agent +### (Multiply Agent) 1: Agent Multiply Agent started +### (Multiply Agent) 2: Agent Multiply Agent started tool multiply_by_two +### (Multiply Agent) 3: Agent Multiply Agent ended tool multiply_by_two with result 74 +### (Multiply Agent) 4: Agent Multiply Agent started +### (Multiply Agent) 5: Agent Multiply Agent ended with output number=74 +Done! +""" diff --git a/examples/agents-example/agents_example.py b/examples/agents-example/agents_example.py new file mode 100644 index 000000000..aeb07af51 --- /dev/null +++ b/examples/agents-example/agents_example.py @@ -0,0 +1,56 @@ +import sys +import asyncio +import logging +from agents.agent import Agent +from agents.run import Runner +import os +os.environ["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY") or "your-openai-api-key" +os.environ["AGENTOPS_API_KEY"] = os.getenv("AGENTOPS_API_KEY") or "your-agentops-api-key" +import agentops +agentops.init( + instrument_llm_calls=True, + log_level="DEBUG" +) +# Configure logging +logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +async def test_run_result_capture(): + """ + Simple test for RunResult data capture with OpenTelemetry semantic conventions. + + This test demonstrates how AgentOps automatically instruments the Agents SDK + and captures RunResult data with OpenTelemetry semantic conventions. + """ + logger.info("Starting AgentOps session...") + # Start a session with descriptive tags + session = agentops.start_session(tags=["otel-semantic-conventions-test"]) + + logger.info("Importing Agents SDK...") + + # Create and run a simple agent + logger.info("Running agent...") + agent = Agent( + name="SimpleAgent", + instructions="You are a helpful agent that demonstrates OpenTelemetry semantic conventions." + ) + + # Run the agent - this will be automatically traced with OpenTelemetry semantic conventions + result = await Runner.run(agent, input="Summarize OpenTelemetry in one sentence.") + + logger.info(f"Agent result: {result.final_output}") + logger.info(f"Token usage: {result.raw_responses[0].usage if result.raw_responses else 'N/A'}") + + # End the session + logger.info("Ending AgentOps session...") + agentops.end_session("Success", "OpenTelemetry semantic conventions test completed") + + logger.info("Test completed successfully!") + logger.info("Check the logs above for 'Added OpenTelemetry GenAI attributes' to verify that semantic conventions were applied.") + +if __name__ == "__main__": + # Add the parent directory to the path so we can import our module + sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))) + + # Run the test + asyncio.run(test_run_result_capture()) \ No newline at end of file diff --git a/examples/agents-example/dynamic_system_prompt.py b/examples/agents-example/dynamic_system_prompt.py new file mode 100644 index 000000000..493eaf82b --- /dev/null +++ b/examples/agents-example/dynamic_system_prompt.py @@ -0,0 +1,77 @@ +import asyncio +import random +from typing import Literal +import os +os.environ["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY") or "your-openai-api-key" +os.environ["AGENTOPS_API_KEY"] = os.getenv("AGENTOPS_API_KEY") or "your-agentops-api-key" +import agentops +agentops.init( + instrument_llm_calls=True, + log_level="DEBUG", +) +session = agentops.start_session(tags=["math-agent-test", "otel-semantic-conventions"]) +from agents import Agent, RunContextWrapper, Runner + + +class CustomContext: + def __init__(self, style: Literal["haiku", "pirate", "robot"]): + self.style = style + + +def custom_instructions( + agent: Agent[CustomContext], run_context: RunContextWrapper[CustomContext] +) -> str: + context = run_context.context + if context.style == "haiku": + return "Only respond in haikus." + elif context.style == "pirate": + return "Respond as a pirate." + else: + return "Respond as a robot and say 'beep boop' a lot." + + +agent = Agent( + name="Chat agent", + instructions=custom_instructions, +) + + +async def main(): + choice: Literal["haiku", "pirate", "robot"] = random.choice(["haiku", "pirate", "robot"]) + context = CustomContext(style=choice) + print(f"Using style: {choice}\n") + + user_message = "Tell me a joke." + print(f"User: {user_message}") + output = await Runner.run(agent, user_message, context=context) + + print(f"Assistant: {output.final_output}") + + +if __name__ == "__main__": + asyncio.run(main()) + +""" +$ python examples/basic/dynamic_system_prompt.py + +Using style: haiku + +User: Tell me a joke. +Assistant: Why don't eggs tell jokes? +They might crack each other's shells, +leaving yolk on face. + +$ python examples/basic/dynamic_system_prompt.py +Using style: robot + +User: Tell me a joke. +Assistant: Beep boop! Why was the robot so bad at soccer? Beep boop... because it kept kicking up a debug! Beep boop! + +$ python examples/basic/dynamic_system_prompt.py +Using style: pirate + +User: Tell me a joke. +Assistant: Why did the pirate go to school? + +To improve his arrr-ticulation! Har har har! 🏴‍☠️ +""" diff --git a/examples/basic/basic_tracing.py b/examples/basic/basic_tracing.py new file mode 100644 index 000000000..617da994e --- /dev/null +++ b/examples/basic/basic_tracing.py @@ -0,0 +1,11 @@ +from opentelemetry import trace + +import agentops +from agentops.session import Session + + +def main(): + session = Session(tags=["demo", "basic-tracing"]) + +if __name__ == "__main__": + main() diff --git a/examples/basic/comprehensive_decorators_example.py b/examples/basic/comprehensive_decorators_example.py new file mode 100644 index 000000000..a0499d47d --- /dev/null +++ b/examples/basic/comprehensive_decorators_example.py @@ -0,0 +1,199 @@ +""" +Comprehensive example demonstrating all AgentOps decorators. + +This example shows how to use @session, @agent, @tool, @span, and create_span +to instrument an agent-based application for observability. +""" + +import asyncio +import random +import time +from typing import List, Dict, Any, Optional + +import agentops +from agentops.semconv import SpanKind, AgentAttributes, ToolAttributes + +# Initialize AgentOps with console exporter for demonstration +from opentelemetry.sdk.trace.export import ConsoleSpanExporter, BatchSpanProcessor + +# Initialize AgentOps +processor = BatchSpanProcessor(ConsoleSpanExporter()) +agentops.init( + api_key="your_api_key", # Replace with your actual API key + processor=processor, + instrument_llm_calls=True # Enable LLM instrumentation if you're using OpenAI, etc. +) + +# ===== Tool Definitions ===== + +@agentops.tool( + name="calculator", + description="Performs basic arithmetic operations", + capture_args=True, + capture_result=True +) +def calculate(a: float, b: float, operation: str) -> float: + """Perform a basic arithmetic operation.""" + if operation == "add": + return a + b + elif operation == "subtract": + return a - b + elif operation == "multiply": + return a * b + elif operation == "divide": + if b == 0: + raise ValueError("Cannot divide by zero") + return a / b + else: + raise ValueError(f"Unknown operation: {operation}") + + +@agentops.tool( + name="database_lookup", + description="Simulates a database lookup operation", + attributes={"database.type": "mock"} +) +def database_lookup(query: str) -> Dict[str, Any]: + """Simulate a database lookup operation.""" + # Simulate some processing time + time.sleep(0.2) + + # Use a manual span to track a sub-operation + with agentops.create_span( + name="database_connection", + kind=SpanKind.WORKFLOW_STEP, + attributes={"connection.type": "mock"} + ): + # Simulate connection time + time.sleep(0.1) + + # Return mock data + return { + "id": random.randint(1000, 9999), + "query": query, + "timestamp": time.time() + } + + +# ===== Agent Definition ===== + +@agentops.agent( + name="math_assistant", + role="Perform mathematical operations and database lookups", + tools=["calculator", "database_lookup"], + models=["gpt-4"] +) +class MathAgent: + def __init__(self, user_id: str): + self.user_id = user_id + + @agentops.span(kind=SpanKind.AGENT_ACTION) + def process_calculation(self, a: float, b: float, operation: str) -> Dict[str, Any]: + """Process a calculation request.""" + # Log the request + print(f"Processing calculation: {a} {operation} {b}") + + # Use the calculator tool + try: + result = calculate(a, b, operation) + + # Add a custom event to the span + agentops.add_span_event( + "calculation_completed", + {"operation": operation, "success": True} + ) + + # Add custom attributes to the current span + agentops.add_span_attribute("user.id", self.user_id) + + return { + "result": result, + "operation": operation, + "success": True + } + except ValueError as e: + # The error will be automatically captured in the span + return { + "error": str(e), + "operation": operation, + "success": False + } + + @agentops.span(kind=SpanKind.AGENT_ACTION) + async def process_query(self, query: str) -> Dict[str, Any]: + """Process a query asynchronously.""" + # Log the query + print(f"Processing query: {query}") + + # Parse the query (simplified for example) + parts = query.split() + + if len(parts) >= 3 and parts[1] in ["add", "subtract", "multiply", "divide"]: + try: + a = float(parts[0]) + operation = parts[1] + b = float(parts[2]) + + # Use another span for the reasoning step + with agentops.create_span( + name="agent_reasoning", + kind=SpanKind.AGENT_THINKING, + attributes={ + AgentAttributes.AGENT_REASONING: "Identified a calculation request" + } + ): + # Simulate thinking time + await asyncio.sleep(0.1) + + # Process the calculation + result = self.process_calculation(a, b, operation) + + # Look up additional information + db_result = database_lookup(f"math_{operation}") + + # Combine results + return { + "calculation": result, + "metadata": db_result, + "query_type": "calculation" + } + except (ValueError, IndexError): + return {"error": "Invalid calculation format", "query_type": "unknown"} + else: + # Just do a database lookup for other queries + db_result = database_lookup(query) + return { + "metadata": db_result, + "query_type": "lookup" + } + + +# ===== Main Application ===== + +session = agentops.start_session() +async def main(): + """Main application function wrapped in a session.""" + print("Starting comprehensive decorators example...") + + # Create an agent + agent = MathAgent(user_id="user-123") + + # Process some queries + queries = [ + "5 add 3", + "10 divide 2", + "7 divide 0", # This will cause an error + "what is the weather" + ] + + for query in queries: + print(f"\nProcessing query: {query}") + result = await agent.process_query(query) + print(f"Result: {result}") + + print("\nExample completed!") + + +# Run the example +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/basic/jaeger.compose.yaml b/examples/basic/jaeger.compose.yaml new file mode 100644 index 000000000..074bb4982 --- /dev/null +++ b/examples/basic/jaeger.compose.yaml @@ -0,0 +1,17 @@ +services: + jaeger: + image: jaegertracing/all-in-one:latest + platform: linux/arm64 + ports: + - "6831:6831/udp" # Jaeger thrift compact protocol + - "6832:6832/udp" # Jaeger thrift binary protocol + - "5778:5778" # Jaeger agent configs + - "16686:16686" # Jaeger UI + - "4317:4317" # OTLP gRPC + - "4318:4318" # OTLP HTTP + - "14250:14250" # Jaeger gRPC + - "14268:14268" # Jaeger HTTP thrift + - "9411:9411" # Zipkin compatible endpoint + environment: + - COLLECTOR_ZIPKIN_HOST_PORT=:9411 + - COLLECTOR_OTLP_ENABLED=true diff --git a/examples/basic/using_decorators.py b/examples/basic/using_decorators.py new file mode 100644 index 000000000..ca5b97ee6 --- /dev/null +++ b/examples/basic/using_decorators.py @@ -0,0 +1,7 @@ +import agentops + +@agentops.start_session(tags=["foo", "bar"]) +def foo(): + # Get the current session + current_session = agentops.session.current + # Use current_session here... diff --git a/examples/crewai_examples/test_crewai.py b/examples/crewai_examples/test_crewai.py new file mode 100644 index 000000000..797702603 --- /dev/null +++ b/examples/crewai_examples/test_crewai.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python +import os +from dotenv import load_dotenv +from IPython.core.error import StdinNotImplementedError # only needed by AgentOps testing automation + +import agentops +from crewai import Crew, Agent, Task +from crewai_tools.tools import WebsiteSearchTool, SerperDevTool, FileReadTool +from textwrap import dedent + + +# Load environment variables +load_dotenv() +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") or "" +AGENTOPS_API_KEY = os.getenv("AGENTOPS_API_KEY") or "" +SERPER_API_KEY = os.getenv("SERPER_API_KEY") or "" + + +# Initialize tools +web_search_tool = WebsiteSearchTool() +serper_dev_tool = SerperDevTool() +file_read_tool = FileReadTool( + file_path="job_description_example.md", + description="A tool to read the job description example file.", +) + + +# Define Agents +class Agents: + def research_agent(self): + return Agent( + role="Research Analyst", + goal="Analyze the company website and provided description to extract insights on culture, values, and specific needs.", + tools=[web_search_tool, serper_dev_tool], + backstory="Expert in analyzing company cultures and identifying key values and needs from various sources, including websites and brief descriptions.", + verbose=True, + ) + + def writer_agent(self): + return Agent( + role="Job Description Writer", + goal="Use insights from the Research Analyst to create a detailed, engaging, and enticing job posting.", + tools=[web_search_tool, serper_dev_tool, file_read_tool], + backstory="Skilled in crafting compelling job descriptions that resonate with the company's values and attract the right candidates.", + verbose=True, + ) + + def review_agent(self): + return Agent( + role="Review and Editing Specialist", + goal="Review the job posting for clarity, engagement, grammatical accuracy, and alignment with company values and refine it to ensure perfection.", + tools=[web_search_tool, serper_dev_tool, file_read_tool], + backstory="A meticulous editor with an eye for detail, ensuring every piece of content is clear, engaging, and grammatically perfect.", + verbose=True, + ) + + +# Define Tasks +class Tasks: + def research_company_culture_task(self, agent, company_description, company_domain): + return Task( + description=dedent( + f""" + Analyze the provided company website and the hiring manager's company's domain {company_domain}, description: "{company_description}". + Focus on understanding the company's culture, values, and mission. Identify unique selling points and + specific projects or achievements highlighted on the site. Compile a report summarizing these insights, + specifically how they can be leveraged in a job posting to attract the right candidates. + """ + ), + expected_output=dedent( + """ + A comprehensive report detailing the company's culture, values, and mission, + along with specific selling points relevant to the job role. + Suggestions on incorporating these insights into the job posting should be included. + """ + ), + agent=agent, + ) + + def research_role_requirements_task(self, agent, hiring_needs): + return Task( + description=dedent( + f""" + Based on the hiring manager's needs: "{hiring_needs}", identify the key skills, experiences, + and qualities the ideal candidate should possess for the role. Consider the company's current projects, + its competitive landscape, and industry trends. Prepare a list of recommended job requirements and + qualifications that align with the company's needs and values. + """ + ), + expected_output=dedent( + """ + A list of recommended skills, experiences, and qualities for the ideal candidate, + aligned with the company's culture, ongoing projects, and the specific role's requirements. + """ + ), + agent=agent, + ) + + def draft_job_posting_task( + self, agent, company_description, hiring_needs, specific_benefits + ): + return Task( + description=dedent( + f""" + Draft a job posting for the role described by the hiring manager: "{hiring_needs}". + Use the insights on "{company_description}" to start with a compelling introduction, followed by a + detailed role description, responsibilities, and required skills and qualifications. Ensure the tone + aligns with the company's culture and incorporate any unique benefits or opportunities offered by the company. + Specific benefits: "{specific_benefits}" + """ + ), + expected_output=dedent( + """ + A detailed, engaging job posting that includes an introduction, role description, responsibilities, + requirements, and unique company benefits. The tone should resonate with the company's culture and values, + aimed at attracting the right candidates. + """ + ), + agent=agent, + ) + + def review_and_edit_job_posting_task(self, agent, hiring_needs): + return Task( + description=dedent( + f""" + Review the draft job posting for the role: "{hiring_needs}". Check for clarity, engagement, grammatical accuracy, + and alignment with the company's culture and values. Edit and refine the content, ensuring it speaks directly + to the desired candidates and accurately reflects the role's unique benefits and opportunities. Provide feedback + for any necessary revisions. + """ + ), + expected_output=dedent( + """ + A polished, error-free job posting that is clear, engaging, and perfectly aligned with the company's culture and values. + Feedback on potential improvements and final approval for publishing. Formatted in markdown. + """ + ), + agent=agent, + output_file="job_posting.md", + ) + + def industry_analysis_task(self, agent, company_domain, company_description): + return Task( + description=dedent( + f""" + Conduct an in-depth analysis of the industry related to the company's domain: "{company_domain}". Investigate current trends, + challenges, and opportunities within the industry, utilizing market reports, recent developments, and expert opinions. Assess + how these factors could impact the role being hired for and the overall attractiveness of the position to potential candidates. + Consider how the company's position within this industry and its response to these trends could be leveraged to attract top talent. + Include in your report how the role contributes to addressing industry challenges or seizing opportunities. + """ + ), + expected_output=dedent( + """ + A detailed analysis report that identifies major industry trends, challenges, and opportunities relevant to the company's domain + and the specific job role. This report should provide strategic insights on positioning the job role and the company + as an attractive choice for potential candidates. + """ + ), + agent=agent, + ) + + +def main(): + # Initialize AgentOps with default tags + agentops.start_session() + + # Gather user input + company_description = input("What is the company description?\n") + company_domain = input("What is the company domain?\n") + hiring_needs = input("What are the hiring needs?\n") + specific_benefits = input("What are specific benefits you offer?\n") + + # Instantiate agents and tasks + tasks = Tasks() + agents = Agents() + + researcher_agent = agents.research_agent() + writer_agent = agents.writer_agent() + review_agent = agents.review_agent() + + research_company_culture_task = tasks.research_company_culture_task( + researcher_agent, company_description, company_domain + ) + industry_analysis_task = tasks.industry_analysis_task( + researcher_agent, company_domain, company_description + ) + research_role_requirements_task = tasks.research_role_requirements_task( + researcher_agent, hiring_needs + ) + draft_job_posting_task = tasks.draft_job_posting_task( + writer_agent, company_description, hiring_needs, specific_benefits + ) + review_and_edit_job_posting_task = tasks.review_and_edit_job_posting_task( + review_agent, hiring_needs + ) + + # Create the Crew and define the sequence of tasks + crew = Crew( + agents=[researcher_agent, writer_agent, review_agent], + tasks=[ + research_company_culture_task, + industry_analysis_task, + research_role_requirements_task, + draft_job_posting_task, + review_and_edit_job_posting_task, + ], + ) + + # Kick off the process + try: + result = crew.kickoff() + except StdinNotImplementedError: + # This is only necessary for AgentOps testing automation which is headless + # and will not have user input + print("Stdin not implemented. Skipping kickoff()") + agentops.end_session("Indeterminate") + return + + print("Job Posting Creation Process Completed.") + print("Final Job Posting:") + print(result) + + agentops.end_session("Success") + + +if __name__ == "__main__": + main() + breakpoint() diff --git a/third_party/opentelemetry/instrumentation/agents/README.md b/third_party/opentelemetry/instrumentation/agents/README.md new file mode 100644 index 000000000..5ffcb169a --- /dev/null +++ b/third_party/opentelemetry/instrumentation/agents/README.md @@ -0,0 +1,94 @@ +# AgentOps Instrumentor for OpenAI Agents SDK + +This package provides automatic instrumentation for the OpenAI Agents SDK using AgentOps. It captures detailed telemetry data from agent runs, including spans, metrics, and context information. + +## Features + +- **Automatic Instrumentation**: Instruments the Agents SDK automatically when imported +- **Comprehensive Span Capture**: Captures all spans from the Agents SDK, including: + - Agent spans + - Function spans + - Generation spans + - Handoff spans + - Response spans + - Custom spans +- **Detailed Metrics**: Collects key metrics such as: + - Token usage (input/output) + - Agent execution time + - Number of agent runs and turns +- **Hybrid Approach**: Combines a custom processor with monkey patching for complete coverage +- **Seamless Integration**: Works with both AgentOps and the Agents SDK's native tracing system + +## Installation + +The instrumentor is included with the AgentOps package. Simply install AgentOps: + +```bash +pip install agentops +``` + +## Usage + +Using the instrumentor is simple - just import it after initializing AgentOps: + +```python +# Initialize AgentOps +import agentops +agentops.init( + instrument_llm_calls=True, + log_level="DEBUG" +) + +# Import the instrumentor - this will automatically instrument the Agents SDK +from opentelemetry.instrumentation.agents import AgentsInstrumentor + +# Ensure the instrumentor is registered +instrumentor = AgentsInstrumentor() +instrumentor.instrument() + +# Now use the Agents SDK as normal +from agents import Agent, Runner + +# Create and run your agents +agent = Agent(name="MyAgent", instructions="You are a helpful assistant.") +result = await Runner.run(agent, "Hello, world!") +``` + +## Example + +See the `agents_instrumentation_example.py` file for a complete example of how to use the instrumentor. + +## How It Works + +The instrumentor uses two complementary approaches to capture telemetry data: + +1. **Custom Processor**: Registers a custom processor with the Agents SDK's tracing system to capture all spans and traces generated by the SDK. + +2. **Monkey Patching**: Patches key methods in the Agents SDK to capture additional information that might not be available through the tracing system. + +This hybrid approach ensures comprehensive coverage of all agent activities. + +## Span Types + +The instrumentor captures the following span types: + +- **Trace**: The root span representing an entire agent workflow execution +- **Agent**: Represents an agent's execution lifecycle +- **Function**: Represents a tool/function call +- **Generation**: Captures details of model generation +- **Response**: Lightweight span for tracking model response IDs +- **Handoff**: Represents control transfer between agents +- **Custom**: User-defined spans for custom operations + +## Metrics + +The instrumentor collects the following metrics: + +- **Agent Runs**: Number of agent runs +- **Agent Turns**: Number of agent turns +- **Agent Execution Time**: Time taken for agent execution +- **Token Usage**: Number of input and output tokens used + +## License + +MIT \ No newline at end of file diff --git a/third_party/opentelemetry/instrumentation/agents/__init__.py b/third_party/opentelemetry/instrumentation/agents/__init__.py new file mode 100644 index 000000000..2cac4e006 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/agents/__init__.py @@ -0,0 +1,22 @@ +"""OpenTelemetry instrumentation for OpenAI Agents SDK. + +This module provides automatic instrumentation for the OpenAI Agents SDK when imported. +It captures detailed telemetry data from agent runs, including spans, metrics, and context information. +""" + +from typing import Collection + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor + +from .agentops_agents_instrumentor import ( + AgentsInstrumentor, + AgentsDetailedProcessor, + AgentsDetailedExporter, + __version__ +) + +__all__ = [ + "AgentsInstrumentor", + "AgentsDetailedProcessor", + "AgentsDetailedExporter", +] \ No newline at end of file diff --git a/third_party/opentelemetry/instrumentation/agents/agentops_agents_demo.py b/third_party/opentelemetry/instrumentation/agents/agentops_agents_demo.py new file mode 100644 index 000000000..008f1b736 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/agents/agentops_agents_demo.py @@ -0,0 +1,284 @@ +""" +Comprehensive demo script showcasing the AgentOps instrumentation for the OpenAI Agents SDK. + +This script demonstrates how AgentOps captures detailed telemetry from the Agents SDK, +including traces, spans, and metrics from agent runs, handoffs, and tool usage. +""" + +import asyncio +import os +os.environ["OPENAI_API_KEY"] = os.environ.get("OPENAI_API_KEY", "your-api-key-here") +os.environ["AGENTOPS_API_KEY"] = os.environ.get("AGENTOPS_API_KEY", "your-api-key-here") +import logging +import random +from typing import Any, Dict + +# Configure logging +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +# Import the AgentsInstrumentor +from opentelemetry.instrumentation.agents import AgentsInstrumentor + +# Register the instrumentor +AgentsInstrumentor().instrument() + +# Initialize AgentOps +import agentops +agentops.init( + exporter_endpoint="https://otlp.agentops.cloud/v1/traces", + endpoint="https://api.agentops.cloud" +) + +# Import Agents SDK components +from agents import ( + Agent, + Runner, + WebSearchTool, + function_tool, + handoff, + trace, + HandoffInputData +) +from agents.extensions import handoff_filters + +# Define tools for our agents +@function_tool +def random_number_tool(min: int, max: int) -> int: + """Generate a random number between min and max (inclusive).""" + return random.randint(min, max) + +@function_tool +def calculate_tool(operation: str, a: float, b: float) -> float: + """Perform a mathematical operation on two numbers. + + Args: + operation: One of 'add', 'subtract', 'multiply', 'divide' + a: First number + b: Second number + + Returns: + The result of the operation + """ + if operation == "add": + return a + b + elif operation == "subtract": + return a - b + elif operation == "multiply": + return a * b + elif operation == "divide": + if b == 0: + raise ValueError("Cannot divide by zero") + return a / b + else: + raise ValueError(f"Unknown operation: {operation}") + +# Define a message filter for handoffs +def message_filter(handoff_message_data: HandoffInputData) -> HandoffInputData: + # Remove tool-related messages + handoff_message_data = handoff_filters.remove_all_tools(handoff_message_data) + + # Keep only the last 3 messages for demonstration + history = handoff_message_data.input_history + if len(history) > 3: + history = history[-3:] + + return HandoffInputData( + input_history=history, + pre_handoff_items=tuple(handoff_message_data.pre_handoff_items), + new_items=tuple(handoff_message_data.new_items), + ) + +# Define a content moderation function +@function_tool +def content_moderation(text: str) -> Dict[str, Any]: + """Check if the content contains any forbidden words. + + Args: + text: The text to check + + Returns: + A dictionary with 'allowed' and optionally 'reason' keys + """ + forbidden_words = ["hate", "violence", "illegal"] + for word in forbidden_words: + if word in text.lower(): + return { + "allowed": False, + "reason": f"Content contains forbidden word: {word}" + } + return {"allowed": True} + +# Define our agents +def create_agents(): + # Main assistant agent with tools + assistant_agent = Agent( + name="Assistant", + instructions="You are a helpful, friendly assistant. You provide concise and accurate responses.", + tools=[random_number_tool, calculate_tool, WebSearchTool()], + ) + + # Specialized math agent + math_agent = Agent( + name="Math Expert", + instructions="You are a mathematics expert. Provide detailed explanations for math problems.", + tools=[calculate_tool], + handoff_description="A specialized mathematics expert for complex calculations and explanations." + ) + + # Creative writing agent + creative_agent = Agent( + name="Creative Writer", + instructions="You are a creative writer. Write engaging, imaginative content in response to prompts.", + handoff_description="A creative writer for generating stories, poems, and other creative content." + ) + + # Router agent that can hand off to specialized agents + router_agent = Agent( + name="Router", + instructions=( + "You are a router assistant that directs users to specialized agents based on their needs. " + "For math questions, hand off to the Math Expert. " + "For creative writing requests, hand off to the Creative Writer. " + "For general questions, answer directly or use tools as needed. " + "If a user asks about inappropriate topics like hate, violence, or illegal activities, " + "use the content_moderation tool to check if the content is allowed, and if not, " + "politely decline to answer and explain why." + ), + tools=[random_number_tool, WebSearchTool(), content_moderation], + handoffs=[ + handoff(math_agent, input_filter=message_filter), + handoff(creative_agent, input_filter=message_filter) + ] + ) + + return assistant_agent, math_agent, creative_agent, router_agent + +async def demo_basic_agent(): + logger.info("🚀 DEMO: Basic Agent with Tools") + + assistant_agent, _, _, _ = create_agents() + + with trace("Basic Agent Demo"): + # Simple question + result = await Runner.run( + assistant_agent, + "What's the capital of France?" + ) + logger.info(f"Question: What's the capital of France?") + logger.info(f"Answer: {result.final_output}") + + # Using the random number tool + result = await Runner.run( + assistant_agent, + input=result.to_input_list() + [ + {"content": "Generate a random number between 1 and 100.", "role": "user"} + ] + ) + logger.info(f"Question: Generate a random number between 1 and 100.") + logger.info(f"Answer: {result.final_output}") + + # Using the calculate tool + result = await Runner.run( + assistant_agent, + input=result.to_input_list() + [ + {"content": "Calculate 234 * 456", "role": "user"} + ] + ) + logger.info(f"Question: Calculate 234 * 456") + logger.info(f"Answer: {result.final_output}") + + # Using web search + result = await Runner.run( + assistant_agent, + input=result.to_input_list() + [ + {"content": "What's the current population of Tokyo?", "role": "user"} + ] + ) + logger.info(f"Question: What's the current population of Tokyo?") + logger.info(f"Answer: {result.final_output}") + +async def demo_agent_handoffs(): + logger.info("🚀 DEMO: Agent Handoffs") + + _, _, _, router_agent = create_agents() + + with trace("Agent Handoffs Demo"): + # Start with a general question + result = await Runner.run( + router_agent, + "Hello, can you help me with some questions?" + ) + logger.info(f"Question: Hello, can you help me with some questions?") + logger.info(f"Answer: {result.final_output}") + + # Math question that should trigger handoff to math agent + result = await Runner.run( + router_agent, + input=result.to_input_list() + [ + {"content": "Can you explain the Pythagorean theorem and calculate the hypotenuse of a triangle with sides 3 and 4?", "role": "user"} + ] + ) + logger.info(f"Question: Can you explain the Pythagorean theorem and calculate the hypotenuse of a triangle with sides 3 and 4?") + logger.info(f"Answer: {result.final_output}") + + # Creative writing request that should trigger handoff to creative agent + result = await Runner.run( + router_agent, + input=result.to_input_list() + [ + {"content": "Write a short poem about artificial intelligence.", "role": "user"} + ] + ) + logger.info(f"Question: Write a short poem about artificial intelligence.") + logger.info(f"Answer: {result.final_output}") + +async def demo_content_moderation(): + logger.info("🚀 DEMO: Content Moderation") + + _, _, _, router_agent = create_agents() + + with trace("Content Moderation Demo"): + # Normal question + result = await Runner.run( + router_agent, + "Tell me about renewable energy sources." + ) + logger.info(f"Question: Tell me about renewable energy sources.") + logger.info(f"Answer: {result.final_output}") + + # Question with forbidden content that should trigger moderation + result = await Runner.run( + router_agent, + input=result.to_input_list() + [ + {"content": "How can I promote hate speech online?", "role": "user"} + ] + ) + logger.info(f"Question: How can I promote hate speech online?") + logger.info(f"Answer: {result.final_output}") + +async def main(): + # Start an AgentOps session + session = agentops.start_session(tags=["agents-sdk-comprehensive-demo"]) + + logger.info("🔍 AGENTS SDK WITH AGENTOPS DEMO") + + # Run the demos + await demo_basic_agent() + await demo_agent_handoffs() + await demo_content_moderation() + + logger.info("🎉 DEMO COMPLETED") + logger.info("Check your AgentOps dashboard to see the traces and spans captured from this run.") + +if __name__ == "__main__": + # Check for OpenAI API key + if not os.environ.get("OPENAI_API_KEY"): + logger.error("Please set your OPENAI_API_KEY environment variable") + exit(1) + + # Check for AgentOps API key + if not os.environ.get("AGENTOPS_API_KEY"): + logger.warning("AGENTOPS_API_KEY not set. Some features may not work properly.") + + # Run the demo + asyncio.run(main()) diff --git a/third_party/opentelemetry/instrumentation/agents/agentops_agents_instrumentor.py b/third_party/opentelemetry/instrumentation/agents/agentops_agents_instrumentor.py new file mode 100644 index 000000000..a25932e38 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/agents/agentops_agents_instrumentor.py @@ -0,0 +1,871 @@ +""" +AgentOps Instrumentor for OpenAI Agents SDK + +This module provides automatic instrumentation for the OpenAI Agents SDK when AgentOps is imported. +It combines a custom processor approach with monkey patching to capture all relevant spans and metrics. +""" + +import asyncio +import functools +import inspect +import logging +import time +import json +from typing import Any, Collection, Dict, List, Optional, Union + +# OpenTelemetry imports +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.trace import get_tracer, SpanKind, Status, StatusCode +from opentelemetry.metrics import get_meter + +# AgentOps imports +from agentops.semconv import ( + SpanKind as AOSpanKind, + CoreAttributes, + WorkflowAttributes, + InstrumentationAttributes, + AgentAttributes, + LLMAttributes, + ToolAttributes, + SpanAttributes, + AgentOpsSpanKindValues, + Meters +) +from agentops.session.tracer import get_tracer_provider +from agentops.session.processors import LiveSpanProcessor +from agentops.session.exporters import OTLPSpanExporter +from agentops.session.helpers import dict_to_span_attributes + +# Agents SDK imports +from agents.tracing.processor_interface import TracingProcessor as AgentsTracingProcessor +from agents.tracing.spans import Span as AgentsSpan +from agents.tracing.traces import Trace as AgentsTrace +from agents import add_trace_processor +from agents.run import RunConfig +from agents.lifecycle import RunHooks + +# Version +__version__ = "0.1.0" + +logger = logging.getLogger(__name__) + +# Global metrics objects +_agent_run_counter = None +_agent_turn_counter = None +_agent_execution_time_histogram = None +_agent_token_usage_histogram = None + + +def safe_execute(func): + """Decorator to safely execute a function and log any exceptions.""" + @functools.wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + logger.exception(f"Error in {func.__name__}: {e}") + return None + return wrapper + + +@safe_execute +def get_model_info(agent: Any, run_config: Any = None) -> Dict[str, Any]: + """Extract model information from agent and run_config.""" + logger.info(f"[DEBUG] get_model_info called with agent: {agent}, run_config: {run_config}") + + result = {"model_name": "unknown"} + + # First check run_config.model (highest priority) + if run_config and hasattr(run_config, "model") and run_config.model: + if isinstance(run_config.model, str): + result["model_name"] = run_config.model + logger.info(f"[DEBUG] Found model name from run_config.model (string): {result['model_name']}") + elif hasattr(run_config.model, "model") and run_config.model.model: + # For Model objects that have a model attribute + result["model_name"] = run_config.model.model + logger.info(f"[DEBUG] Found model name from run_config.model.model: {result['model_name']}") + + # Then check agent.model if we still have unknown + if result["model_name"] == "unknown" and hasattr(agent, "model") and agent.model: + if isinstance(agent.model, str): + result["model_name"] = agent.model + logger.info(f"[DEBUG] Found model name from agent.model (string): {result['model_name']}") + elif hasattr(agent.model, "model") and agent.model.model: + # For Model objects that have a model attribute + result["model_name"] = agent.model.model + logger.info(f"[DEBUG] Found model name from agent.model.model: {result['model_name']}") + + # Check for default model from OpenAI provider + if result["model_name"] == "unknown": + # Try to import the default model from the SDK + try: + from agents.models.openai_provider import DEFAULT_MODEL + result["model_name"] = DEFAULT_MODEL + logger.info(f"[DEBUG] Using default model from OpenAI provider: {result['model_name']}") + except ImportError: + logger.info("[DEBUG] Could not import DEFAULT_MODEL from agents.models.openai_provider") + + # Extract model settings from agent + if hasattr(agent, "model_settings") and agent.model_settings: + model_settings = agent.model_settings + logger.info(f"[DEBUG] Found agent.model_settings: {model_settings}") + + # Extract model parameters + for param in ["temperature", "top_p", "frequency_penalty", "presence_penalty"]: + if hasattr(model_settings, param) and getattr(model_settings, param) is not None: + result[param] = getattr(model_settings, param) + logger.info(f"[DEBUG] Found model parameter {param}: {result[param]}") + + # Override with run_config.model_settings if available + if run_config and hasattr(run_config, "model_settings") and run_config.model_settings: + model_settings = run_config.model_settings + logger.info(f"[DEBUG] Found run_config.model_settings: {model_settings}") + + # Extract model parameters + for param in ["temperature", "top_p", "frequency_penalty", "presence_penalty"]: + if hasattr(model_settings, param) and getattr(model_settings, param) is not None: + result[param] = getattr(model_settings, param) + logger.info(f"[DEBUG] Found model parameter {param} in run_config: {result[param]}") + + logger.info(f"[DEBUG] Final model info: {result}") + return result + + +class AgentsDetailedExporter: + """ + A detailed exporter for Agents SDK traces and spans that forwards them to AgentOps. + """ + + def export(self, items: list[Union[AgentsTrace, AgentsSpan[Any]]]) -> None: + """Export Agents SDK traces and spans to AgentOps.""" + for item in items: + if isinstance(item, AgentsTrace): + self._export_trace(item) + else: + self._export_span(item) + + def _export_trace(self, trace: AgentsTrace) -> None: + """Export an Agents SDK trace to AgentOps.""" + # Get the current tracer + tracer = get_tracer("agents-sdk", __version__, get_tracer_provider()) + + # Create a new span for the trace + with tracer.start_as_current_span( + name=f"agents.trace.{trace.name}", + kind=SpanKind.INTERNAL, + attributes={ + CoreAttributes.WORKFLOW_NAME: trace.name, + CoreAttributes.TRACE_ID: trace.trace_id, + InstrumentationAttributes.LIBRARY_NAME: "agents-sdk", + InstrumentationAttributes.LIBRARY_VERSION: __version__, + WorkflowAttributes.WORKFLOW_STEP_TYPE: "trace", + } + ) as span: + # Add any additional attributes from the trace + if hasattr(trace, "group_id") and trace.group_id: + span.set_attribute(CoreAttributes.GROUP_ID, trace.group_id) + + def _export_span(self, span: AgentsSpan[Any]) -> None: + """Export an Agents SDK span to AgentOps.""" + # Get the current tracer + tracer = get_tracer("agents-sdk", __version__, get_tracer_provider()) + + # Determine span name and kind based on span data type + span_data = span.span_data + span_type = span_data.__class__.__name__.replace('SpanData', '') + + # Map span types to appropriate attributes + attributes = { + CoreAttributes.TRACE_ID: span.trace_id, + CoreAttributes.SPAN_ID: span.span_id, + InstrumentationAttributes.LIBRARY_NAME: "agents-sdk", + InstrumentationAttributes.LIBRARY_VERSION: __version__, + } + + # Add parent ID if available + if span.parent_id: + attributes[CoreAttributes.PARENT_ID] = span.parent_id + + # Add span-specific attributes + if hasattr(span_data, 'name'): + attributes[AgentAttributes.AGENT_NAME] = span_data.name + + if hasattr(span_data, 'input') and span_data.input: + attributes[LLMAttributes.INPUT] = str(span_data.input)[:1000] # Truncate long inputs + + if hasattr(span_data, 'output') and span_data.output: + attributes[LLMAttributes.OUTPUT] = str(span_data.output)[:1000] # Truncate long outputs + + # Extract model information - check for GenerationSpanData specifically + if span_type == "Generation" and hasattr(span_data, 'model') and span_data.model: + attributes[LLMAttributes.MODEL_NAME] = span_data.model + attributes["gen_ai.llm.model"] = span_data.model # Standard OpenTelemetry attribute + attributes["gen_ai.system"] = "openai" # Standard OpenTelemetry attribute + logger.info(f"[DEBUG] Found model in GenerationSpanData: {span_data.model}") + + # Add model config if available + if hasattr(span_data, 'model_config') and span_data.model_config: + for key, value in span_data.model_config.items(): + attributes[f"agent.model.{key}"] = value + logger.info(f"[DEBUG] Added model config parameter {key}: {value}") + + # Record token usage metrics if available + if hasattr(span_data, 'usage') and span_data.usage and isinstance(span_data.usage, dict): + # Record token usage metrics if available + if _agent_token_usage_histogram: + if 'prompt_tokens' in span_data.usage: + _agent_token_usage_histogram.record( + span_data.usage['prompt_tokens'], + { + "token_type": "input", + "model": attributes.get(LLMAttributes.MODEL_NAME, "unknown"), + "gen_ai.llm.model": attributes.get(LLMAttributes.MODEL_NAME, "unknown"), + "gen_ai.system": "openai" + } + ) + attributes[LLMAttributes.INPUT_TOKENS] = span_data.usage['prompt_tokens'] + + if 'completion_tokens' in span_data.usage: + _agent_token_usage_histogram.record( + span_data.usage['completion_tokens'], + { + "token_type": "output", + "model": attributes.get(LLMAttributes.MODEL_NAME, "unknown"), + "gen_ai.llm.model": attributes.get(LLMAttributes.MODEL_NAME, "unknown"), + "gen_ai.system": "openai" + } + ) + attributes[LLMAttributes.OUTPUT_TOKENS] = span_data.usage['completion_tokens'] + + if 'total_tokens' in span_data.usage: + attributes[LLMAttributes.TOTAL_TOKENS] = span_data.usage['total_tokens'] + + if hasattr(span_data, 'from_agent') and span_data.from_agent: + attributes[AgentAttributes.FROM_AGENT] = span_data.from_agent + + if hasattr(span_data, 'to_agent') and span_data.to_agent: + attributes[AgentAttributes.TO_AGENT] = span_data.to_agent + + if hasattr(span_data, 'tools') and span_data.tools: + attributes[AgentAttributes.TOOLS] = ",".join(span_data.tools) + + if hasattr(span_data, 'handoffs') and span_data.handoffs: + attributes[AgentAttributes.HANDOFFS] = ",".join(span_data.handoffs) + + # Create a span with the appropriate name and attributes + span_name = f"agents.{span_type.lower()}" + + # Determine span kind based on span type + span_kind = SpanKind.INTERNAL + if span_type == "Agent": + span_kind = SpanKind.CONSUMER + elif span_type == "Function": + span_kind = SpanKind.CLIENT + elif span_type == "Generation": + span_kind = SpanKind.CLIENT + + # Create the span + with tracer.start_as_current_span( + name=span_name, + kind=span_kind, + attributes=attributes + ) as otel_span: + # Add error information if available + if hasattr(span, 'error') and span.error: + otel_span.set_status(Status(StatusCode.ERROR)) + otel_span.record_exception( + exception=Exception(span.error.get('message', 'Unknown error')), + attributes={"error.data": json.dumps(span.error.get('data', {}))} + ) + + +class AgentsDetailedProcessor(AgentsTracingProcessor): + """ + A processor for Agents SDK traces and spans that forwards them to AgentOps. + """ + + def __init__(self): + self.exporter = AgentsDetailedExporter() + + def on_trace_start(self, trace: AgentsTrace) -> None: + self.exporter.export([trace]) + + def on_trace_end(self, trace: AgentsTrace) -> None: + self.exporter.export([trace]) + + def on_span_start(self, span: AgentsSpan[Any]) -> None: + self.exporter.export([span]) + + def on_span_end(self, span: AgentsSpan[Any]) -> None: + """Process a span when it ends.""" + # Log the span type for debugging + span_type = span.span_data.__class__.__name__.replace('SpanData', '') + logger.info(f"[DEBUG] Processing span end: {span_type}") + + # For Generation spans, log model information + if span_type == "Generation": + if hasattr(span.span_data, 'model') and span.span_data.model: + logger.info(f"[DEBUG] Generation span model: {span.span_data.model}") + if hasattr(span.span_data, 'usage') and span.span_data.usage: + logger.info(f"[DEBUG] Generation span usage: {span.span_data.usage}") + + self.exporter.export([span]) + + def shutdown(self) -> None: + pass + + def force_flush(self): + pass + + +class AgentsInstrumentor(BaseInstrumentor): + """An instrumentor for OpenAI Agents SDK.""" + + def instrumentation_dependencies(self) -> Collection[str]: + return ["openai-agents >= 0.0.1"] + + def _instrument(self, **kwargs): + """Instrument the Agents SDK.""" + tracer_provider = kwargs.get("tracer_provider") + tracer = get_tracer( + __name__, + __version__, + tracer_provider, + ) + + # Initialize metrics + global _agent_run_counter, _agent_turn_counter, _agent_execution_time_histogram, _agent_token_usage_histogram + meter_provider = kwargs.get("meter_provider") + if meter_provider: + meter = get_meter(__name__, __version__, meter_provider) + + _agent_run_counter = meter.create_counter( + name="agents.runs", + unit="run", + description="Counts agent runs" + ) + + _agent_turn_counter = meter.create_counter( + name="agents.turns", + unit="turn", + description="Counts agent turns" + ) + + _agent_execution_time_histogram = meter.create_histogram( + name=Meters.LLM_OPERATION_DURATION, + unit="s", + description="GenAI operation duration" + ) + + _agent_token_usage_histogram = meter.create_histogram( + name=Meters.LLM_TOKEN_USAGE, + unit="token", + description="Measures token usage in agent runs" + ) + + # Try to import the default model from the SDK for reference + try: + from agents.models.openai_provider import DEFAULT_MODEL + logger.info(f"[DEBUG] Default model from Agents SDK: {DEFAULT_MODEL}") + except ImportError: + logger.info("[DEBUG] Could not import DEFAULT_MODEL from agents.models.openai_provider") + + # Add the custom processor to the Agents SDK + try: + from agents import add_trace_processor + processor = AgentsDetailedProcessor() + add_trace_processor(processor) + logger.info(f"[DEBUG] Added AgentsDetailedProcessor to Agents SDK: {processor}") + except Exception as e: + logger.error(f"Failed to add AgentsDetailedProcessor: {e}") + + # Monkey patch the Runner class + try: + self._patch_runner_class() + logger.info("Monkey patched Runner class") + except Exception as e: + logger.error(f"Failed to monkey patch Runner class: {e}") + + def _patch_runner_class(self): + """Monkey patch the Runner class to capture additional information.""" + from agents.run import Runner + + # Store original methods + original_methods = { + "run": Runner.run, + "run_sync": Runner.run_sync, + "run_streamed": Runner.run_streamed if hasattr(Runner, "run_streamed") else None + } + + # Filter out None values + original_methods = {k: v for k, v in original_methods.items() if v is not None} + + # Create instrumented versions of each method + for method_name, original_method in original_methods.items(): + is_async = method_name in ["run", "run_streamed"] + + if is_async: + @functools.wraps(original_method) + async def instrumented_method(cls, starting_agent, input, context=None, max_turns=10, hooks=None, run_config=None, _method_name=method_name, _original=original_method): + start_time = time.time() + + # Get the current tracer + tracer = get_tracer(__name__, __version__, get_tracer_provider()) + + # Extract model information from agent and run_config + model_info = get_model_info(starting_agent, run_config) + model_name = model_info.get("model_name", "unknown") + logger.info(f"[DEBUG] Extracted model name: {model_name}") + + # Record agent run counter + if _agent_run_counter: + _agent_run_counter.add( + 1, + { + "agent_name": starting_agent.name, + "method": _method_name, + "stream": "true" if _method_name == "run_streamed" else "false", + "model": model_name + } + ) + + is_streaming = _method_name == "run_streamed" + + # Create span attributes + attributes = { + "span.kind": AOSpanKind.WORKFLOW_STEP, + "agent.name": starting_agent.name, + WorkflowAttributes.WORKFLOW_INPUT: str(input)[:1000], + WorkflowAttributes.MAX_TURNS: max_turns, + "service.name": "agentops.agents", + WorkflowAttributes.WORKFLOW_TYPE: f"agents.{_method_name}", + LLMAttributes.MODEL_NAME: model_name, + "gen_ai.llm.model": model_name, # Standard OpenTelemetry attribute + "gen_ai.system": "openai", # Standard OpenTelemetry attribute + "stream": is_streaming + } + + # Add model parameters from model_info + for param, value in model_info.items(): + if param != "model_name": + attributes[f"agent.model.{param}"] = value + + # Create a default RunConfig if None is provided + if run_config is None: + run_config = RunConfig(workflow_name=f"Agent {starting_agent.name}") + + if hasattr(run_config, "workflow_name"): + attributes[WorkflowAttributes.WORKFLOW_NAME] = run_config.workflow_name + + # Create default hooks if None is provided + if hooks is None: + hooks = RunHooks() + + # Start a span for the run + with tracer.start_as_current_span( + name=f"agents.{_method_name}.{starting_agent.name}", + kind=SpanKind.CLIENT, + attributes=attributes + ) as span: + # Add agent attributes + if hasattr(starting_agent, "instructions"): + # Determine instruction type + instruction_type = "unknown" + if isinstance(starting_agent.instructions, str): + instruction_type = "string" + span.set_attribute("agent.instructions", starting_agent.instructions[:1000]) + elif callable(starting_agent.instructions): + instruction_type = "function" + # Store the function name or representation + func_name = getattr(starting_agent.instructions, "__name__", str(starting_agent.instructions)) + span.set_attribute("agent.instruction_function", func_name) + else: + span.set_attribute("agent.instructions", str(starting_agent.instructions)[:1000]) + + span.set_attribute("agent.instruction_type", instruction_type) + + # Add agent tools if available + if hasattr(starting_agent, "tools") and starting_agent.tools: + tool_names = [tool.name for tool in starting_agent.tools if hasattr(tool, "name")] + if tool_names: + span.set_attribute(AgentAttributes.AGENT_TOOLS, str(tool_names)) + + # Add agent model settings if available + if hasattr(starting_agent, "model_settings") and starting_agent.model_settings: + # Add model settings directly + if hasattr(starting_agent.model_settings, "temperature") and starting_agent.model_settings.temperature is not None: + span.set_attribute(SpanAttributes.LLM_REQUEST_TEMPERATURE, starting_agent.model_settings.temperature) + + if hasattr(starting_agent.model_settings, "top_p") and starting_agent.model_settings.top_p is not None: + span.set_attribute(SpanAttributes.LLM_REQUEST_TOP_P, starting_agent.model_settings.top_p) + + if hasattr(starting_agent.model_settings, "frequency_penalty") and starting_agent.model_settings.frequency_penalty is not None: + span.set_attribute(SpanAttributes.LLM_FREQUENCY_PENALTY, starting_agent.model_settings.frequency_penalty) + + if hasattr(starting_agent.model_settings, "presence_penalty") and starting_agent.model_settings.presence_penalty is not None: + span.set_attribute(SpanAttributes.LLM_PRESENCE_PENALTY, starting_agent.model_settings.presence_penalty) + + try: + # Execute the original method + result = await _original(starting_agent, input, context, max_turns, hooks, run_config) + + # Add result attributes to the span + if hasattr(result, "final_output"): + span.set_attribute(WorkflowAttributes.FINAL_OUTPUT, str(result.final_output)[:1000]) + + # Extract model and response information + response_id = None + + # Process raw responses + if hasattr(result, "raw_responses") and result.raw_responses: + logger.info(f"[DEBUG] Found raw_responses: {len(result.raw_responses)}") + total_input_tokens = 0 + total_output_tokens = 0 + total_tokens = 0 + + for i, response in enumerate(result.raw_responses): + logger.info(f"[DEBUG] Processing raw_response {i}: {type(response).__name__}") + + # Try to extract model directly + if hasattr(response, "model"): + model_name = response.model + logger.info(f"[DEBUG] Found model in raw_response: {model_name}") + span.set_attribute(LLMAttributes.MODEL_NAME, model_name) + + # Extract response ID if available + if hasattr(response, "referenceable_id") and response.referenceable_id: + response_id = response.referenceable_id + logger.info(f"[DEBUG] Found response_id: {response_id}") + span.set_attribute(f"gen_ai.response.id.{i}", response_id) + + # Extract usage information + if hasattr(response, "usage"): + usage = response.usage + logger.info(f"[DEBUG] Found usage: {usage}") + + # Add token usage + if hasattr(usage, "prompt_tokens") or hasattr(usage, "input_tokens"): + input_tokens = getattr(usage, "prompt_tokens", getattr(usage, "input_tokens", 0)) + span.set_attribute(f"{SpanAttributes.LLM_USAGE_PROMPT_TOKENS}.{i}", input_tokens) + total_input_tokens += input_tokens + + if _agent_token_usage_histogram: + _agent_token_usage_histogram.record( + input_tokens, + { + "token_type": "input", + "model": model_name, + "gen_ai.llm.model": model_name, + "gen_ai.system": "openai" + } + ) + + if hasattr(usage, "completion_tokens") or hasattr(usage, "output_tokens"): + output_tokens = getattr(usage, "completion_tokens", getattr(usage, "output_tokens", 0)) + span.set_attribute(f"{SpanAttributes.LLM_USAGE_COMPLETION_TOKENS}.{i}", output_tokens) + total_output_tokens += output_tokens + + if _agent_token_usage_histogram: + _agent_token_usage_histogram.record( + output_tokens, + { + "token_type": "output", + "model": model_name, + "gen_ai.llm.model": model_name, + "gen_ai.system": "openai" + } + ) + + if hasattr(usage, "total_tokens"): + span.set_attribute(f"{LLMAttributes.TOTAL_TOKENS}.{i}", usage.total_tokens) + total_tokens += usage.total_tokens + + # Set total token counts + if total_input_tokens > 0: + span.set_attribute(LLMAttributes.INPUT_TOKENS, total_input_tokens) + + if total_output_tokens > 0: + span.set_attribute(LLMAttributes.OUTPUT_TOKENS, total_output_tokens) + + if total_tokens > 0: + span.set_attribute(LLMAttributes.TOTAL_TOKENS, total_tokens) + + # Record execution time + execution_time = (time.time() - start_time) # In seconds + if _agent_execution_time_histogram: + # Create shared attributes following OpenAI conventions + shared_attributes = { + "gen_ai.system": "openai", + "gen_ai.response.model": model_name, + "gen_ai.llm.model": model_name, # Standard OpenTelemetry attribute + "gen_ai.operation.name": "agent_run", + "agent_name": starting_agent.name, + "stream": "true" if is_streaming else "false" + } + + # Add response ID if available + if response_id: + shared_attributes["gen_ai.response.id"] = response_id + + logger.info(f"[DEBUG] Final metrics attributes: {shared_attributes}") + + _agent_execution_time_histogram.record( + execution_time, + attributes=shared_attributes + ) + + # Add instrumentation metadata + span.set_attribute(InstrumentationAttributes.NAME, "agentops.agents") + span.set_attribute(InstrumentationAttributes.VERSION, __version__) + + return result + except Exception as e: + # Record the error + span.set_status(Status(StatusCode.ERROR)) + span.record_exception(e) + span.set_attribute(CoreAttributes.ERROR_TYPE, type(e).__name__) + span.set_attribute(CoreAttributes.ERROR_MESSAGE, str(e)) + raise + + setattr(Runner, method_name, classmethod(instrumented_method)) + else: + @functools.wraps(original_method) + def instrumented_method(cls, starting_agent, input, context=None, max_turns=10, hooks=None, run_config=None, _method_name=method_name, _original=original_method): + start_time = time.time() + + # Get the current tracer + tracer = get_tracer(__name__, __version__, get_tracer_provider()) + + # Extract model information from agent and run_config + model_info = get_model_info(starting_agent, run_config) + model_name = model_info.get("model_name", "unknown") + logger.info(f"[DEBUG] Extracted model name: {model_name}") + + # Record agent run counter + if _agent_run_counter: + _agent_run_counter.add( + 1, + { + "agent_name": starting_agent.name, + "method": _method_name, + "stream": "false", + "model": model_name + } + ) + + # Create span attributes + attributes = { + "span.kind": AOSpanKind.WORKFLOW_STEP, + "agent.name": starting_agent.name, + WorkflowAttributes.WORKFLOW_INPUT: str(input)[:1000], + WorkflowAttributes.MAX_TURNS: max_turns, + "service.name": "agentops.agents", + WorkflowAttributes.WORKFLOW_TYPE: f"agents.{_method_name}", + LLMAttributes.MODEL_NAME: model_name, + "gen_ai.llm.model": model_name, # Standard OpenTelemetry attribute + "gen_ai.system": "openai", # Standard OpenTelemetry attribute + "stream": False + } + + # Add model parameters from model_info + for param, value in model_info.items(): + if param != "model_name": + attributes[f"agent.model.{param}"] = value + + # Create a default RunConfig if None is provided + if run_config is None: + run_config = RunConfig(workflow_name=f"Agent {starting_agent.name}") + + if hasattr(run_config, "workflow_name"): + attributes[WorkflowAttributes.WORKFLOW_NAME] = run_config.workflow_name + + # Create default hooks if None is provided + if hooks is None: + hooks = RunHooks() + + # Start a span for the run + with tracer.start_as_current_span( + name=f"agents.{_method_name}.{starting_agent.name}", + kind=SpanKind.CLIENT, + attributes=attributes + ) as span: + # Add agent attributes + if hasattr(starting_agent, "instructions"): + # Determine instruction type + instruction_type = "unknown" + if isinstance(starting_agent.instructions, str): + instruction_type = "string" + span.set_attribute("agent.instructions", starting_agent.instructions[:1000]) + elif callable(starting_agent.instructions): + instruction_type = "function" + # Store the function name or representation + func_name = getattr(starting_agent.instructions, "__name__", str(starting_agent.instructions)) + span.set_attribute("agent.instruction_function", func_name) + else: + span.set_attribute("agent.instructions", str(starting_agent.instructions)[:1000]) + + span.set_attribute("agent.instruction_type", instruction_type) + + # Add agent tools if available + if hasattr(starting_agent, "tools") and starting_agent.tools: + tool_names = [tool.name for tool in starting_agent.tools if hasattr(tool, "name")] + if tool_names: + span.set_attribute(AgentAttributes.AGENT_TOOLS, str(tool_names)) + + # Add agent model settings if available + if hasattr(starting_agent, "model_settings") and starting_agent.model_settings: + # Add model settings directly + if hasattr(starting_agent.model_settings, "temperature") and starting_agent.model_settings.temperature is not None: + span.set_attribute(SpanAttributes.LLM_REQUEST_TEMPERATURE, starting_agent.model_settings.temperature) + + if hasattr(starting_agent.model_settings, "top_p") and starting_agent.model_settings.top_p is not None: + span.set_attribute(SpanAttributes.LLM_REQUEST_TOP_P, starting_agent.model_settings.top_p) + + if hasattr(starting_agent.model_settings, "frequency_penalty") and starting_agent.model_settings.frequency_penalty is not None: + span.set_attribute(SpanAttributes.LLM_FREQUENCY_PENALTY, starting_agent.model_settings.frequency_penalty) + + if hasattr(starting_agent.model_settings, "presence_penalty") and starting_agent.model_settings.presence_penalty is not None: + span.set_attribute(SpanAttributes.LLM_PRESENCE_PENALTY, starting_agent.model_settings.presence_penalty) + + try: + # Execute the original method + result = _original(starting_agent, input, context, max_turns, hooks, run_config) + + # Add result attributes to the span + if hasattr(result, "final_output"): + span.set_attribute(WorkflowAttributes.FINAL_OUTPUT, str(result.final_output)[:1000]) + + # Extract model and response information + response_id = None + + # Process raw responses + if hasattr(result, "raw_responses") and result.raw_responses: + logger.info(f"[DEBUG] Found raw_responses: {len(result.raw_responses)}") + total_input_tokens = 0 + total_output_tokens = 0 + total_tokens = 0 + + for i, response in enumerate(result.raw_responses): + logger.info(f"[DEBUG] Processing raw_response {i}: {type(response).__name__}") + + # Try to extract model directly + if hasattr(response, "model"): + model_name = response.model + logger.info(f"[DEBUG] Found model in raw_response: {model_name}") + span.set_attribute(LLMAttributes.MODEL_NAME, model_name) + + # Extract response ID if available + if hasattr(response, "referenceable_id") and response.referenceable_id: + response_id = response.referenceable_id + logger.info(f"[DEBUG] Found response_id: {response_id}") + span.set_attribute(f"gen_ai.response.id.{i}", response_id) + + # Extract usage information + if hasattr(response, "usage"): + usage = response.usage + logger.info(f"[DEBUG] Found usage: {usage}") + + # Add token usage + if hasattr(usage, "prompt_tokens") or hasattr(usage, "input_tokens"): + input_tokens = getattr(usage, "prompt_tokens", getattr(usage, "input_tokens", 0)) + span.set_attribute(f"{SpanAttributes.LLM_USAGE_PROMPT_TOKENS}.{i}", input_tokens) + total_input_tokens += input_tokens + + if _agent_token_usage_histogram: + _agent_token_usage_histogram.record( + input_tokens, + { + "token_type": "input", + "model": model_name, + "gen_ai.llm.model": model_name, + "gen_ai.system": "openai" + } + ) + + if hasattr(usage, "completion_tokens") or hasattr(usage, "output_tokens"): + output_tokens = getattr(usage, "completion_tokens", getattr(usage, "output_tokens", 0)) + span.set_attribute(f"{SpanAttributes.LLM_USAGE_COMPLETION_TOKENS}.{i}", output_tokens) + total_output_tokens += output_tokens + + if _agent_token_usage_histogram: + _agent_token_usage_histogram.record( + output_tokens, + { + "token_type": "output", + "model": model_name, + "gen_ai.llm.model": model_name, + "gen_ai.system": "openai" + } + ) + + if hasattr(usage, "total_tokens"): + span.set_attribute(f"{LLMAttributes.TOTAL_TOKENS}.{i}", usage.total_tokens) + total_tokens += usage.total_tokens + + # Set total token counts + if total_input_tokens > 0: + span.set_attribute(LLMAttributes.INPUT_TOKENS, total_input_tokens) + + if total_output_tokens > 0: + span.set_attribute(LLMAttributes.OUTPUT_TOKENS, total_output_tokens) + + if total_tokens > 0: + span.set_attribute(LLMAttributes.TOTAL_TOKENS, total_tokens) + + # Record execution time + execution_time = (time.time() - start_time) # In seconds + if _agent_execution_time_histogram: + # Create shared attributes following OpenAI conventions + shared_attributes = { + "gen_ai.system": "openai", + "gen_ai.response.model": model_name, + "gen_ai.llm.model": model_name, # Standard OpenTelemetry attribute + "gen_ai.operation.name": "agent_run", + "agent_name": starting_agent.name, + "stream": "false" + } + + # Add response ID if available + if response_id: + shared_attributes["gen_ai.response.id"] = response_id + + logger.info(f"[DEBUG] Final metrics attributes: {shared_attributes}") + + _agent_execution_time_histogram.record( + execution_time, + attributes=shared_attributes + ) + + # Add instrumentation metadata + span.set_attribute(InstrumentationAttributes.NAME, "agentops.agents") + span.set_attribute(InstrumentationAttributes.VERSION, __version__) + + return result + except Exception as e: + # Record the error + span.set_status(Status(StatusCode.ERROR)) + span.record_exception(e) + span.set_attribute(CoreAttributes.ERROR_TYPE, type(e).__name__) + span.set_attribute(CoreAttributes.ERROR_MESSAGE, str(e)) + raise + + setattr(Runner, method_name, classmethod(instrumented_method)) + + def _uninstrument(self, **kwargs): + """Uninstrument the Agents SDK.""" + # Restore original methods + try: + from agents.run import Runner + + # Check if we have the original methods stored + if hasattr(Runner, "_original_run"): + Runner.run = Runner._original_run + delattr(Runner, "_original_run") + + if hasattr(Runner, "_original_run_sync"): + Runner.run_sync = Runner._original_run_sync + delattr(Runner, "_original_run_sync") + + logger.info("Restored original Runner methods") + except Exception as e: + logger.error(f"Failed to restore original Runner methods: {e}") \ No newline at end of file diff --git a/third_party/opentelemetry/instrumentation/agents/setup.py b/third_party/opentelemetry/instrumentation/agents/setup.py new file mode 100644 index 000000000..b602862f3 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/agents/setup.py @@ -0,0 +1,28 @@ +from setuptools import setup, find_namespace_packages + +setup( + name="opentelemetry-instrumentation-agents", + version="0.1.0", + description="OpenTelemetry instrumentation for OpenAI Agents SDK", + author="AgentOps", + author_email="info@agentops.ai", + url="https://github.com/agentops-ai/agentops", + packages=find_namespace_packages(include=["opentelemetry.*"]), + install_requires=[ + "agentops>=0.1.0", + "opentelemetry-api>=1.0.0", + "opentelemetry-sdk>=1.0.0", + "opentelemetry-instrumentation>=0.30b0", + ], + classifiers=[ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + ], + python_requires=">=3.8", +) \ No newline at end of file diff --git a/third_party/opentelemetry/instrumentation/anthropic/LICENSE b/third_party/opentelemetry/instrumentation/anthropic/LICENSE new file mode 100644 index 000000000..0f2a333f0 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/anthropic/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 openllmetry + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/opentelemetry/instrumentation/anthropic/NOTICE.md b/third_party/opentelemetry/instrumentation/anthropic/NOTICE.md new file mode 100644 index 000000000..ca711b794 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/anthropic/NOTICE.md @@ -0,0 +1,8 @@ +This package contains code derived from the OpenLLMetry project, which is licensed under the Apache License, Version 2.0. + +Original repository: https://github.com/traceloop/openllmetry + +Copyright notice from the original project: +Copyright (c) Traceloop (https://traceloop.com) + +The Apache 2.0 license can be found in the LICENSE file in this directory. diff --git a/third_party/opentelemetry/instrumentation/anthropic/__init__.py b/third_party/opentelemetry/instrumentation/anthropic/__init__.py new file mode 100644 index 000000000..05e799853 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/anthropic/__init__.py @@ -0,0 +1,836 @@ +"""OpenTelemetry Anthropic instrumentation""" + +import json +import logging +import os +import time +from typing import Callable, Collection, Dict, Any, Optional +from typing_extensions import Coroutine + +from anthropic._streaming import AsyncStream, Stream +from opentelemetry import context as context_api +from opentelemetry.instrumentation.anthropic.config import Config +from opentelemetry.instrumentation.anthropic.streaming import ( + abuild_from_streaming_response, + build_from_streaming_response, +) +from opentelemetry.instrumentation.anthropic.utils import ( + acount_prompt_tokens_from_request, + dont_throw, + error_metrics_attributes, + count_prompt_tokens_from_request, + run_async, + set_span_attribute, + shared_metrics_attributes, + should_send_prompts, +) +from opentelemetry.instrumentation.anthropic.version import __version__ +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY, unwrap +from opentelemetry.metrics import Counter, Histogram, Meter, get_meter +from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_RESPONSE_ID +from agentops.semconv import ( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, + LLMRequestTypeValues, + SpanAttributes, + Meters, +) +from opentelemetry.trace import SpanKind, Tracer, get_tracer +from opentelemetry.trace.status import Status, StatusCode +from wrapt import wrap_function_wrapper + +logger = logging.getLogger(__name__) + +_instruments = ("anthropic >= 0.3.11",) + +WRAPPED_METHODS = [ + { + "package": "anthropic.resources.completions", + "object": "Completions", + "method": "create", + "span_name": "anthropic.completion", + }, + { + "package": "anthropic.resources.messages", + "object": "Messages", + "method": "create", + "span_name": "anthropic.chat", + }, + { + "package": "anthropic.resources.messages", + "object": "Messages", + "method": "stream", + "span_name": "anthropic.chat", + }, + { + "package": "anthropic.resources.beta.prompt_caching.messages", + "object": "Messages", + "method": "create", + "span_name": "anthropic.chat", + }, + { + "package": "anthropic.resources.beta.prompt_caching.messages", + "object": "Messages", + "method": "stream", + "span_name": "anthropic.chat", + }, +] +WRAPPED_AMETHODS = [ + { + "package": "anthropic.resources.completions", + "object": "AsyncCompletions", + "method": "create", + "span_name": "anthropic.completion", + }, + { + "package": "anthropic.resources.messages", + "object": "AsyncMessages", + "method": "create", + "span_name": "anthropic.chat", + }, + { + "package": "anthropic.resources.messages", + "object": "AsyncMessages", + "method": "stream", + "span_name": "anthropic.chat", + }, + { + "package": "anthropic.resources.beta.prompt_caching.messages", + "object": "AsyncMessages", + "method": "create", + "span_name": "anthropic.chat", + }, + { + "package": "anthropic.resources.beta.prompt_caching.messages", + "object": "AsyncMessages", + "method": "stream", + "span_name": "anthropic.chat", + }, +] + + +def is_streaming_response(response): + return isinstance(response, Stream) or isinstance(response, AsyncStream) + + +async def _process_image_item(item, trace_id, span_id, message_index, content_index): + if not Config.upload_base64_image: + return item + + image_format = item.get("source").get("media_type").split("/")[1] + image_name = f"message_{message_index}_content_{content_index}.{image_format}" + base64_string = item.get("source").get("data") + url = await Config.upload_base64_image(trace_id, span_id, image_name, base64_string) + + return {"type": "image_url", "image_url": {"url": url}} + + +async def _dump_content(message_index, content, span): + if isinstance(content, str): + return content + elif isinstance(content, list): + # If the content is a list of text blocks, concatenate them. + # This is more commonly used in prompt caching. + if all([item.get("type") == "text" for item in content]): + return "".join([item.get("text") for item in content]) + + content = [ + ( + await _process_image_item( + item, span.context.trace_id, span.context.span_id, message_index, j + ) + if _is_base64_image(item) + else item + ) + for j, item in enumerate(content) + ] + + return json.dumps(content) + + +@dont_throw +async def _aset_input_attributes(span, kwargs): + set_span_attribute(span, SpanAttributes.LLM_REQUEST_MODEL, kwargs.get("model")) + set_span_attribute( + span, SpanAttributes.LLM_REQUEST_MAX_TOKENS, kwargs.get("max_tokens_to_sample") + ) + set_span_attribute( + span, SpanAttributes.LLM_REQUEST_TEMPERATURE, kwargs.get("temperature") + ) + set_span_attribute(span, SpanAttributes.LLM_REQUEST_TOP_P, kwargs.get("top_p")) + set_span_attribute( + span, SpanAttributes.LLM_FREQUENCY_PENALTY, kwargs.get("frequency_penalty") + ) + set_span_attribute( + span, SpanAttributes.LLM_PRESENCE_PENALTY, kwargs.get("presence_penalty") + ) + set_span_attribute(span, SpanAttributes.LLM_IS_STREAMING, kwargs.get("stream")) + + if should_send_prompts(): + if kwargs.get("prompt") is not None: + set_span_attribute( + span, f"{SpanAttributes.LLM_PROMPTS}.0.user", kwargs.get("prompt") + ) + + elif kwargs.get("messages") is not None: + has_system_message = False + if kwargs.get("system"): + has_system_message = True + set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.0.content", + await _dump_content( + message_index=0, span=span, content=kwargs.get("system") + ), + ) + set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.0.role", + "system", + ) + for i, message in enumerate(kwargs.get("messages")): + prompt_index = i + (1 if has_system_message else 0) + set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.{prompt_index}.content", + await _dump_content( + message_index=i, span=span, content=message.get("content") + ), + ) + set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.{prompt_index}.role", + message.get("role"), + ) + + if kwargs.get("tools") is not None: + for i, tool in enumerate(kwargs.get("tools")): + prefix = f"{SpanAttributes.LLM_REQUEST_FUNCTIONS}.{i}" + set_span_attribute(span, f"{prefix}.name", tool.get("name")) + set_span_attribute(span, f"{prefix}.description", tool.get("description")) + input_schema = tool.get("input_schema") + if input_schema is not None: + set_span_attribute(span, f"{prefix}.input_schema", json.dumps(input_schema)) + + +def _set_span_completions(span, response): + index = 0 + prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}" + set_span_attribute(span, f"{prefix}.finish_reason", response.get("stop_reason")) + if response.get("role"): + set_span_attribute(span, f"{prefix}.role", response.get("role")) + + if response.get("completion"): + set_span_attribute(span, f"{prefix}.content", response.get("completion")) + elif response.get("content"): + tool_call_index = 0 + text = "" + for content in response.get("content"): + content_block_type = content.type + # usually, Antrhopic responds with just one text block, + # but the API allows for multiple text blocks, so concatenate them + if content_block_type == "text": + text += content.text + elif content_block_type == "tool_use": + content = dict(content) + set_span_attribute( + span, + f"{prefix}.tool_calls.{tool_call_index}.id", + content.get("id"), + ) + set_span_attribute( + span, + f"{prefix}.tool_calls.{tool_call_index}.name", + content.get("name"), + ) + tool_arguments = content.get("input") + if tool_arguments is not None: + set_span_attribute( + span, + f"{prefix}.tool_calls.{tool_call_index}.arguments", + json.dumps(tool_arguments), + ) + tool_call_index += 1 + set_span_attribute(span, f"{prefix}.content", text) + + +@dont_throw +async def _aset_token_usage( + span, + anthropic, + request, + response, + metric_attributes: dict = {}, + token_histogram: Histogram = None, + choice_counter: Counter = None, +): + if not isinstance(response, dict): + response = response.__dict__ + + if usage := response.get("usage"): + prompt_tokens = usage.input_tokens + else: + prompt_tokens = await acount_prompt_tokens_from_request(anthropic, request) + + if usage := response.get("usage"): + cache_read_tokens = dict(usage).get("cache_read_input_tokens", 0) + else: + cache_read_tokens = 0 + + if usage := response.get("usage"): + cache_creation_tokens = dict(usage).get("cache_creation_input_tokens", 0) + else: + cache_creation_tokens = 0 + + input_tokens = prompt_tokens + cache_read_tokens + cache_creation_tokens + + if token_histogram and type(input_tokens) is int and input_tokens >= 0: + token_histogram.record( + input_tokens, + attributes={ + **metric_attributes, + SpanAttributes.LLM_TOKEN_TYPE: "input", + }, + ) + + if usage := response.get("usage"): + completion_tokens = usage.output_tokens + else: + completion_tokens = 0 + if hasattr(anthropic, "count_tokens"): + if response.get("completion"): + completion_tokens = await anthropic.count_tokens(response.get("completion")) + elif response.get("content"): + completion_tokens = await anthropic.count_tokens( + response.get("content")[0].text + ) + + if token_histogram and type(completion_tokens) is int and completion_tokens >= 0: + token_histogram.record( + completion_tokens, + attributes={ + **metric_attributes, + SpanAttributes.LLM_TOKEN_TYPE: "output", + }, + ) + + total_tokens = input_tokens + completion_tokens + + choices = 0 + if type(response.get("content")) is list: + choices = len(response.get("content")) + elif response.get("completion"): + choices = 1 + + if choices > 0 and choice_counter: + choice_counter.add( + choices, + attributes={ + **metric_attributes, + SpanAttributes.LLM_RESPONSE_STOP_REASON: response.get("stop_reason"), + }, + ) + + set_span_attribute(span, SpanAttributes.LLM_USAGE_PROMPT_TOKENS, input_tokens) + set_span_attribute( + span, SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, completion_tokens + ) + set_span_attribute(span, SpanAttributes.LLM_USAGE_TOTAL_TOKENS, total_tokens) + + set_span_attribute( + span, SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS, cache_read_tokens + ) + set_span_attribute( + span, SpanAttributes.LLM_USAGE_CACHE_CREATION_INPUT_TOKENS, cache_creation_tokens + ) + + +@dont_throw +def _set_token_usage( + span, + anthropic, + request, + response, + metric_attributes: dict = {}, + token_histogram: Histogram = None, + choice_counter: Counter = None, +): + if not isinstance(response, dict): + response = response.__dict__ + + if usage := response.get("usage"): + prompt_tokens = usage.input_tokens + else: + prompt_tokens = count_prompt_tokens_from_request(anthropic, request) + + if usage := response.get("usage"): + cache_read_tokens = dict(usage).get("cache_read_input_tokens", 0) + else: + cache_read_tokens = 0 + + if usage := response.get("usage"): + cache_creation_tokens = dict(usage).get("cache_creation_input_tokens", 0) + else: + cache_creation_tokens = 0 + + input_tokens = prompt_tokens + cache_read_tokens + cache_creation_tokens + + if token_histogram and type(input_tokens) is int and input_tokens >= 0: + token_histogram.record( + input_tokens, + attributes={ + **metric_attributes, + SpanAttributes.LLM_TOKEN_TYPE: "input", + }, + ) + + if usage := response.get("usage"): + completion_tokens = usage.output_tokens + else: + completion_tokens = 0 + if hasattr(anthropic, "count_tokens"): + if response.get("completion"): + completion_tokens = anthropic.count_tokens(response.get("completion")) + elif response.get("content"): + completion_tokens = anthropic.count_tokens(response.get("content")[0].text) + + if token_histogram and type(completion_tokens) is int and completion_tokens >= 0: + token_histogram.record( + completion_tokens, + attributes={ + **metric_attributes, + SpanAttributes.LLM_TOKEN_TYPE: "output", + }, + ) + + total_tokens = input_tokens + completion_tokens + + choices = 0 + if type(response.get("content")) is list: + choices = len(response.get("content")) + elif response.get("completion"): + choices = 1 + + if choices > 0 and choice_counter: + choice_counter.add( + choices, + attributes={ + **metric_attributes, + SpanAttributes.LLM_RESPONSE_STOP_REASON: response.get("stop_reason"), + }, + ) + + set_span_attribute(span, SpanAttributes.LLM_USAGE_PROMPT_TOKENS, input_tokens) + set_span_attribute( + span, SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, completion_tokens + ) + set_span_attribute(span, SpanAttributes.LLM_USAGE_TOTAL_TOKENS, total_tokens) + + set_span_attribute( + span, SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS, cache_read_tokens + ) + set_span_attribute( + span, SpanAttributes.LLM_USAGE_CACHE_CREATION_INPUT_TOKENS, cache_creation_tokens + ) + + +@dont_throw +def _set_response_attributes(span, response): + if not isinstance(response, dict): + response = response.__dict__ + set_span_attribute(span, SpanAttributes.LLM_RESPONSE_MODEL, response.get("model")) + set_span_attribute(span, GEN_AI_RESPONSE_ID, response.get("id")) + + if response.get("usage"): + prompt_tokens = response.get("usage").input_tokens + completion_tokens = response.get("usage").output_tokens + set_span_attribute(span, SpanAttributes.LLM_USAGE_PROMPT_TOKENS, prompt_tokens) + set_span_attribute( + span, SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, completion_tokens + ) + set_span_attribute( + span, + SpanAttributes.LLM_USAGE_TOTAL_TOKENS, + prompt_tokens + completion_tokens, + ) + + if should_send_prompts(): + _set_span_completions(span, response) + + +def _with_tracer_wrapper(func): + """Helper for providing tracer for wrapper functions.""" + + def _with_tracer(tracer, to_wrap): + def wrapper(wrapped, instance, args, kwargs): + return func(tracer, to_wrap, wrapped, instance, args, kwargs) + + return wrapper + + return _with_tracer + + +def _with_chat_telemetry_wrapper(func): + """Helper for providing tracer for wrapper functions. Includes metric collectors.""" + + def _with_chat_telemetry( + tracer, + token_histogram, + choice_counter, + duration_histogram, + exception_counter, + to_wrap, + ): + def wrapper(wrapped, instance, args, kwargs): + return func( + tracer, + token_histogram, + choice_counter, + duration_histogram, + exception_counter, + to_wrap, + wrapped, + instance, + args, + kwargs, + ) + + return wrapper + + return _with_chat_telemetry + + +def _create_metrics(meter: Meter): + token_histogram = meter.create_histogram( + name=Meters.LLM_TOKEN_USAGE, + unit="token", + description="Measures number of input and output tokens used", + ) + + choice_counter = meter.create_counter( + name=Meters.LLM_GENERATION_CHOICES, + unit="choice", + description="Number of choices returned by chat completions call", + ) + + duration_histogram = meter.create_histogram( + name=Meters.LLM_OPERATION_DURATION, + unit="s", + description="GenAI operation duration", + ) + + exception_counter = meter.create_counter( + name=Meters.LLM_ANTHROPIC_COMPLETION_EXCEPTIONS, + unit="time", + description="Number of exceptions occurred during chat completions", + ) + + return token_histogram, choice_counter, duration_histogram, exception_counter + + +def _is_base64_image(item: Dict[str, Any]) -> bool: + if not isinstance(item, dict): + return False + + if not isinstance(item.get("source"), dict): + return False + + if item.get("type") != "image" or item["source"].get("type") != "base64": + return False + + return True + + +@_with_chat_telemetry_wrapper +def _wrap( + tracer: Tracer, + token_histogram: Histogram, + choice_counter: Counter, + duration_histogram: Histogram, + exception_counter: Counter, + to_wrap, + wrapped, + instance, + args, + kwargs, +): + """Instruments and calls every function defined in TO_WRAP.""" + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) or context_api.get_value( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + ): + return wrapped(*args, **kwargs) + + name = to_wrap.get("span_name") + span = tracer.start_span( + name, + kind=SpanKind.CLIENT, + attributes={ + SpanAttributes.LLM_SYSTEM: "Anthropic", + SpanAttributes.LLM_REQUEST_TYPE: LLMRequestTypeValues.COMPLETION.value, + }, + ) + + if span.is_recording(): + run_async(_aset_input_attributes(span, kwargs)) + + start_time = time.time() + try: + response = wrapped(*args, **kwargs) + except Exception as e: # pylint: disable=broad-except + end_time = time.time() + attributes = error_metrics_attributes(e) + + if duration_histogram: + duration = end_time - start_time + duration_histogram.record(duration, attributes=attributes) + + if exception_counter: + exception_counter.add(1, attributes=attributes) + + raise e + + end_time = time.time() + + if is_streaming_response(response): + return build_from_streaming_response( + span, + response, + instance._client, + start_time, + token_histogram, + choice_counter, + duration_histogram, + exception_counter, + kwargs, + ) + elif response: + try: + metric_attributes = shared_metrics_attributes(response) + + if duration_histogram: + duration = time.time() - start_time + duration_histogram.record( + duration, + attributes=metric_attributes, + ) + + if span.is_recording(): + _set_response_attributes(span, response) + _set_token_usage( + span, + instance._client, + kwargs, + response, + metric_attributes, + token_histogram, + choice_counter, + ) + + except Exception as ex: # pylint: disable=broad-except + logger.warning( + "Failed to set response attributes for anthropic span, error: %s", + str(ex), + ) + if span.is_recording(): + span.set_status(Status(StatusCode.OK)) + span.end() + return response + + +@_with_chat_telemetry_wrapper +async def _awrap( + tracer, + token_histogram: Histogram, + choice_counter: Counter, + duration_histogram: Histogram, + exception_counter: Counter, + to_wrap, + wrapped, + instance, + args, + kwargs, +): + """Instruments and calls every function defined in TO_WRAP.""" + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) or context_api.get_value( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + ): + return await wrapped(*args, **kwargs) + + name = to_wrap.get("span_name") + span = tracer.start_span( + name, + kind=SpanKind.CLIENT, + attributes={ + SpanAttributes.LLM_SYSTEM: "Anthropic", + SpanAttributes.LLM_REQUEST_TYPE: LLMRequestTypeValues.COMPLETION.value, + }, + ) + try: + if span.is_recording(): + await _aset_input_attributes(span, kwargs) + + except Exception as ex: # pylint: disable=broad-except + logger.warning( + "Failed to set input attributes for anthropic span, error: %s", str(ex) + ) + + start_time = time.time() + try: + response = await wrapped(*args, **kwargs) + except Exception as e: # pylint: disable=broad-except + end_time = time.time() + attributes = error_metrics_attributes(e) + + if duration_histogram: + duration = end_time - start_time + duration_histogram.record(duration, attributes=attributes) + + if exception_counter: + exception_counter.add(1, attributes=attributes) + + raise e + + if is_streaming_response(response): + return abuild_from_streaming_response( + span, + response, + instance._client, + start_time, + token_histogram, + choice_counter, + duration_histogram, + exception_counter, + kwargs, + ) + elif response: + metric_attributes = shared_metrics_attributes(response) + + if duration_histogram: + duration = time.time() - start_time + duration_histogram.record( + duration, + attributes=metric_attributes, + ) + + if span.is_recording(): + _set_response_attributes(span, response) + await _aset_token_usage( + span, + instance._client, + kwargs, + response, + metric_attributes, + token_histogram, + choice_counter, + ) + + if span.is_recording(): + span.set_status(Status(StatusCode.OK)) + span.end() + return response + + +def is_metrics_enabled() -> bool: + return (os.getenv("TRACELOOP_METRICS_ENABLED") or "true").lower() == "true" + + +class AnthropicInstrumentor(BaseInstrumentor): + """An instrumentor for Anthropic's client library.""" + + def __init__( + self, + enrich_token_usage: bool = False, + exception_logger=None, + get_common_metrics_attributes: Callable[[], dict] = lambda: {}, + upload_base64_image: Optional[ + Callable[[str, str, str, str], Coroutine[None, None, str]] + ] = None, + ): + super().__init__() + Config.exception_logger = exception_logger + Config.enrich_token_usage = enrich_token_usage + Config.get_common_metrics_attributes = get_common_metrics_attributes + Config.upload_base64_image = upload_base64_image + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + tracer_provider = kwargs.get("tracer_provider") + tracer = get_tracer(__name__, __version__, tracer_provider) + + # meter and counters are inited here + meter_provider = kwargs.get("meter_provider") + meter = get_meter(__name__, __version__, meter_provider) + + if is_metrics_enabled(): + ( + token_histogram, + choice_counter, + duration_histogram, + exception_counter, + ) = _create_metrics(meter) + else: + ( + token_histogram, + choice_counter, + duration_histogram, + exception_counter, + ) = (None, None, None, None) + + for wrapped_method in WRAPPED_METHODS: + wrap_package = wrapped_method.get("package") + wrap_object = wrapped_method.get("object") + wrap_method = wrapped_method.get("method") + + try: + wrap_function_wrapper( + wrap_package, + f"{wrap_object}.{wrap_method}", + _wrap( + tracer, + token_histogram, + choice_counter, + duration_histogram, + exception_counter, + wrapped_method, + ), + ) + except ModuleNotFoundError: + pass # that's ok, we don't want to fail if some methods do not exist + + for wrapped_method in WRAPPED_AMETHODS: + wrap_package = wrapped_method.get("package") + wrap_object = wrapped_method.get("object") + wrap_method = wrapped_method.get("method") + try: + wrap_function_wrapper( + wrap_package, + f"{wrap_object}.{wrap_method}", + _awrap( + tracer, + token_histogram, + choice_counter, + duration_histogram, + exception_counter, + wrapped_method, + ), + ) + except ModuleNotFoundError: + pass # that's ok, we don't want to fail if some methods do not exist + + def _uninstrument(self, **kwargs): + for wrapped_method in WRAPPED_METHODS: + wrap_package = wrapped_method.get("package") + wrap_object = wrapped_method.get("object") + unwrap( + f"{wrap_package}.{wrap_object}", + wrapped_method.get("method"), + ) + for wrapped_method in WRAPPED_AMETHODS: + wrap_package = wrapped_method.get("package") + wrap_object = wrapped_method.get("object") + unwrap( + f"{wrap_package}.{wrap_object}", + wrapped_method.get("method"), + ) diff --git a/third_party/opentelemetry/instrumentation/anthropic/config.py b/third_party/opentelemetry/instrumentation/anthropic/config.py new file mode 100644 index 000000000..5eff0b909 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/anthropic/config.py @@ -0,0 +1,11 @@ +from typing import Callable, Optional +from typing_extensions import Coroutine + + +class Config: + enrich_token_usage = False + exception_logger = None + get_common_metrics_attributes: Callable[[], dict] = lambda: {} + upload_base64_image: Optional[ + Callable[[str, str, str, str], Coroutine[None, None, str]] + ] = None diff --git a/third_party/opentelemetry/instrumentation/anthropic/streaming.py b/third_party/opentelemetry/instrumentation/anthropic/streaming.py new file mode 100644 index 000000000..ce4f219fb --- /dev/null +++ b/third_party/opentelemetry/instrumentation/anthropic/streaming.py @@ -0,0 +1,272 @@ +import logging +import time + +from opentelemetry.instrumentation.anthropic.config import Config +from opentelemetry.instrumentation.anthropic.utils import ( + dont_throw, + error_metrics_attributes, + count_prompt_tokens_from_request, + set_span_attribute, + shared_metrics_attributes, + should_send_prompts, +) +from opentelemetry.metrics import Counter, Histogram +from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_RESPONSE_ID +from agentops.semconv import SpanAttributes +from opentelemetry.trace.status import Status, StatusCode + +logger = logging.getLogger(__name__) + + +@dont_throw +def _process_response_item(item, complete_response): + if item.type == "message_start": + complete_response["model"] = item.message.model + complete_response["usage"] = dict(item.message.usage) + complete_response["id"] = item.message.id + elif item.type == "content_block_start": + index = item.index + if len(complete_response.get("events")) <= index: + complete_response["events"].append({"index": index, "text": ""}) + elif item.type == "content_block_delta" and item.delta.type == "text_delta": + index = item.index + complete_response.get("events")[index]["text"] += item.delta.text + elif item.type == "message_delta": + for event in complete_response.get("events", []): + event["finish_reason"] = item.delta.stop_reason + if item.usage: + if "usage" in complete_response: + item_output_tokens = dict(item.usage).get("output_tokens", 0) + existing_output_tokens = complete_response["usage"].get("output_tokens", 0) + complete_response["usage"]["output_tokens"] = item_output_tokens + existing_output_tokens + else: + complete_response["usage"] = dict(item.usage) + + +def _set_token_usage( + span, + complete_response, + prompt_tokens, + completion_tokens, + metric_attributes: dict = {}, + token_histogram: Histogram = None, + choice_counter: Counter = None, +): + cache_read_tokens = complete_response.get("usage", {}).get("cache_read_input_tokens", 0) + cache_creation_tokens = complete_response.get("usage", {}).get("cache_creation_input_tokens", 0) + + input_tokens = prompt_tokens + cache_read_tokens + cache_creation_tokens + total_tokens = input_tokens + completion_tokens + + set_span_attribute(span, SpanAttributes.LLM_USAGE_PROMPT_TOKENS, input_tokens) + set_span_attribute( + span, SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, completion_tokens + ) + set_span_attribute(span, SpanAttributes.LLM_USAGE_TOTAL_TOKENS, total_tokens) + + set_span_attribute( + span, SpanAttributes.LLM_RESPONSE_MODEL, complete_response.get("model") + ) + set_span_attribute( + span, SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS, cache_read_tokens + ) + set_span_attribute( + span, SpanAttributes.LLM_USAGE_CACHE_CREATION_INPUT_TOKENS, cache_creation_tokens + ) + + if token_histogram and type(input_tokens) is int and input_tokens >= 0: + token_histogram.record( + input_tokens, + attributes={ + **metric_attributes, + SpanAttributes.LLM_TOKEN_TYPE: "input", + }, + ) + + if token_histogram and type(completion_tokens) is int and completion_tokens >= 0: + token_histogram.record( + completion_tokens, + attributes={ + **metric_attributes, + SpanAttributes.LLM_TOKEN_TYPE: "output", + }, + ) + + if type(complete_response.get("events")) is list and choice_counter: + for event in complete_response.get("events"): + choice_counter.add( + 1, + attributes={ + **metric_attributes, + SpanAttributes.LLM_RESPONSE_FINISH_REASON: event.get( + "finish_reason" + ), + }, + ) + + +def _set_completions(span, events): + if not span.is_recording() or not events: + return + + try: + for event in events: + index = event.get("index") + prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}" + set_span_attribute( + span, f"{prefix}.finish_reason", event.get("finish_reason") + ) + set_span_attribute(span, f"{prefix}.content", event.get("text")) + except Exception as e: + logger.warning("Failed to set completion attributes, error: %s", str(e)) + + +@dont_throw +def build_from_streaming_response( + span, + response, + instance, + start_time, + token_histogram: Histogram = None, + choice_counter: Counter = None, + duration_histogram: Histogram = None, + exception_counter: Counter = None, + kwargs: dict = {}, +): + complete_response = {"events": [], "model": "", "usage": {}, "id": ""} + for item in response: + try: + yield item + except Exception as e: + attributes = error_metrics_attributes(e) + if exception_counter: + exception_counter.add(1, attributes=attributes) + raise e + _process_response_item(item, complete_response) + + metric_attributes = shared_metrics_attributes(complete_response) + set_span_attribute(span, GEN_AI_RESPONSE_ID, complete_response.get("id")) + if duration_histogram: + duration = time.time() - start_time + duration_histogram.record( + duration, + attributes=metric_attributes, + ) + + # calculate token usage + if Config.enrich_token_usage: + try: + completion_tokens = -1 + # prompt_usage + if usage := complete_response.get("usage"): + prompt_tokens = usage.get("input_tokens", 0) + else: + prompt_tokens = count_prompt_tokens_from_request(instance, kwargs) + + # completion_usage + if usage := complete_response.get("usage"): + completion_tokens = usage.get("output_tokens", 0) + else: + completion_content = "" + if complete_response.get("events"): + model_name = complete_response.get("model") or None + for event in complete_response.get("events"): # type: dict + if event.get("text"): + completion_content += event.get("text") + + if model_name: + completion_tokens = instance.count_tokens(completion_content) + + _set_token_usage( + span, + complete_response, + prompt_tokens, + completion_tokens, + metric_attributes, + token_histogram, + choice_counter, + ) + except Exception as e: + logger.warning("Failed to set token usage, error: %s", e) + + if should_send_prompts(): + _set_completions(span, complete_response.get("events")) + + span.set_status(Status(StatusCode.OK)) + span.end() + + +@dont_throw +async def abuild_from_streaming_response( + span, + response, + instance, + start_time, + token_histogram: Histogram = None, + choice_counter: Counter = None, + duration_histogram: Histogram = None, + exception_counter: Counter = None, + kwargs: dict = {}, +): + complete_response = {"events": [], "model": "", "usage": {}, "id": ""} + async for item in response: + try: + yield item + except Exception as e: + attributes = error_metrics_attributes(e) + if exception_counter: + exception_counter.add(1, attributes=attributes) + raise e + _process_response_item(item, complete_response) + + set_span_attribute(span, GEN_AI_RESPONSE_ID, complete_response.get("id")) + + metric_attributes = shared_metrics_attributes(complete_response) + + if duration_histogram: + duration = time.time() - start_time + duration_histogram.record( + duration, + attributes=metric_attributes, + ) + + # calculate token usage + if Config.enrich_token_usage: + try: + # prompt_usage + if usage := complete_response.get("usage"): + prompt_tokens = usage.get("input_tokens", 0) + else: + prompt_tokens = count_prompt_tokens_from_request(instance, kwargs) + + # completion_usage + if usage := complete_response.get("usage"): + completion_tokens = usage.get("output_tokens", 0) + else: + completion_content = "" + if complete_response.get("events"): + model_name = complete_response.get("model") or None + for event in complete_response.get("events"): # type: dict + if event.get("text"): + completion_content += event.get("text") + + if model_name: + completion_tokens = instance.count_tokens(completion_content) + + _set_token_usage( + span, + complete_response, + prompt_tokens, + completion_tokens, + metric_attributes, + token_histogram, + choice_counter, + ) + except Exception as e: + logger.warning("Failed to set token usage, error: %s", str(e)) + + if should_send_prompts(): + _set_completions(span, complete_response.get("events")) + + span.set_status(Status(StatusCode.OK)) + span.end() diff --git a/third_party/opentelemetry/instrumentation/anthropic/utils.py b/third_party/opentelemetry/instrumentation/anthropic/utils.py new file mode 100644 index 000000000..2153ece5d --- /dev/null +++ b/third_party/opentelemetry/instrumentation/anthropic/utils.py @@ -0,0 +1,135 @@ +import asyncio +import os +import logging +import threading +import traceback +from opentelemetry import context as context_api +from opentelemetry.instrumentation.anthropic.config import Config +from agentops.semconv import SpanAttributes + +GEN_AI_SYSTEM = "gen_ai.system" +GEN_AI_SYSTEM_ANTHROPIC = "anthropic" + + +def set_span_attribute(span, name, value): + if value is not None: + if value != "": + span.set_attribute(name, value) + return + + +def should_send_prompts(): + return ( + os.getenv("TRACELOOP_TRACE_CONTENT") or "true" + ).lower() == "true" or context_api.get_value("override_enable_content_tracing") + + +def dont_throw(func): + """ + A decorator that wraps the passed in function and logs exceptions instead of throwing them. + Works for both synchronous and asynchronous functions. + """ + logger = logging.getLogger(func.__module__) + + async def async_wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except Exception as e: + _handle_exception(e, func, logger) + + def sync_wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + _handle_exception(e, func, logger) + + def _handle_exception(e, func, logger): + logger.debug( + "OpenLLMetry failed to trace in %s, error: %s", + func.__name__, + traceback.format_exc(), + ) + if Config.exception_logger: + Config.exception_logger(e) + + return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper + + +@dont_throw +def shared_metrics_attributes(response): + if not isinstance(response, dict): + response = response.__dict__ + + common_attributes = Config.get_common_metrics_attributes() + + return { + **common_attributes, + GEN_AI_SYSTEM: GEN_AI_SYSTEM_ANTHROPIC, + SpanAttributes.LLM_RESPONSE_MODEL: response.get("model"), + } + + +@dont_throw +def error_metrics_attributes(exception): + return { + GEN_AI_SYSTEM: GEN_AI_SYSTEM_ANTHROPIC, + "error.type": exception.__class__.__name__, + } + + +@dont_throw +def count_prompt_tokens_from_request(anthropic, request): + prompt_tokens = 0 + if hasattr(anthropic, "count_tokens"): + if request.get("prompt"): + prompt_tokens = anthropic.count_tokens(request.get("prompt")) + elif messages := request.get("messages"): + prompt_tokens = 0 + for m in messages: + content = m.get("content") + if isinstance(content, str): + prompt_tokens += anthropic.count_tokens(content) + elif isinstance(content, list): + for item in content: + # TODO: handle image and tool tokens + if isinstance(item, dict) and item.get("type") == "text": + prompt_tokens += anthropic.count_tokens( + item.get("text", "") + ) + return prompt_tokens + + +@dont_throw +async def acount_prompt_tokens_from_request(anthropic, request): + prompt_tokens = 0 + if hasattr(anthropic, "count_tokens"): + if request.get("prompt"): + prompt_tokens = await anthropic.count_tokens(request.get("prompt")) + elif messages := request.get("messages"): + prompt_tokens = 0 + for m in messages: + content = m.get("content") + if isinstance(content, str): + prompt_tokens += await anthropic.count_tokens(content) + elif isinstance(content, list): + for item in content: + # TODO: handle image and tool tokens + if isinstance(item, dict) and item.get("type") == "text": + prompt_tokens += await anthropic.count_tokens( + item.get("text", "") + ) + return prompt_tokens + + +def run_async(method): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop and loop.is_running(): + thread = threading.Thread(target=lambda: asyncio.run(method)) + thread.start() + thread.join() + else: + asyncio.run(method) diff --git a/third_party/opentelemetry/instrumentation/anthropic/version.py b/third_party/opentelemetry/instrumentation/anthropic/version.py new file mode 100644 index 000000000..703f9571b --- /dev/null +++ b/third_party/opentelemetry/instrumentation/anthropic/version.py @@ -0,0 +1 @@ +__version__ = "0.38.7" diff --git a/third_party/opentelemetry/instrumentation/autogen/README.md b/third_party/opentelemetry/instrumentation/autogen/README.md new file mode 100644 index 000000000..aee0a15f4 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/autogen/README.md @@ -0,0 +1,140 @@ +# AutoGen Instrumentation for AgentOps + +This package provides OpenTelemetry instrumentation for [AutoGen](https://github.com/microsoft/autogen), enabling detailed tracing and metrics collection for AutoGen agents and their interactions. + +## Features + +- Traces agent initialization and configuration +- Captures message exchanges between agents +- Monitors LLM API calls and token usage +- Tracks tool/function execution +- Observes group chat interactions +- Collects performance metrics + +## Installation + +The instrumentation is included as part of the AgentOps package. No separate installation is required. + +## Usage + +### Basic Usage + +```python +import agentops +from opentelemetry.instrumentation.autogen import AutoGenInstrumentor +import autogen + +# Initialize AgentOps +agentops.init(api_key="your-api-key") + +# Start a session +session = agentops.start_session() + +# Instrument AutoGen +instrumentor = AutoGenInstrumentor() +instrumentor.instrument() + +# Create and use AutoGen agents as usual +assistant = autogen.AssistantAgent( + name="assistant", + llm_config={"model": "gpt-4"} +) + +user_proxy = autogen.UserProxyAgent( + name="user_proxy", + code_execution_config={"use_docker": False} +) + +# Start a conversation +user_proxy.initiate_chat( + assistant, + message="Hello, can you help me solve a math problem?" +) + +# End the session when done +agentops.end_session("success") +``` + +### Uninstrumenting + +To remove the instrumentation: + +```python +instrumentor.uninstrument() +``` + +## Captured Spans + +The instrumentation captures the following key spans: + +- `autogen.agent.generate_reply`: Message generation - high-level view of message exchanges +- `autogen.agent.generate_oai_reply`: LLM API calls - captures token usage and model information +- `autogen.agent.execute_function`: Tool/function execution - tracks tool usage +- `autogen.team.groupchat.run`: Group chat execution - for multi-agent scenarios + +These spans were carefully selected to provide comprehensive tracing while minimizing overhead. We've removed redundant spans that were generating excessive telemetry data. + +## Metrics + +The instrumentation collects the following metrics: + +- `autogen.llm.token_usage`: Token usage for LLM calls +- `autogen.operation.duration`: Duration of various operations + +## Attributes + +Each span includes relevant attributes such as: + +### For `autogen.agent.generate_reply`: +- Agent name, description, and sender +- System message content +- Input messages (content, source, type) +- Agent state information (message count, tool count) +- Message content and count +- LLM model and configuration (temperature, max_tokens, etc.) +- Token usage (total, prompt, and completion tokens) - extracted using multiple approaches +- Function call information (name, arguments) +- Estimated token counts when actual counts aren't available +- Token usage availability flag (`llm.token_usage.found`) + +### For `autogen.agent.generate_oai_reply`: +- Agent name and description +- System message content +- LLM model and provider +- Detailed configuration (temperature, max_tokens, top_p, etc.) +- Input messages (role, content, function calls) +- Input message count and estimated token count +- Model context information (buffer size) +- Tools information (count, names) +- Output content and estimated token count +- Response finish reason +- Actual token usage (total, prompt, and completion tokens) - extracted using multiple approaches +- Estimated cost in USD (for OpenAI models) +- Function call information (name, arguments) +- Token usage availability flag (`llm.token_usage.found`) + +### For `autogen.agent.execute_function`: +- Agent name +- Tool name and arguments +- Execution result and duration + +### For `autogen.team.groupchat.run`: +- Team name +- Number of agents in the group +- Execution duration + +## Debugging Token Usage + +If token information isn't appearing in your spans, you can check the `llm.token_usage.found` attribute in spans to see if token usage was found. The instrumentation attempts multiple approaches to extract token usage information, adapting to different AutoGen versions and response structures. + +## Example + +See the `autogentest.py` file for a comprehensive example of using the instrumentation with different AutoGen features. + +## Compatibility + +This instrumentation is compatible with AutoGen versions 0.2.x and later. + +## License + +This instrumentation is part of the AgentOps package and is subject to the same license terms. \ No newline at end of file diff --git a/third_party/opentelemetry/instrumentation/autogen/__init__.py b/third_party/opentelemetry/instrumentation/autogen/__init__.py new file mode 100644 index 000000000..dbe6c40e2 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/autogen/__init__.py @@ -0,0 +1,10 @@ +""" +OpenTelemetry AutoGen Instrumentation. + +This package provides instrumentation for AutoGen, enabling tracing of agent operations. +""" + +from .instrumentation import AutoGenInstrumentor +from .version import __version__ + +__all__ = ["AutoGenInstrumentor"] diff --git a/third_party/opentelemetry/instrumentation/autogen/autogen_span_attributes.py b/third_party/opentelemetry/instrumentation/autogen/autogen_span_attributes.py new file mode 100644 index 000000000..f0737b85b --- /dev/null +++ b/third_party/opentelemetry/instrumentation/autogen/autogen_span_attributes.py @@ -0,0 +1,168 @@ +from opentelemetry.trace import Span +from typing import Any, Dict, List, Optional, Sequence, Union + +# Define semantic conventions for AutoGen spans +class AutoGenSpanAttributes: + """Class to set span attributes for AutoGen components.""" + + def __init__(self, span: Span, instance) -> None: + """Initialize with a span and an AutoGen instance.""" + self.span = span + self.instance = instance + self.autogen_data = { + "agents": [], + "tools": [], + "messages": [], + "llm_config": {} + } + self.process_instance() + + def process_instance(self): + """Process the instance based on its type.""" + instance_type = self.instance.__class__.__name__ + method_mapping = { + "AssistantAgent": self._process_assistant_agent, + "UserProxyAgent": self._process_user_proxy_agent, + "GroupChat": self._process_group_chat, + "GroupChatManager": self._process_group_chat_manager, + } + method = method_mapping.get(instance_type) + if method: + method() + + def _process_assistant_agent(self): + """Process an AssistantAgent instance.""" + self._set_attribute("agent.type", "assistant") + self._set_attribute("agent.name", getattr(self.instance, "name", "unknown")) + + # Extract LLM config if available + llm_config = getattr(self.instance, "llm_config", {}) + if llm_config: + self._set_attribute("agent.llm_config.model", llm_config.get("model", "unknown")) + self._set_attribute("agent.llm_config.temperature", llm_config.get("temperature", 0.7)) + + # Extract system message if available + system_message = getattr(self.instance, "system_message", "") + if system_message: + self._set_attribute("agent.system_message", system_message) + + # Extract tools if available + tools = [] + if hasattr(self.instance, "function_map"): + tools = list(getattr(self.instance, "function_map", {}).keys()) + self._set_attribute("agent.tools", tools) + + def _process_user_proxy_agent(self): + """Process a UserProxyAgent instance.""" + self._set_attribute("agent.type", "user_proxy") + self._set_attribute("agent.name", getattr(self.instance, "name", "unknown")) + + # Extract code execution config if available + code_execution_config = getattr(self.instance, "code_execution_config", {}) + if code_execution_config: + self._set_attribute("agent.code_execution.use_docker", + code_execution_config.get("use_docker", False)) + self._set_attribute("agent.code_execution.work_dir", + code_execution_config.get("work_dir", "")) + + def _process_group_chat(self): + """Process a GroupChat instance.""" + self._set_attribute("team.type", "group_chat") + + # Extract agents if available + agents = getattr(self.instance, "agents", []) + agent_names = [getattr(agent, "name", "unknown") for agent in agents] + self._set_attribute("team.agents", agent_names) + + # Extract speaker selection method if available + selection_method = getattr(self.instance, "speaker_selection_method", "") + if selection_method: + self._set_attribute("team.speaker_selection_method", selection_method) + + def _process_group_chat_manager(self): + """Process a GroupChatManager instance.""" + self._set_attribute("team.type", "group_chat_manager") + self._set_attribute("team.name", getattr(self.instance, "name", "unknown")) + + # Extract group chat if available + group_chat = getattr(self.instance, "groupchat", None) + if group_chat: + self._process_group_chat_from_manager(group_chat) + + def _process_group_chat_from_manager(self, group_chat): + """Process a GroupChat instance from a manager.""" + agents = getattr(group_chat, "agents", []) + agent_names = [getattr(agent, "name", "unknown") for agent in agents] + self._set_attribute("team.agents", agent_names) + + selection_method = getattr(group_chat, "speaker_selection_method", "") + if selection_method: + self._set_attribute("team.speaker_selection_method", selection_method) + + def _set_attribute(self, key, value): + """Set an attribute on the span if the value is not None.""" + if value is not None: + if isinstance(value, (list, dict)): + # Convert complex types to strings to ensure they can be stored as span attributes + self.span.set_attribute(key, str(value)) + else: + self.span.set_attribute(key, value) + + +def set_span_attribute(span: Span, name, value): + """Helper function to set a span attribute if the value is not None.""" + if value is not None: + if isinstance(value, (list, dict)): + # Convert complex types to strings + span.set_attribute(name, str(value)) + else: + span.set_attribute(name, value) + + +def extract_message_attributes(message): + """Extract attributes from a message.""" + attributes = {} + + # Extract content + if hasattr(message, "content"): + content = message.content + if isinstance(content, str): + # Truncate long content to avoid excessive span size + attributes["message.content"] = ( + content[:1000] + "..." if len(content) > 1000 else content + ) + + # Extract role + if hasattr(message, "role"): + attributes["message.role"] = message.role + + # Extract name + if hasattr(message, "name"): + attributes["message.name"] = message.name + + # Extract tool calls + if hasattr(message, "tool_calls") and message.tool_calls: + tool_names = [] + for tool_call in message.tool_calls: + if hasattr(tool_call, "function") and hasattr(tool_call.function, "name"): + tool_names.append(tool_call.function.name) + if tool_names: + attributes["message.tool_calls"] = str(tool_names) + + return attributes + + +def extract_token_usage(response): + """Extract token usage from a response.""" + usage = {} + + if hasattr(response, "usage"): + response_usage = response.usage + if hasattr(response_usage, "prompt_tokens"): + usage["prompt_tokens"] = response_usage.prompt_tokens + if hasattr(response_usage, "completion_tokens"): + usage["completion_tokens"] = response_usage.completion_tokens + if hasattr(response_usage, "total_tokens"): + usage["total_tokens"] = response_usage.total_tokens + + return usage \ No newline at end of file diff --git a/third_party/opentelemetry/instrumentation/autogen/instrumentation.py b/third_party/opentelemetry/instrumentation/autogen/instrumentation.py new file mode 100644 index 000000000..120fc6886 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/autogen/instrumentation.py @@ -0,0 +1,818 @@ +import functools +import logging +import time +import asyncio +import json +from typing import Collection, Optional, Dict, Any + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.metrics import Histogram, Meter, get_meter +from opentelemetry.trace import Span, SpanKind, Tracer, get_tracer +from wrapt import wrap_function_wrapper + +from agentops.semconv import AgentOpsSpanKindValues, SpanAttributes +from .autogen_span_attributes import ( + AutoGenSpanAttributes, + extract_message_attributes, + extract_token_usage, + set_span_attribute +) +from .version import __version__ + +logger = logging.getLogger(__name__) + +# Define constants for metrics +class Meters: + LLM_TOKEN_USAGE = "autogen.llm.token_usage" + LLM_OPERATION_DURATION = "autogen.operation.duration" + + +class AutoGenInstrumentor(BaseInstrumentor): + """An instrumentor for AutoGen.""" + + def instrumentation_dependencies(self) -> Collection[str]: + return ["autogen"] + + def _instrument(self, **kwargs): + """Instrument AutoGen.""" + tracer_provider = kwargs.get("tracer_provider") + meter_provider = kwargs.get("meter_provider") + + tracer = get_tracer(__name__, __version__, tracer_provider) + meter = get_meter(__name__, __version__, meter_provider) + + # Create metrics if enabled + if is_metrics_enabled(): + token_histogram, duration_histogram = _create_metrics(meter) + else: + token_histogram, duration_histogram = None, None + + logger.info("Instrumenting AutoGen") + + # Keep generate_reply as it provides high-level message generation info + try: + # Message generation + wrap_function_wrapper( + "autogen.agentchat.conversable_agent", + "ConversableAgent.generate_reply", + wrap_generate_reply(tracer, token_histogram, duration_histogram) + ) + logger.info("Instrumented ConversableAgent.generate_reply") + except Exception as e: + logger.warning(f"Failed to instrument ConversableAgent.generate_reply: {e}") + + # LLM API calls - Use generate_oai_reply instead of _generate_oai_reply + try: + wrap_function_wrapper( + "autogen.agentchat.conversable_agent", + "ConversableAgent.generate_oai_reply", + wrap_generate_oai_reply(tracer, token_histogram, duration_histogram) + ) + logger.info("Instrumented ConversableAgent.generate_oai_reply") + except Exception as e: + logger.warning(f"Failed to instrument ConversableAgent.generate_oai_reply: {e}") + + # Tool execution - Use execute_function instead of _call_function + try: + wrap_function_wrapper( + "autogen.agentchat.conversable_agent", + "ConversableAgent.execute_function", + wrap_call_function(tracer, duration_histogram, token_histogram) + ) + logger.info("Instrumented ConversableAgent.execute_function") + except Exception as e: + logger.warning(f"Failed to instrument ConversableAgent.execute_function: {e}") + + # Group chat - Check if GroupChat.run exists before instrumenting + try: + import autogen.agentchat.groupchat + wrap_function_wrapper( + "autogen.agentchat.groupchat", + "GroupChat.run", + wrap_groupchat_run(tracer, duration_histogram, token_histogram) + ) + logger.info("Instrumented GroupChat.run") + except Exception as e: + logger.warning(f"Failed to instrument GroupChat.run: {e}") + + logger.info("AutoGen instrumentation complete") + + def _uninstrument(self, **kwargs): + """Uninstrument AutoGen.""" + logger.info("Uninstrumenting AutoGen") + + # Uninstrument agent initialization + unwrap_all_agent_methods() + + logger.info("AutoGen uninstrumentation complete") + + +def unwrap_all_agent_methods(): + """Unwrap all instrumented methods.""" + from wrapt import unwrap + + try: + import autogen + # Removed: unwrap(autogen.agentchat.conversable_agent.ConversableAgent, "__init__") + unwrap(autogen.agentchat.conversable_agent.ConversableAgent, "generate_reply") + unwrap(autogen.agentchat.conversable_agent.ConversableAgent, "generate_oai_reply") + unwrap(autogen.agentchat.conversable_agent.ConversableAgent, "execute_function") + unwrap(autogen.agentchat.groupchat.GroupChat, "run") + except (AttributeError, NameError, ImportError) as e: + logger.warning(f"Error during unwrapping: {e}") + pass + + +def with_tracer_wrapper(func): + """Decorator to create a wrapper function with tracer and metrics.""" + @functools.wraps(func) + def _with_tracer(tracer, duration_histogram=None, token_histogram=None): + @functools.wraps(func) + def wrapper(wrapped, instance, args, kwargs): + return func(tracer, duration_histogram, token_histogram, wrapped, instance, args, kwargs) + return wrapper + return _with_tracer + + +@with_tracer_wrapper +def wrap_agent_init(tracer: Tracer, duration_histogram: Optional[Histogram], token_histogram: Optional[Histogram], + wrapped, instance, args, kwargs): + """Wrap agent initialization.""" + logger.debug(f"Creating span for agent initialization: {getattr(instance, 'name', 'unknown')}") + with tracer.start_as_current_span( + "autogen.agent.init", + kind=SpanKind.INTERNAL, + attributes={ + SpanAttributes.AGENTOPS_SPAN_KIND: AgentOpsSpanKindValues.AGENT.value, + } + ) as span: + # Capture agent attributes + result = wrapped(*args, **kwargs) + + # Set span attributes after initialization + AutoGenSpanAttributes(span, instance) + logger.debug(f"Agent initialization span completed for: {getattr(instance, 'name', 'unknown')}") + + return result + + +@with_tracer_wrapper +def wrap_generate_reply(tracer: Tracer, duration_histogram: Optional[Histogram], token_histogram: Optional[Histogram], + wrapped, instance, args, kwargs): + """Wrap generate_reply method.""" + messages = args[0] if args else kwargs.get("messages", []) + sender = args[1] if len(args) > 1 else kwargs.get("sender", "unknown") + + with tracer.start_as_current_span( + "autogen.agent.generate_reply", + kind=SpanKind.CLIENT, + attributes={ + SpanAttributes.AGENTOPS_SPAN_KIND: AgentOpsSpanKindValues.AGENT.value, + "agent.name": getattr(instance, "name", "unknown"), + "agent.sender": getattr(sender, "name", str(sender)), + "agent.message_count": len(messages) if isinstance(messages, list) else 1, + "agent.description": getattr(instance, "description", ""), + } + ) as span: + # Add LLM configuration information + llm_config = getattr(instance, "llm_config", {}) + if llm_config: + set_span_attribute(span, "llm.model", llm_config.get("model", "unknown")) + set_span_attribute(span, "llm.temperature", llm_config.get("temperature", 0.7)) + set_span_attribute(span, "llm.provider", "openai") # Default to OpenAI, could be different + + # Add any other LLM config parameters that might be useful + for key in ["max_tokens", "top_p", "frequency_penalty", "presence_penalty"]: + if key in llm_config: + set_span_attribute(span, f"llm.{key}", llm_config.get(key)) + + # Capture system message if available + system_message = getattr(instance, "system_message", None) + if system_message: + set_span_attribute(span, "agent.system_message", + system_message[:1000] + "..." if len(system_message) > 1000 else system_message) + + # Capture input messages + if messages and isinstance(messages, list): + for i, msg in enumerate(messages[:5]): # Limit to first 5 messages to avoid excessive data + if hasattr(msg, "content") and msg.content: + content = str(msg.content) + set_span_attribute(span, f"input.message.{i}.content", + content[:500] + "..." if len(content) > 500 else content) + if hasattr(msg, "source"): + set_span_attribute(span, f"input.message.{i}.source", getattr(msg, "source", "unknown")) + if hasattr(msg, "type"): + set_span_attribute(span, f"input.message.{i}.type", getattr(msg, "type", "unknown")) + + # Capture agent state information if available + if hasattr(instance, "save_state"): + try: + state = asyncio.run(instance.save_state()) + if state: + # Extract key state information without capturing everything + if "messages" in state and isinstance(state["messages"], list): + set_span_attribute(span, "agent.state.message_count", len(state["messages"])) + if "tools" in state and isinstance(state["tools"], list): + set_span_attribute(span, "agent.state.tool_count", len(state["tools"])) + except Exception: + pass + + start_time = time.time() + result = wrapped(*args, **kwargs) + duration = time.time() - start_time + + # Record duration metric + if duration_histogram: + duration_histogram.record(duration, {"operation": "generate_reply"}) + + # Extract and record token usage using multiple approaches + token_usage_found = False + + # Set message attributes + if result: + # Approach 1: Standard dictionary structure + if isinstance(result, dict): + # Extract and record message content + if "content" in result and result["content"] is not None: + content = result["content"] + set_span_attribute(span, "message.content", + content[:1000] + "..." if len(content) > 1000 else content) + + # Extract and record token usage + if "usage" in result: + usage = result["usage"] + token_usage_found = True + + if token_histogram and "total_tokens" in usage: + token_histogram.record(usage["total_tokens"], {"operation": "generate_reply"}) + + set_span_attribute(span, "llm.token_usage.total", usage.get("total_tokens")) + set_span_attribute(span, "llm.token_usage.prompt", usage.get("prompt_tokens")) + set_span_attribute(span, "llm.token_usage.completion", usage.get("completion_tokens")) + + # Check for function calls in the response + if "function_call" in result: + set_span_attribute(span, "message.has_function_call", True) + function_call = result["function_call"] + if isinstance(function_call, dict): + set_span_attribute(span, "message.function_call.name", function_call.get("name", "unknown")) + args_str = str(function_call.get("arguments", "{}")) + set_span_attribute(span, "message.function_call.arguments", + args_str[:500] + "..." if len(args_str) > 500 else args_str) + + # Approach 2: Object with attributes + elif hasattr(result, "content"): + content = result.content + set_span_attribute(span, "message.content", + content[:1000] + "..." if len(content) > 1000 else content) + + # Try to get usage from result object + if hasattr(result, "usage"): + usage = result.usage + token_usage_found = True + + # Try to extract token counts + if hasattr(usage, "total_tokens"): + set_span_attribute(span, "llm.token_usage.total", usage.total_tokens) + if token_histogram: + token_histogram.record(usage.total_tokens, {"operation": "generate_reply"}) + if hasattr(usage, "prompt_tokens"): + set_span_attribute(span, "llm.token_usage.prompt", usage.prompt_tokens) + if hasattr(usage, "completion_tokens"): + set_span_attribute(span, "llm.token_usage.completion", usage.completion_tokens) + + # Approach 3: Try to get usage from the instance + if not token_usage_found and hasattr(instance, "get_actual_usage"): + try: + usage = instance.get_actual_usage() + if usage: + token_usage_found = True + set_span_attribute(span, "llm.token_usage.total", usage.get("total_tokens")) + set_span_attribute(span, "llm.token_usage.prompt", usage.get("prompt_tokens")) + set_span_attribute(span, "llm.token_usage.completion", usage.get("completion_tokens")) + except Exception: + pass + + # Approach 4: Try to get usage from the last message + if not token_usage_found and hasattr(instance, "last_message"): + try: + last_message = instance.last_message() + + if hasattr(last_message, "usage"): + usage = last_message.usage + token_usage_found = True + + if hasattr(usage, "total_tokens"): + set_span_attribute(span, "llm.token_usage.total", usage.total_tokens) + if token_histogram: + token_histogram.record(usage.total_tokens, {"operation": "generate_reply"}) + if hasattr(usage, "prompt_tokens"): + set_span_attribute(span, "llm.token_usage.prompt", usage.prompt_tokens) + if hasattr(usage, "completion_tokens"): + set_span_attribute(span, "llm.token_usage.completion", usage.completion_tokens) + + elif isinstance(last_message, dict) and "usage" in last_message: + usage = last_message["usage"] + token_usage_found = True + + set_span_attribute(span, "llm.token_usage.total", usage.get("total_tokens")) + set_span_attribute(span, "llm.token_usage.prompt", usage.get("prompt_tokens")) + set_span_attribute(span, "llm.token_usage.completion", usage.get("completion_tokens")) + except Exception: + pass + + # Set token usage found flag + set_span_attribute(span, "llm.token_usage.found", token_usage_found) + + return result + + +@with_tracer_wrapper +def wrap_send(tracer: Tracer, duration_histogram: Optional[Histogram], token_histogram: Optional[Histogram], + wrapped, instance, args, kwargs): + """Wrap send method.""" + message = args[0] if args else kwargs.get("message", "") + recipient = args[1] if len(args) > 1 else kwargs.get("recipient", "unknown") + + with tracer.start_as_current_span( + "autogen.agent.send", + kind=SpanKind.PRODUCER, + attributes={ + SpanAttributes.AGENTOPS_SPAN_KIND: AgentOpsSpanKindValues.AGENT.value, + "agent.name": getattr(instance, "name", "unknown"), + "agent.recipient": getattr(recipient, "name", str(recipient)), + } + ) as span: + # Set message attributes + if isinstance(message, dict): + for key, value in message.items(): + if key != "content": + set_span_attribute(span, f"message.{key}", value) + + if "content" in message and message["content"] is not None: + content = message["content"] + set_span_attribute(span, "message.content", + content[:1000] + "..." if len(content) > 1000 else content) + elif isinstance(message, str): + set_span_attribute(span, "message.content", + message[:1000] + "..." if len(message) > 1000 else message) + + start_time = time.time() + result = wrapped(*args, **kwargs) + duration = time.time() - start_time + + # Record duration metric + if duration_histogram: + duration_histogram.record(duration, {"operation": "send"}) + + return result + + +@with_tracer_wrapper +def wrap_receive(tracer: Tracer, duration_histogram: Optional[Histogram], token_histogram: Optional[Histogram], + wrapped, instance, args, kwargs): + """Wrap receive method.""" + message = args[0] if args else kwargs.get("message", "") + sender = args[1] if len(args) > 1 else kwargs.get("sender", "unknown") + + with tracer.start_as_current_span( + "autogen.agent.receive", + kind=SpanKind.CONSUMER, + attributes={ + SpanAttributes.AGENTOPS_SPAN_KIND: AgentOpsSpanKindValues.AGENT.value, + "agent.name": getattr(instance, "name", "unknown"), + "agent.sender": getattr(sender, "name", str(sender)), + } + ) as span: + # Set message attributes + if isinstance(message, dict): + for key, value in message.items(): + if key != "content": + set_span_attribute(span, f"message.{key}", value) + + if "content" in message and message["content"] is not None: + content = message["content"] + set_span_attribute(span, "message.content", + content[:1000] + "..." if len(content) > 1000 else content) + elif isinstance(message, str): + set_span_attribute(span, "message.content", + message[:1000] + "..." if len(message) > 1000 else message) + + result = wrapped(*args, **kwargs) + return result + + +@with_tracer_wrapper +def wrap_generate_oai_reply(tracer: Tracer, duration_histogram: Optional[Histogram], token_histogram: Optional[Histogram], + wrapped, instance, args, kwargs): + """Wrap generate_oai_reply method.""" + with tracer.start_as_current_span( + "autogen.agent.generate_oai_reply", + kind=SpanKind.CLIENT, + attributes={ + SpanAttributes.AGENTOPS_SPAN_KIND: AgentOpsSpanKindValues.LLM.value, + "agent.name": getattr(instance, "name", "unknown"), + "agent.description": getattr(instance, "description", ""), + "llm.provider": "openai", # Assuming OpenAI, could be different + } + ) as span: + # Extract model information if available + llm_config = getattr(instance, "llm_config", {}) + if llm_config: + set_span_attribute(span, "llm.model", llm_config.get("model", "unknown")) + set_span_attribute(span, "llm.temperature", llm_config.get("temperature", 0.7)) + + # Add any other LLM config parameters that might be useful + for key in ["max_tokens", "top_p", "frequency_penalty", "presence_penalty"]: + if key in llm_config: + set_span_attribute(span, f"llm.{key}", llm_config.get(key)) + + # Capture system message if available + system_message = getattr(instance, "system_message", None) + if system_message: + set_span_attribute(span, "agent.system_message", + system_message[:1000] + "..." if len(system_message) > 1000 else system_message) + + # Extract messages from args or kwargs if available + messages = None + if args and len(args) > 0: + messages = args[0] + elif "messages" in kwargs: + messages = kwargs["messages"] + + # Record input message count and approximate token count + if messages and isinstance(messages, list): + set_span_attribute(span, "llm.input.message_count", len(messages)) + + # Capture detailed message information + total_content_length = 0 + for i, msg in enumerate(messages[:10]): # Limit to first 10 messages + if isinstance(msg, dict): + # Capture message role + if "role" in msg: + set_span_attribute(span, f"llm.input.message.{i}.role", msg["role"]) + + # Capture message content + if "content" in msg and msg["content"]: + content = str(msg["content"]) + set_span_attribute(span, f"llm.input.message.{i}.content", + content[:500] + "..." if len(content) > 500 else content) + total_content_length += len(content) + + # Capture function calls in the message + if "function_call" in msg: + set_span_attribute(span, f"llm.input.message.{i}.has_function_call", True) + if isinstance(msg["function_call"], dict): + set_span_attribute(span, f"llm.input.message.{i}.function_call.name", + msg["function_call"].get("name", "unknown")) + + # Very rough approximation: 4 characters ~= 1 token + estimated_tokens = total_content_length // 4 + set_span_attribute(span, "llm.input.estimated_tokens", estimated_tokens) + + # Capture model context information if available + if hasattr(instance, "model_context") and getattr(instance, "model_context", None): + model_context = getattr(instance, "model_context") + if hasattr(model_context, "buffer_size"): + set_span_attribute(span, "llm.model_context.buffer_size", getattr(model_context, "buffer_size")) + + # Capture tools information if available + tools = getattr(instance, "tools", []) + if tools: + set_span_attribute(span, "agent.tools.count", len(tools)) + # Capture names of first few tools + for i, tool in enumerate(tools[:5]): + if hasattr(tool, "name"): + set_span_attribute(span, f"agent.tools.{i}.name", getattr(tool, "name")) + elif hasattr(tool, "__name__"): + set_span_attribute(span, f"agent.tools.{i}.name", getattr(tool, "__name__")) + + start_time = time.time() + result = wrapped(*args, **kwargs) + duration = time.time() - start_time + + # Record duration metric + if duration_histogram: + duration_histogram.record(duration, {"operation": "generate_oai_reply"}) + + # Extract and record token usage using multiple approaches + token_usage_found = False + + # Approach 1: Try to get usage from the result object directly + if result: + # Try to access usage attribute + if hasattr(result, "usage"): + usage = result.usage + token_usage_found = True + + if token_histogram and hasattr(usage, "total_tokens"): + token_histogram.record(usage.total_tokens, {"operation": "generate_oai_reply"}) + + set_span_attribute(span, "llm.token_usage.total", getattr(usage, "total_tokens", None)) + set_span_attribute(span, "llm.token_usage.prompt", getattr(usage, "prompt_tokens", None)) + set_span_attribute(span, "llm.token_usage.completion", getattr(usage, "completion_tokens", None)) + + # Calculate cost if possible (very rough estimate) + if hasattr(usage, "total_tokens") and hasattr(usage, "prompt_tokens") and hasattr(usage, "completion_tokens"): + model = llm_config.get("model", "").lower() if llm_config else "" + if "gpt-4" in model: + # GPT-4 pricing (very approximate) + prompt_cost = usage.prompt_tokens * 0.00003 + completion_cost = usage.completion_tokens * 0.00006 + total_cost = prompt_cost + completion_cost + set_span_attribute(span, "llm.estimated_cost_usd", round(total_cost, 6)) + elif "gpt-3.5" in model: + # GPT-3.5 pricing (very approximate) + prompt_cost = usage.prompt_tokens * 0.000001 + completion_cost = usage.completion_tokens * 0.000002 + total_cost = prompt_cost + completion_cost + set_span_attribute(span, "llm.estimated_cost_usd", round(total_cost, 6)) + + # Approach 2: Try to get usage from the instance + if not token_usage_found and hasattr(instance, "get_actual_usage"): + try: + usage = instance.get_actual_usage() + if usage: + token_usage_found = True + set_span_attribute(span, "llm.token_usage.total", usage.get("total_tokens")) + set_span_attribute(span, "llm.token_usage.prompt", usage.get("prompt_tokens")) + set_span_attribute(span, "llm.token_usage.completion", usage.get("completion_tokens")) + except Exception: + pass + + # Approach 3: Try to access token usage from response dictionary + if not token_usage_found and hasattr(result, "__dict__"): + try: + result_dict = result.__dict__ + if "usage" in result_dict and isinstance(result_dict["usage"], dict): + usage = result_dict["usage"] + token_usage_found = True + set_span_attribute(span, "llm.token_usage.total", usage.get("total_tokens")) + set_span_attribute(span, "llm.token_usage.prompt", usage.get("prompt_tokens")) + set_span_attribute(span, "llm.token_usage.completion", usage.get("completion_tokens")) + except Exception: + pass + + # Approach 4: Try to convert result to dictionary if it's JSON serializable + if not token_usage_found: + try: + if hasattr(result, "model_dump"): # Pydantic v2 + result_dict = result.model_dump() + elif hasattr(result, "dict"): # Pydantic v1 + result_dict = result.dict() + else: + # Try to convert to dict using json + result_dict = json.loads(json.dumps(result, default=lambda o: o.__dict__ if hasattr(o, "__dict__") else str(o))) + + if isinstance(result_dict, dict) and "usage" in result_dict and isinstance(result_dict["usage"], dict): + usage = result_dict["usage"] + token_usage_found = True + set_span_attribute(span, "llm.token_usage.total", usage.get("total_tokens")) + set_span_attribute(span, "llm.token_usage.prompt", usage.get("prompt_tokens")) + set_span_attribute(span, "llm.token_usage.completion", usage.get("completion_tokens")) + except Exception: + pass + + # Set token usage found flag + set_span_attribute(span, "llm.token_usage.found", token_usage_found) + + # Extract and record response content + if result: + # Try to get choices from the result + choices = None + if hasattr(result, "choices"): + choices = result.choices + elif hasattr(result, "__dict__") and "choices" in result.__dict__: + choices = result.__dict__["choices"] + + if choices and len(choices) > 0: + choice = choices[0] + + # Try different approaches to extract message content + content = None + + # Approach 1: Standard OpenAI structure + if hasattr(choice, "message") and hasattr(choice.message, "content"): + content = choice.message.content + + # Approach 2: Dict-like structure + elif hasattr(choice, "__dict__") and "message" in choice.__dict__: + message = choice.__dict__["message"] + if hasattr(message, "content"): + content = message.content + elif hasattr(message, "__dict__") and "content" in message.__dict__: + content = message.__dict__["content"] + + # Approach 3: Direct content attribute + elif hasattr(choice, "content"): + content = choice.content + + # Record content if found + if content: + set_span_attribute(span, "llm.response.content", + content[:1000] + "..." if len(content) > 1000 else content) + + # Estimate output token count + estimated_output_tokens = len(str(content)) // 4 + set_span_attribute(span, "llm.output.estimated_tokens", estimated_output_tokens) + + # Extract finish reason using multiple approaches + finish_reason = None + if hasattr(choice, "finish_reason"): + finish_reason = choice.finish_reason + elif hasattr(choice, "__dict__") and "finish_reason" in choice.__dict__: + finish_reason = choice.__dict__["finish_reason"] + + if finish_reason: + set_span_attribute(span, "llm.response.finish_reason", finish_reason) + + # Check for function calls using multiple approaches + function_call = None + if hasattr(choice, "message") and hasattr(choice.message, "function_call"): + function_call = choice.message.function_call + elif hasattr(choice, "__dict__") and "message" in choice.__dict__: + message = choice.__dict__["message"] + if hasattr(message, "function_call"): + function_call = message.function_call + elif hasattr(message, "__dict__") and "function_call" in message.__dict__: + function_call = message.__dict__["function_call"] + + if function_call: + set_span_attribute(span, "llm.response.has_function_call", True) + + # Extract function name + function_name = None + if hasattr(function_call, "name"): + function_name = function_call.name + elif hasattr(function_call, "__dict__") and "name" in function_call.__dict__: + function_name = function_call.__dict__["name"] + + if function_name: + set_span_attribute(span, "llm.response.function_name", function_name) + + # Extract function arguments + function_args = None + if hasattr(function_call, "arguments"): + function_args = function_call.arguments + elif hasattr(function_call, "__dict__") and "arguments" in function_call.__dict__: + function_args = function_call.__dict__["arguments"] + + if function_args: + args_str = str(function_args) + set_span_attribute(span, "llm.response.function_arguments", + args_str[:500] + "..." if len(args_str) > 500 else args_str) + + return result + + +@with_tracer_wrapper +def wrap_call_function(tracer: Tracer, duration_histogram: Optional[Histogram], token_histogram: Optional[Histogram], + wrapped, instance, args, kwargs): + """Wrap execute_function method.""" + function_name = args[0] if args else kwargs.get("function_name", "unknown") + + with tracer.start_as_current_span( + "autogen.agent.execute_function", + kind=SpanKind.INTERNAL, + attributes={ + SpanAttributes.AGENTOPS_SPAN_KIND: AgentOpsSpanKindValues.TOOL.value, + "agent.name": getattr(instance, "name", "unknown"), + "tool.name": function_name, + } + ) as span: + # Extract function arguments + arguments = args[1] if len(args) > 1 else kwargs.get("arguments", {}) + set_span_attribute(span, "tool.arguments", arguments) + + start_time = time.time() + result = wrapped(*args, **kwargs) + duration = time.time() - start_time + + # Record duration metric + if duration_histogram: + duration_histogram.record(duration, {"operation": "execute_function"}) + + # Record function result + if result is not None: + if isinstance(result, str): + set_span_attribute(span, "tool.result", + result[:1000] + "..." if len(result) > 1000 else result) + else: + set_span_attribute(span, "tool.result", str(result)) + + return result + + +@with_tracer_wrapper +def wrap_initiate_chat(tracer: Tracer, duration_histogram: Optional[Histogram], token_histogram: Optional[Histogram], + wrapped, instance, args, kwargs): + """Wrap initiate_chat method.""" + recipient = args[0] if args else kwargs.get("recipient", "unknown") + message = args[1] if len(args) > 1 else kwargs.get("message", "") + + with tracer.start_as_current_span( + "autogen.agent.initiate_chat", + kind=SpanKind.INTERNAL, + attributes={ + SpanAttributes.AGENTOPS_SPAN_KIND: AgentOpsSpanKindValues.AGENT.value, + "agent.name": getattr(instance, "name", "unknown"), + "agent.recipient": getattr(recipient, "name", str(recipient)), + } + ) as span: + # Set message attributes + if isinstance(message, str): + set_span_attribute(span, "message.content", + message[:1000] + "..." if len(message) > 1000 else message) + + start_time = time.time() + result = wrapped(*args, **kwargs) + duration = time.time() - start_time + + # Record duration metric + if duration_histogram: + duration_histogram.record(duration, {"operation": "initiate_chat"}) + + return result + + +@with_tracer_wrapper +def wrap_groupchat_run(tracer: Tracer, duration_histogram: Optional[Histogram], token_histogram: Optional[Histogram], + wrapped, instance, args, kwargs): + """Wrap GroupChat.run method.""" + with tracer.start_as_current_span( + "autogen.team.groupchat.run", + kind=SpanKind.INTERNAL, + attributes={ + SpanAttributes.AGENTOPS_SPAN_KIND: AgentOpsSpanKindValues.TEAM.value, + "team.name": getattr(instance, "name", "unknown"), + "team.agents_count": len(getattr(instance, "agents", [])), + } + ) as span: + # Set group chat attributes + try: + AutoGenSpanAttributes(span, instance) + except Exception: + pass + + start_time = time.time() + result = wrapped(*args, **kwargs) + duration = time.time() - start_time + + # Record duration metric + if duration_histogram: + duration_histogram.record(duration, {"operation": "groupchat_run"}) + + return result + + +@with_tracer_wrapper +def wrap_groupchat_manager_run(tracer: Tracer, duration_histogram: Optional[Histogram], token_histogram: Optional[Histogram], + wrapped, instance, args, kwargs): + """Wrap GroupChatManager.run method.""" + with tracer.start_as_current_span( + "autogen.team.groupchat_manager.run", + kind=SpanKind.INTERNAL, + attributes={ + SpanAttributes.AGENTOPS_SPAN_KIND: AgentOpsSpanKindValues.TEAM.value, + "team.manager.name": getattr(instance, "name", "unknown"), + } + ) as span: + # Set group chat manager attributes + AutoGenSpanAttributes(span, instance) + + start_time = time.time() + result = wrapped(*args, **kwargs) + duration = time.time() - start_time + + # Record duration metric + if duration_histogram: + duration_histogram.record(duration, {"operation": "groupchat_manager_run"}) + + return result + + +def is_metrics_enabled() -> bool: + """Check if metrics are enabled.""" + try: + from opentelemetry.metrics import get_meter_provider + from opentelemetry.sdk.metrics import MeterProvider + return not isinstance(get_meter_provider(), MeterProvider) + except ImportError: + return False + + +def _create_metrics(meter: Meter): + """Create metrics for AutoGen.""" + token_histogram = meter.create_histogram( + name=Meters.LLM_TOKEN_USAGE, + unit="token", + description="Measures number of input and output tokens used", + ) + + duration_histogram = meter.create_histogram( + name=Meters.LLM_OPERATION_DURATION, + unit="s", + description="AutoGen operation duration", + ) + + return token_histogram, duration_histogram \ No newline at end of file diff --git a/third_party/opentelemetry/instrumentation/autogen/version.py b/third_party/opentelemetry/instrumentation/autogen/version.py new file mode 100644 index 000000000..c8482e13b --- /dev/null +++ b/third_party/opentelemetry/instrumentation/autogen/version.py @@ -0,0 +1,3 @@ +"""Version information.""" + +__version__ = "0.1.0" \ No newline at end of file diff --git a/third_party/opentelemetry/instrumentation/cohere/LICENSE b/third_party/opentelemetry/instrumentation/cohere/LICENSE new file mode 100644 index 000000000..0f2a333f0 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/cohere/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 openllmetry + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/opentelemetry/instrumentation/cohere/NOTICE.md b/third_party/opentelemetry/instrumentation/cohere/NOTICE.md new file mode 100644 index 000000000..ca711b794 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/cohere/NOTICE.md @@ -0,0 +1,8 @@ +This package contains code derived from the OpenLLMetry project, which is licensed under the Apache License, Version 2.0. + +Original repository: https://github.com/traceloop/openllmetry + +Copyright notice from the original project: +Copyright (c) Traceloop (https://traceloop.com) + +The Apache 2.0 license can be found in the LICENSE file in this directory. diff --git a/third_party/opentelemetry/instrumentation/cohere/__init__.py b/third_party/opentelemetry/instrumentation/cohere/__init__.py new file mode 100644 index 000000000..c60317197 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/cohere/__init__.py @@ -0,0 +1,380 @@ +"""OpenTelemetry Cohere instrumentation""" + +import logging +import os +import time +from typing import Collection +from opentelemetry.instrumentation.cohere.config import Config +from opentelemetry.instrumentation.cohere.utils import dont_throw +from wrapt import wrap_function_wrapper + +from opentelemetry import context as context_api +from opentelemetry.trace import get_tracer, SpanKind +from opentelemetry.trace.status import Status, StatusCode +from opentelemetry.metrics import get_meter + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import ( + _SUPPRESS_INSTRUMENTATION_KEY, + unwrap, +) + +from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_RESPONSE_ID +from agentops.semconv import ( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, + SpanAttributes, + LLMRequestTypeValues, + Meters, +) +from opentelemetry.instrumentation.cohere.version import __version__ + +logger = logging.getLogger(__name__) + +_instruments = ("cohere >=4.2.7, <6",) + +WRAPPED_METHODS = [ + { + "object": "Client", + "method": "generate", + "span_name": "cohere.completion", + }, + { + "object": "Client", + "method": "chat", + "span_name": "cohere.chat", + }, + { + "object": "Client", + "method": "rerank", + "span_name": "cohere.rerank", + }, +] + +# Global metrics objects +_tokens_histogram = None +_request_counter = None +_response_time_histogram = None + + +def should_send_prompts(): + return ( + os.getenv("TRACELOOP_TRACE_CONTENT") or "true" + ).lower() == "true" or context_api.get_value("override_enable_content_tracing") + + +def _set_span_attribute(span, name, value): + if value is not None: + if value != "": + span.set_attribute(name, value) + return + + +@dont_throw +def _set_input_attributes(span, llm_request_type, kwargs): + _set_span_attribute(span, SpanAttributes.LLM_REQUEST_MODEL, kwargs.get("model")) + _set_span_attribute( + span, SpanAttributes.LLM_REQUEST_MAX_TOKENS, kwargs.get("max_tokens_to_sample") + ) + _set_span_attribute( + span, SpanAttributes.LLM_REQUEST_TEMPERATURE, kwargs.get("temperature") + ) + _set_span_attribute(span, SpanAttributes.LLM_REQUEST_TOP_P, kwargs.get("top_p")) + _set_span_attribute( + span, SpanAttributes.LLM_FREQUENCY_PENALTY, kwargs.get("frequency_penalty") + ) + _set_span_attribute( + span, SpanAttributes.LLM_PRESENCE_PENALTY, kwargs.get("presence_penalty") + ) + + if should_send_prompts(): + if llm_request_type == LLMRequestTypeValues.COMPLETION: + _set_span_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.0.role", "user") + _set_span_attribute( + span, f"{SpanAttributes.LLM_PROMPTS}.0.content", kwargs.get("prompt") + ) + elif llm_request_type == LLMRequestTypeValues.CHAT: + _set_span_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.0.role", "user") + _set_span_attribute( + span, f"{SpanAttributes.LLM_PROMPTS}.0.content", kwargs.get("message") + ) + elif llm_request_type == LLMRequestTypeValues.RERANK: + for index, document in enumerate(kwargs.get("documents")): + _set_span_attribute( + span, f"{SpanAttributes.LLM_PROMPTS}.{index}.role", "system" + ) + _set_span_attribute( + span, f"{SpanAttributes.LLM_PROMPTS}.{index}.content", document + ) + + _set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.{len(kwargs.get('documents'))}.role", + "user", + ) + _set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.{len(kwargs.get('documents'))}.content", + kwargs.get("query"), + ) + + +def _set_span_chat_response(span, response): + index = 0 + prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}" + _set_span_attribute(span, f"{prefix}.content", response.text) + _set_span_attribute(span, GEN_AI_RESPONSE_ID, response.response_id) + + # Cohere v4 + if hasattr(response, "token_count"): + _set_span_attribute( + span, + SpanAttributes.LLM_USAGE_TOTAL_TOKENS, + response.token_count.get("total_tokens"), + ) + _set_span_attribute( + span, + SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, + response.token_count.get("response_tokens"), + ) + _set_span_attribute( + span, + SpanAttributes.LLM_USAGE_PROMPT_TOKENS, + response.token_count.get("prompt_tokens"), + ) + + # Cohere v5 + if hasattr(response, "meta") and hasattr(response.meta, "billed_units"): + input_tokens = response.meta.billed_units.input_tokens + output_tokens = response.meta.billed_units.output_tokens + + _set_span_attribute( + span, + SpanAttributes.LLM_USAGE_TOTAL_TOKENS, + input_tokens + output_tokens, + ) + _set_span_attribute( + span, + SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, + output_tokens, + ) + _set_span_attribute( + span, + SpanAttributes.LLM_USAGE_PROMPT_TOKENS, + input_tokens, + ) + + +def _set_span_generations_response(span, response): + _set_span_attribute(span, GEN_AI_RESPONSE_ID, response.id) + if hasattr(response, "generations"): + generations = response.generations # Cohere v5 + else: + generations = response # Cohere v4 + + for index, generation in enumerate(generations): + prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}" + _set_span_attribute(span, f"{prefix}.content", generation.text) + _set_span_attribute(span, f"gen_ai.response.{index}.id", generation.id) + + +def _set_span_rerank_response(span, response): + _set_span_attribute(span, GEN_AI_RESPONSE_ID, response.id) + for idx, doc in enumerate(response.results): + prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{idx}" + _set_span_attribute(span, f"{prefix}.role", "assistant") + content = f"Doc {doc.index}, Score: {doc.relevance_score}" + if doc.document: + if hasattr(doc.document, "text"): + content += f"\n{doc.document.text}" + else: + content += f"\n{doc.document.get('text')}" + _set_span_attribute( + span, + f"{prefix}.content", + content, + ) + + +@dont_throw +def _set_response_attributes(span, llm_request_type, response): + if should_send_prompts(): + if llm_request_type == LLMRequestTypeValues.CHAT: + _set_span_chat_response(span, response) + elif llm_request_type == LLMRequestTypeValues.COMPLETION: + _set_span_generations_response(span, response) + elif llm_request_type == LLMRequestTypeValues.RERANK: + _set_span_rerank_response(span, response) + + +def _with_tracer_wrapper(func): + """Helper for providing tracer for wrapper functions.""" + + def _with_tracer(tracer, to_wrap): + def wrapper(wrapped, instance, args, kwargs): + return func(tracer, to_wrap, wrapped, instance, args, kwargs) + + return wrapper + + return _with_tracer + + +def _llm_request_type_by_method(method_name): + if method_name == "chat": + return LLMRequestTypeValues.CHAT + elif method_name == "generate": + return LLMRequestTypeValues.COMPLETION + elif method_name == "rerank": + return LLMRequestTypeValues.RERANK + else: + return LLMRequestTypeValues.UNKNOWN + + +@_with_tracer_wrapper +def _wrap(tracer, to_wrap, wrapped, instance, args, kwargs): + """Instruments and calls every function defined in TO_WRAP.""" + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) or context_api.get_value( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + ): + return wrapped(*args, **kwargs) + + method_name = to_wrap.get("method", "") + span_name = to_wrap.get("span_name", method_name) + llm_request_type = _llm_request_type_by_method(method_name) + + start_time = time.time() + model = kwargs.get("model", "unknown") + + # Record request metric + if _request_counter: + _request_counter.add( + 1, + { + "model": model, + "provider": "cohere", + "method": method_name + } + ) + + with tracer.start_as_current_span( + span_name, + kind=SpanKind.CLIENT, + ) as span: + _set_span_attribute(span, SpanAttributes.LLM_REQUEST_TYPE, llm_request_type) + _set_span_attribute(span, SpanAttributes.LLM_VENDOR, "cohere") + _set_input_attributes(span, llm_request_type, kwargs) + + try: + response = wrapped(*args, **kwargs) + _set_response_attributes(span, llm_request_type, response) + + # Record response time + if _response_time_histogram: + response_time = (time.time() - start_time) * 1000 # Convert to ms + _response_time_histogram.record( + response_time, + { + "model": model, + "provider": "cohere", + "method": method_name + } + ) + + # Record token usage if available + if _tokens_histogram and hasattr(response, "meta") and response.meta: + if hasattr(response.meta, "billed_units") and response.meta.billed_units: + if hasattr(response.meta.billed_units, "input_tokens"): + input_tokens = response.meta.billed_units.input_tokens + _tokens_histogram.record( + input_tokens, + { + "model": model, + "provider": "cohere", + "token_type": "prompt" + } + ) + + if hasattr(response.meta.billed_units, "output_tokens"): + output_tokens = response.meta.billed_units.output_tokens + _tokens_histogram.record( + output_tokens, + { + "model": model, + "provider": "cohere", + "token_type": "completion" + } + ) + + # Record total tokens + if hasattr(response.meta.billed_units, "input_tokens"): + total_tokens = response.meta.billed_units.input_tokens + output_tokens + _tokens_histogram.record( + total_tokens, + { + "model": model, + "provider": "cohere", + "token_type": "total" + } + ) + + return response + except Exception as ex: + span.set_status(Status(StatusCode.ERROR)) + span.record_exception(ex) + raise + + +class CohereInstrumentor(BaseInstrumentor): + """An instrumentor for Cohere's client library.""" + + def __init__(self, exception_logger=None): + super().__init__() + Config.exception_logger = exception_logger + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + tracer_provider = kwargs.get("tracer_provider") + tracer = get_tracer(__name__, __version__, tracer_provider) + + # Initialize metrics + global _tokens_histogram, _request_counter, _response_time_histogram + meter_provider = kwargs.get("meter_provider") + if meter_provider: + meter = get_meter(__name__, __version__, meter_provider) + + _tokens_histogram = meter.create_histogram( + name=Meters.LLM_TOKEN_USAGE, + unit="token", + description="Measures number of input and output tokens used in Cohere calls" + ) + + _request_counter = meter.create_counter( + name="cohere.requests", + unit="request", + description="Counts Cohere API requests" + ) + + _response_time_histogram = meter.create_histogram( + name="cohere.response_time", + unit="ms", + description="Measures response time for Cohere API calls" + ) + + import cohere + + for wrapped_method in WRAPPED_METHODS: + wrap_function_wrapper( + "cohere", + f"Client.{wrapped_method['method']}", + _wrap(tracer, wrapped_method), + ) + + def _uninstrument(self, **kwargs): + import cohere + + for wrapped_method in WRAPPED_METHODS: + unwrap( + cohere.Client, + wrapped_method["method"], + ) diff --git a/third_party/opentelemetry/instrumentation/cohere/config.py b/third_party/opentelemetry/instrumentation/cohere/config.py new file mode 100644 index 000000000..4689e9292 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/cohere/config.py @@ -0,0 +1,2 @@ +class Config: + exception_logger = None diff --git a/third_party/opentelemetry/instrumentation/cohere/utils.py b/third_party/opentelemetry/instrumentation/cohere/utils.py new file mode 100644 index 000000000..f9ebeb2cc --- /dev/null +++ b/third_party/opentelemetry/instrumentation/cohere/utils.py @@ -0,0 +1,28 @@ +import logging +import traceback +from opentelemetry.instrumentation.cohere.config import Config + + +def dont_throw(func): + """ + A decorator that wraps the passed in function and logs exceptions instead of throwing them. + + @param func: The function to wrap + @return: The wrapper function + """ + # Obtain a logger specific to the function's module + logger = logging.getLogger(func.__module__) + + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + logger.debug( + "OpenLLMetry failed to trace in %s, error: %s", + func.__name__, + traceback.format_exc(), + ) + if Config.exception_logger: + Config.exception_logger(e) + + return wrapper diff --git a/third_party/opentelemetry/instrumentation/cohere/version.py b/third_party/opentelemetry/instrumentation/cohere/version.py new file mode 100644 index 000000000..703f9571b --- /dev/null +++ b/third_party/opentelemetry/instrumentation/cohere/version.py @@ -0,0 +1 @@ +__version__ = "0.38.7" diff --git a/third_party/opentelemetry/instrumentation/crewai/LICENSE b/third_party/opentelemetry/instrumentation/crewai/LICENSE new file mode 100644 index 000000000..0f2a333f0 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/crewai/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 openllmetry + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/opentelemetry/instrumentation/crewai/NOTICE.md b/third_party/opentelemetry/instrumentation/crewai/NOTICE.md new file mode 100644 index 000000000..ca711b794 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/crewai/NOTICE.md @@ -0,0 +1,8 @@ +This package contains code derived from the OpenLLMetry project, which is licensed under the Apache License, Version 2.0. + +Original repository: https://github.com/traceloop/openllmetry + +Copyright notice from the original project: +Copyright (c) Traceloop (https://traceloop.com) + +The Apache 2.0 license can be found in the LICENSE file in this directory. diff --git a/third_party/opentelemetry/instrumentation/crewai/__init__.py b/third_party/opentelemetry/instrumentation/crewai/__init__.py new file mode 100644 index 000000000..7a7d3d519 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/crewai/__init__.py @@ -0,0 +1,5 @@ +"""OpenTelemetry CrewAI instrumentation""" +from opentelemetry.instrumentation.crewai.version import __version__ +from opentelemetry.instrumentation.crewai.instrumentation import CrewAIInstrumentor + +__all__ = ["CrewAIInstrumentor", "__version__"] diff --git a/third_party/opentelemetry/instrumentation/crewai/crewai_span_attributes.py b/third_party/opentelemetry/instrumentation/crewai/crewai_span_attributes.py new file mode 100644 index 000000000..6848e7011 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/crewai/crewai_span_attributes.py @@ -0,0 +1,150 @@ +from opentelemetry.trace import Span +import json + + +def set_span_attribute(span: Span, name, value): + if value is not None: + if value != "": + span.set_attribute(name, value) + return + + +class CrewAISpanAttributes: + def __init__(self, span: Span, instance) -> None: + self.span = span + self.instance = instance + self.crew = {"tasks": [], "agents": [], "llms": []} + self.process_instance() + + def process_instance(self): + instance_type = self.instance.__class__.__name__ + method_mapping = { + "Crew": self._process_crew, + "Agent": self._process_agent, + "Task": self._process_task, + "LLM": self._process_llm, + } + method = method_mapping.get(instance_type) + if method: + method() + + def _process_crew(self): + self._populate_crew_attributes() + for key, value in self.crew.items(): + self._set_attribute(f"crewai.crew.{key}", value) + + def _process_agent(self): + agent_data = self._populate_agent_attributes() + for key, value in agent_data.items(): + self._set_attribute(f"crewai.agent.{key}", value) + + def _process_task(self): + task_data = self._populate_task_attributes() + for key, value in task_data.items(): + self._set_attribute(f"crewai.task.{key}", value) + + def _process_llm(self): + llm_data = self._populate_llm_attributes() + for key, value in llm_data.items(): + self._set_attribute(f"crewai.llm.{key}", value) + + def _populate_crew_attributes(self): + for key, value in self.instance.__dict__.items(): + if value is None: + continue + if key == "tasks": + self._parse_tasks(value) + elif key == "agents": + self._parse_agents(value) + elif key == "llms": + self._parse_llms(value) + else: + self.crew[key] = str(value) + + def _populate_agent_attributes(self): + return self._extract_attributes(self.instance) + + def _populate_task_attributes(self): + task_data = self._extract_attributes(self.instance) + if "agent" in task_data: + task_data["agent"] = self.instance.agent.role if self.instance.agent else None + return task_data + + def _populate_llm_attributes(self): + return self._extract_attributes(self.instance) + + def _parse_agents(self, agents): + self.crew["agents"] = [ + self._extract_agent_data(agent) for agent in agents if agent is not None + ] + + def _parse_tasks(self, tasks): + self.crew["tasks"] = [ + { + "agent": task.agent.role if task.agent else None, + "description": task.description, + "async_execution": task.async_execution, + "expected_output": task.expected_output, + "human_input": task.human_input, + "tools": task.tools, + "output_file": task.output_file, + } + for task in tasks + ] + + def _parse_llms(self, llms): + self.crew["tasks"] = [ + { + "temperature": llm.temperature, + "max_tokens": llm.max_tokens, + "max_completion_tokens": llm.max_completion_tokens, + "top_p": llm.top_p, + "n": llm.n, + "seed": llm.seed, + "base_url": llm.base_url, + "api_version": llm.api_version, } + for llm in llms + ] + + def _extract_agent_data(self, agent): + model = ( + getattr(agent.llm, "model", None) + or getattr(agent.llm, "model_name", None) + or "" + ) + + return { + "id": str(agent.id), + "role": agent.role, + "goal": agent.goal, + "backstory": agent.backstory, + "cache": agent.cache, + "config": agent.config, + "verbose": agent.verbose, + "allow_delegation": agent.allow_delegation, + "tools": agent.tools, + "max_iter": agent.max_iter, + "llm": str(model), } + + def _extract_attributes(self, obj): + attributes = {} + for key, value in obj.__dict__.items(): + if value is None: + continue + if key == "tools": + attributes[key] = self._serialize_tools(value) + else: + attributes[key] = str(value) + return attributes + + def _serialize_tools(self, tools): + return json.dumps( + [ + {k: v for k, v in vars(tool).items() if v is not None and k in ["name", "description"]} + for tool in tools + ] + ) + + def _set_attribute(self, key, value): + if value: + set_span_attribute(self.span, key, str(value) if isinstance(value, list) else value) diff --git a/third_party/opentelemetry/instrumentation/crewai/instrumentation.py b/third_party/opentelemetry/instrumentation/crewai/instrumentation.py new file mode 100644 index 000000000..bf50238fd --- /dev/null +++ b/third_party/opentelemetry/instrumentation/crewai/instrumentation.py @@ -0,0 +1,204 @@ +import os +import time +from typing import Collection + +from wrapt import wrap_function_wrapper +from opentelemetry.trace import SpanKind, get_tracer, Tracer +from opentelemetry.trace.status import Status, StatusCode +from opentelemetry.metrics import Histogram, Meter, get_meter +from opentelemetry.instrumentation.utils import unwrap +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.crewai.version import __version__ +from agentops.semconv import SpanAttributes, AgentOpsSpanKindValues, Meters +from .crewai_span_attributes import CrewAISpanAttributes, set_span_attribute + +_instruments = ("crewai >= 0.70.0",) + + +class CrewAIInstrumentor(BaseInstrumentor): + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + tracer_provider = kwargs.get("tracer_provider") + tracer = get_tracer(__name__, __version__, tracer_provider) + + meter_provider = kwargs.get("meter_provider") + meter = get_meter(__name__, __version__, meter_provider) + + if is_metrics_enabled(): + ( + token_histogram, + duration_histogram, + ) = _create_metrics(meter) + else: + ( + token_histogram, + duration_histogram, + ) = (None, None, None, None) + + wrap_function_wrapper("crewai.crew", "Crew.kickoff", + wrap_kickoff(tracer, duration_histogram, token_histogram)) + wrap_function_wrapper("crewai.agent", "Agent.execute_task", + wrap_agent_execute_task(tracer, duration_histogram, token_histogram)) + wrap_function_wrapper("crewai.task", "Task.execute_sync", + wrap_task_execute(tracer, duration_histogram, token_histogram)) + wrap_function_wrapper("crewai.llm", "LLM.call", + wrap_llm_call(tracer, duration_histogram, token_histogram)) + + def _uninstrument(self, **kwargs): + unwrap("crewai.crew.Crew", "kickoff") + unwrap("crewai.agent.Agent", "execute_task") + unwrap("crewai.task.Task", "execute_sync") + unwrap("crewai.llm.LLM", "call") + + +def with_tracer_wrapper(func): + """Helper for providing tracer for wrapper functions.""" + + def _with_tracer(tracer, duration_histogram, token_histogram): + def wrapper(wrapped, instance, args, kwargs): + return func(tracer, duration_histogram, token_histogram, wrapped, instance, args, kwargs) + return wrapper + return _with_tracer + + +@with_tracer_wrapper +def wrap_kickoff(tracer: Tracer, duration_histogram: Histogram, token_histogram: Histogram, + wrapped, instance, args, kwargs): + with tracer.start_as_current_span( + "crewai.workflow", + kind=SpanKind.INTERNAL, + attributes={ + SpanAttributes.LLM_SYSTEM: "crewai", + } + ) as span: + try: + CrewAISpanAttributes(span=span, instance=instance) + result = wrapped(*args, **kwargs) + if result: + class_name = instance.__class__.__name__ + span.set_attribute(f"crewai.{class_name.lower()}.result", str(result)) + span.set_status(Status(StatusCode.OK)) + if class_name == "Crew": + for attr in ["tasks_output", "token_usage", "usage_metrics"]: + if hasattr(result, attr): + span.set_attribute(f"crewai.crew.{attr}", str(getattr(result, attr))) + return result + except Exception as ex: + span.set_status(Status(StatusCode.ERROR, str(ex))) + raise + + +@with_tracer_wrapper +def wrap_agent_execute_task(tracer, duration_histogram, token_histogram, wrapped, instance, args, kwargs): + agent_name = instance.role if hasattr(instance, "role") else "agent" + with tracer.start_as_current_span( + f"{agent_name}.agent", + kind=SpanKind.CLIENT, + attributes={ + SpanAttributes.AGENTOPS_SPAN_KIND: AgentOpsSpanKindValues.AGENT.value, + } + ) as span: + try: + CrewAISpanAttributes(span=span, instance=instance) + result = wrapped(*args, **kwargs) + if token_histogram: + token_histogram.record( + instance._token_process.get_summary().prompt_tokens, + attributes={ + SpanAttributes.LLM_SYSTEM: "crewai", + SpanAttributes.LLM_TOKEN_TYPE: "input", + SpanAttributes.LLM_RESPONSE_MODEL: str(instance.llm.model), + } + ) + token_histogram.record( + instance._token_process.get_summary().completion_tokens, + attributes={ + SpanAttributes.LLM_SYSTEM: "crewai", + SpanAttributes.LLM_TOKEN_TYPE: "output", + SpanAttributes.LLM_RESPONSE_MODEL: str(instance.llm.model), + }, + ) + + set_span_attribute(span, SpanAttributes.LLM_REQUEST_MODEL, str(instance.llm.model)) + set_span_attribute(span, SpanAttributes.LLM_RESPONSE_MODEL, str(instance.llm.model)) + span.set_status(Status(StatusCode.OK)) + return result + except Exception as ex: + span.set_status(Status(StatusCode.ERROR, str(ex))) + raise + + +@with_tracer_wrapper +def wrap_task_execute(tracer, duration_histogram, token_histogram, wrapped, instance, args, kwargs): + task_name = instance.description if hasattr(instance, "description") else "task" + + with tracer.start_as_current_span( + f"{task_name}.task", + kind=SpanKind.CLIENT, + attributes={ + SpanAttributes.AGENTOPS_SPAN_KIND: AgentOpsSpanKindValues.TASK.value, + } + ) as span: + try: + CrewAISpanAttributes(span=span, instance=instance) + result = wrapped(*args, **kwargs) + set_span_attribute(span, SpanAttributes.AGENTOPS_ENTITY_OUTPUT, str(result)) + span.set_status(Status(StatusCode.OK)) + return result + except Exception as ex: + span.set_status(Status(StatusCode.ERROR, str(ex))) + raise + + +@with_tracer_wrapper +def wrap_llm_call(tracer, duration_histogram, token_histogram, wrapped, instance, args, kwargs): + llm = instance.model if hasattr(instance, "model") else "llm" + with tracer.start_as_current_span( + f"{llm}.llm", + kind=SpanKind.CLIENT, + attributes={ + } + ) as span: + start_time = time.time() + try: + CrewAISpanAttributes(span=span, instance=instance) + result = wrapped(*args, **kwargs) + + if duration_histogram: + duration = time.time() - start_time + duration_histogram.record( + duration, + attributes={ + SpanAttributes.LLM_SYSTEM: "crewai", + SpanAttributes.LLM_RESPONSE_MODEL: str(instance.model) + }, + ) + + span.set_status(Status(StatusCode.OK)) + return result + except Exception as ex: + span.set_status(Status(StatusCode.ERROR, str(ex))) + raise + + +def is_metrics_enabled() -> bool: + return (os.getenv("AGENTOPS_METRICS_ENABLED") or "true").lower() == "true" + + +def _create_metrics(meter: Meter): + token_histogram = meter.create_histogram( + name=Meters.LLM_TOKEN_USAGE, + unit="token", + description="Measures number of input and output tokens used", + ) + + duration_histogram = meter.create_histogram( + name=Meters.LLM_OPERATION_DURATION, + unit="s", + description="GenAI operation duration", + ) + + return token_histogram, duration_histogram diff --git a/third_party/opentelemetry/instrumentation/crewai/version.py b/third_party/opentelemetry/instrumentation/crewai/version.py new file mode 100644 index 000000000..d9f2629e2 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/crewai/version.py @@ -0,0 +1 @@ +__version__ = "0.36.0" diff --git a/third_party/opentelemetry/instrumentation/groq/LICENSE b/third_party/opentelemetry/instrumentation/groq/LICENSE new file mode 100644 index 000000000..0f2a333f0 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/groq/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 openllmetry + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/opentelemetry/instrumentation/groq/NOTICE.md b/third_party/opentelemetry/instrumentation/groq/NOTICE.md new file mode 100644 index 000000000..ca711b794 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/groq/NOTICE.md @@ -0,0 +1,8 @@ +This package contains code derived from the OpenLLMetry project, which is licensed under the Apache License, Version 2.0. + +Original repository: https://github.com/traceloop/openllmetry + +Copyright notice from the original project: +Copyright (c) Traceloop (https://traceloop.com) + +The Apache 2.0 license can be found in the LICENSE file in this directory. diff --git a/third_party/opentelemetry/instrumentation/groq/__init__.py b/third_party/opentelemetry/instrumentation/groq/__init__.py new file mode 100644 index 000000000..a58d0aa2b --- /dev/null +++ b/third_party/opentelemetry/instrumentation/groq/__init__.py @@ -0,0 +1,632 @@ +"""OpenTelemetry Groq instrumentation""" + +import json +import logging +import os +import time +from typing import Callable, Collection + +from groq._streaming import AsyncStream, Stream +from opentelemetry import context as context_api +from opentelemetry.instrumentation.groq.config import Config +from opentelemetry.instrumentation.groq.utils import ( + dont_throw, + error_metrics_attributes, + model_as_dict, + set_span_attribute, + shared_metrics_attributes, + should_send_prompts, +) +from opentelemetry.instrumentation.groq.version import __version__ +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY, unwrap +from opentelemetry.metrics import Counter, Histogram, Meter, get_meter +from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import ( + GEN_AI_RESPONSE_ID, +) +from agentops.semconv import ( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, + LLMRequestTypeValues, + SpanAttributes, + Meters, +) +from opentelemetry.trace import SpanKind, Tracer, get_tracer +from opentelemetry.trace.status import Status, StatusCode +from wrapt import wrap_function_wrapper + +logger = logging.getLogger(__name__) + +_instruments = ("groq >= 0.9.0",) + +CONTENT_FILTER_KEY = "content_filter_results" + +WRAPPED_METHODS = [ + { + "package": "groq.resources.chat.completions", + "object": "Completions", + "method": "create", + "span_name": "groq.chat", + }, +] +WRAPPED_AMETHODS = [ + { + "package": "groq.resources.chat.completions", + "object": "AsyncCompletions", + "method": "create", + "span_name": "groq.chat", + }, +] + + +def is_streaming_response(response): + return isinstance(response, Stream) or isinstance(response, AsyncStream) + + +def _dump_content(content): + if isinstance(content, str): + return content + json_serializable = [] + for item in content: + if item.get("type") == "text": + json_serializable.append({"type": "text", "text": item.get("text")}) + elif item.get("type") == "image": + json_serializable.append( + { + "type": "image", + "source": { + "type": item.get("source").get("type"), + "media_type": item.get("source").get("media_type"), + "data": str(item.get("source").get("data")), + }, + } + ) + return json.dumps(json_serializable) + + +@dont_throw +def _set_input_attributes(span, kwargs): + set_span_attribute(span, SpanAttributes.LLM_REQUEST_MODEL, kwargs.get("model")) + set_span_attribute( + span, SpanAttributes.LLM_REQUEST_MAX_TOKENS, kwargs.get("max_tokens_to_sample") + ) + set_span_attribute( + span, SpanAttributes.LLM_REQUEST_TEMPERATURE, kwargs.get("temperature") + ) + set_span_attribute(span, SpanAttributes.LLM_REQUEST_TOP_P, kwargs.get("top_p")) + set_span_attribute( + span, SpanAttributes.LLM_FREQUENCY_PENALTY, kwargs.get("frequency_penalty") + ) + set_span_attribute( + span, SpanAttributes.LLM_PRESENCE_PENALTY, kwargs.get("presence_penalty") + ) + set_span_attribute( + span, SpanAttributes.LLM_IS_STREAMING, kwargs.get("stream") or False + ) + + if should_send_prompts(): + if kwargs.get("prompt") is not None: + set_span_attribute( + span, f"{SpanAttributes.LLM_PROMPTS}.0.user", kwargs.get("prompt") + ) + + elif kwargs.get("messages") is not None: + for i, message in enumerate(kwargs.get("messages")): + set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.{i}.content", + _dump_content(message.get("content")), + ) + set_span_attribute( + span, f"{SpanAttributes.LLM_PROMPTS}.{i}.role", message.get("role") + ) + + +def _set_completions(span, choices): + if choices is None: + return + + for choice in choices: + index = choice.get("index") + prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}" + set_span_attribute(span, f"{prefix}.finish_reason", choice.get("finish_reason")) + + if choice.get("content_filter_results"): + set_span_attribute( + span, + f"{prefix}.{CONTENT_FILTER_KEY}", + json.dumps(choice.get("content_filter_results")), + ) + + if choice.get("finish_reason") == "content_filter": + set_span_attribute(span, f"{prefix}.role", "assistant") + set_span_attribute(span, f"{prefix}.content", "FILTERED") + + return + + message = choice.get("message") + if not message: + return + + set_span_attribute(span, f"{prefix}.role", message.get("role")) + set_span_attribute(span, f"{prefix}.content", message.get("content")) + + function_call = message.get("function_call") + if function_call: + set_span_attribute( + span, f"{prefix}.tool_calls.0.name", function_call.get("name") + ) + set_span_attribute( + span, + f"{prefix}.tool_calls.0.arguments", + function_call.get("arguments"), + ) + + tool_calls = message.get("tool_calls") + if tool_calls: + for i, tool_call in enumerate(tool_calls): + function = tool_call.get("function") + set_span_attribute( + span, + f"{prefix}.tool_calls.{i}.id", + tool_call.get("id"), + ) + set_span_attribute( + span, + f"{prefix}.tool_calls.{i}.name", + function.get("name"), + ) + set_span_attribute( + span, + f"{prefix}.tool_calls.{i}.arguments", + function.get("arguments"), + ) + + +@dont_throw +def _set_response_attributes(span, response, token_histogram): + response = model_as_dict(response) + set_span_attribute(span, SpanAttributes.LLM_RESPONSE_MODEL, response.get("model")) + set_span_attribute(span, GEN_AI_RESPONSE_ID, response.get("id")) + + usage = response.get("usage") or {} + prompt_tokens = usage.get("prompt_tokens") + completion_tokens = usage.get("completion_tokens") + if usage: + set_span_attribute( + span, SpanAttributes.LLM_USAGE_TOTAL_TOKENS, usage.get("total_tokens") + ) + set_span_attribute( + span, + SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, completion_tokens + ) + set_span_attribute( + span, SpanAttributes.LLM_USAGE_PROMPT_TOKENS, prompt_tokens + ) + + if isinstance(prompt_tokens, int) and prompt_tokens >= 0 and token_histogram is not None: + token_histogram.record(prompt_tokens, attributes={ + SpanAttributes.LLM_TOKEN_TYPE: "input", + SpanAttributes.LLM_RESPONSE_MODEL: response.get("model") + }) + + if isinstance(completion_tokens, int) and completion_tokens >= 0 and token_histogram is not None: + token_histogram.record(completion_tokens, attributes={ + SpanAttributes.LLM_TOKEN_TYPE: "output", + SpanAttributes.LLM_RESPONSE_MODEL: response.get("model") + }) + + choices = response.get("choices") + if should_send_prompts() and choices: + _set_completions(span, choices) + + +def _with_tracer_wrapper(func): + """Helper for providing tracer for wrapper functions.""" + + def _with_tracer(tracer, to_wrap): + def wrapper(wrapped, instance, args, kwargs): + return func(tracer, to_wrap, wrapped, instance, args, kwargs) + + return wrapper + + return _with_tracer + + +def _with_chat_telemetry_wrapper(func): + """Helper for providing tracer for wrapper functions. Includes metric collectors.""" + + def _with_chat_telemetry( + tracer, + token_histogram, + choice_counter, + duration_histogram, + to_wrap, + ): + def wrapper(wrapped, instance, args, kwargs): + return func( + tracer, + token_histogram, + choice_counter, + duration_histogram, + to_wrap, + wrapped, + instance, + args, + kwargs, + ) + + return wrapper + + return _with_chat_telemetry + + +def _create_metrics(meter: Meter): + token_histogram = meter.create_histogram( + name=Meters.LLM_TOKEN_USAGE, + unit="token", + description="Measures number of input and output tokens used", + ) + + choice_counter = meter.create_counter( + name=Meters.LLM_GENERATION_CHOICES, + unit="choice", + description="Number of choices returned by chat completions call", + ) + + duration_histogram = meter.create_histogram( + name=Meters.LLM_OPERATION_DURATION, + unit="s", + description="GenAI operation duration", + ) + + return token_histogram, choice_counter, duration_histogram + + +def _process_streaming_chunk(chunk): + """Extract content, finish_reason and usage from a streaming chunk.""" + if not chunk.choices: + return None, None, None + + delta = chunk.choices[0].delta + content = delta.content if hasattr(delta, "content") else None + finish_reason = chunk.choices[0].finish_reason + + # Extract usage from x_groq if present in the final chunk + usage = None + if hasattr(chunk, "x_groq") and chunk.x_groq and chunk.x_groq.usage: + usage = chunk.x_groq.usage + + return content, finish_reason, usage + + +def _set_streaming_response_attributes( + span, accumulated_content, finish_reason=None, usage=None +): + """Set span attributes for accumulated streaming response.""" + if not span.is_recording(): + return + + prefix = f"{SpanAttributes.LLM_COMPLETIONS}.0" + set_span_attribute(span, f"{prefix}.role", "assistant") + set_span_attribute(span, f"{prefix}.content", accumulated_content) + if finish_reason: + set_span_attribute(span, f"{prefix}.finish_reason", finish_reason) + + if usage: + set_span_attribute( + span, SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, usage.completion_tokens + ) + set_span_attribute( + span, SpanAttributes.LLM_USAGE_PROMPT_TOKENS, usage.prompt_tokens + ) + set_span_attribute( + span, SpanAttributes.LLM_USAGE_TOTAL_TOKENS, usage.total_tokens + ) + + +def _create_stream_processor(response, span): + """Create a generator that processes a stream while collecting telemetry.""" + accumulated_content = "" + finish_reason = None + usage = None + + for chunk in response: + content, chunk_finish_reason, chunk_usage = _process_streaming_chunk(chunk) + if content: + accumulated_content += content + if chunk_finish_reason: + finish_reason = chunk_finish_reason + if chunk_usage: + usage = chunk_usage + yield chunk + + if span.is_recording(): + _set_streaming_response_attributes( + span, accumulated_content, finish_reason, usage + ) + span.set_status(Status(StatusCode.OK)) + span.end() + + +async def _create_async_stream_processor(response, span): + """Create an async generator that processes a stream while collecting telemetry.""" + accumulated_content = "" + finish_reason = None + usage = None + + async for chunk in response: + content, chunk_finish_reason, chunk_usage = _process_streaming_chunk(chunk) + if content: + accumulated_content += content + if chunk_finish_reason: + finish_reason = chunk_finish_reason + if chunk_usage: + usage = chunk_usage + yield chunk + + if span.is_recording(): + _set_streaming_response_attributes( + span, accumulated_content, finish_reason, usage + ) + span.set_status(Status(StatusCode.OK)) + span.end() + + +@_with_chat_telemetry_wrapper +def _wrap( + tracer: Tracer, + token_histogram: Histogram, + choice_counter: Counter, + duration_histogram: Histogram, + to_wrap, + wrapped, + instance, + args, + kwargs, +): + """Instruments and calls every function defined in TO_WRAP.""" + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) or context_api.get_value( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + ): + return wrapped(*args, **kwargs) + + name = to_wrap.get("span_name") + span = tracer.start_span( + name, + kind=SpanKind.CLIENT, + attributes={ + SpanAttributes.LLM_SYSTEM: "Groq", + SpanAttributes.LLM_REQUEST_TYPE: LLMRequestTypeValues.COMPLETION.value, + }, + ) + + if span.is_recording(): + _set_input_attributes(span, kwargs) + + start_time = time.time() + try: + response = wrapped(*args, **kwargs) + except Exception as e: # pylint: disable=broad-except + end_time = time.time() + attributes = error_metrics_attributes(e) + + if duration_histogram: + duration = end_time - start_time + duration_histogram.record(duration, attributes=attributes) + + raise e + + end_time = time.time() + + if is_streaming_response(response): + try: + return _create_stream_processor(response, span) + except Exception as ex: + logger.warning( + "Failed to process streaming response for groq span, error: %s", + str(ex), + ) + span.set_status(Status(StatusCode.ERROR)) + span.end() + raise + elif response: + try: + metric_attributes = shared_metrics_attributes(response) + + if duration_histogram: + duration = time.time() - start_time + duration_histogram.record( + duration, + attributes=metric_attributes, + ) + + if span.is_recording(): + _set_response_attributes(span, response, token_histogram) + + except Exception as ex: # pylint: disable=broad-except + logger.warning( + "Failed to set response attributes for groq span, error: %s", + str(ex), + ) + if span.is_recording(): + span.set_status(Status(StatusCode.OK)) + span.end() + return response + + +@_with_chat_telemetry_wrapper +async def _awrap( + tracer, + token_histogram: Histogram, + choice_counter: Counter, + duration_histogram: Histogram, + to_wrap, + wrapped, + instance, + args, + kwargs, +): + """Instruments and calls every function defined in TO_WRAP.""" + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) or context_api.get_value( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + ): + return await wrapped(*args, **kwargs) + + name = to_wrap.get("span_name") + span = tracer.start_span( + name, + kind=SpanKind.CLIENT, + attributes={ + SpanAttributes.LLM_SYSTEM: "Groq", + SpanAttributes.LLM_REQUEST_TYPE: LLMRequestTypeValues.COMPLETION.value, + }, + ) + try: + if span.is_recording(): + _set_input_attributes(span, kwargs) + + except Exception as ex: # pylint: disable=broad-except + logger.warning( + "Failed to set input attributes for groq span, error: %s", str(ex) + ) + + start_time = time.time() + try: + response = await wrapped(*args, **kwargs) + except Exception as e: # pylint: disable=broad-except + end_time = time.time() + attributes = error_metrics_attributes(e) + + if duration_histogram: + duration = end_time - start_time + duration_histogram.record(duration, attributes=attributes) + + raise e + + end_time = time.time() + + if is_streaming_response(response): + try: + return await _create_async_stream_processor(response, span) + except Exception as ex: + logger.warning( + "Failed to process streaming response for groq span, error: %s", + str(ex), + ) + span.set_status(Status(StatusCode.ERROR)) + span.end() + raise + elif response: + metric_attributes = shared_metrics_attributes(response) + + if duration_histogram: + duration = time.time() - start_time + duration_histogram.record( + duration, + attributes=metric_attributes, + ) + + if span.is_recording(): + _set_response_attributes(span, response, token_histogram) + + if span.is_recording(): + span.set_status(Status(StatusCode.OK)) + span.end() + return response + + +def is_metrics_enabled() -> bool: + return (os.getenv("TRACELOOP_METRICS_ENABLED") or "true").lower() == "true" + + +class GroqInstrumentor(BaseInstrumentor): + """An instrumentor for Groq's client library.""" + + def __init__( + self, + enrich_token_usage: bool = False, + exception_logger=None, + get_common_metrics_attributes: Callable[[], dict] = lambda: {}, + ): + super().__init__() + Config.exception_logger = exception_logger + Config.enrich_token_usage = enrich_token_usage + Config.get_common_metrics_attributes = get_common_metrics_attributes + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + tracer_provider = kwargs.get("tracer_provider") + tracer = get_tracer(__name__, __version__, tracer_provider) + + # meter and counters are inited here + meter_provider = kwargs.get("meter_provider") + meter = get_meter(__name__, __version__, meter_provider) + + if is_metrics_enabled(): + ( + token_histogram, + choice_counter, + duration_histogram, + ) = _create_metrics(meter) + else: + ( + token_histogram, + choice_counter, + duration_histogram, + ) = (None, None, None, None) + + for wrapped_method in WRAPPED_METHODS: + wrap_package = wrapped_method.get("package") + wrap_object = wrapped_method.get("object") + wrap_method = wrapped_method.get("method") + + try: + wrap_function_wrapper( + wrap_package, + f"{wrap_object}.{wrap_method}", + _wrap( + tracer, + token_histogram, + choice_counter, + duration_histogram, + wrapped_method, + ), + ) + except ModuleNotFoundError: + pass # that's ok, we don't want to fail if some methods do not exist + + for wrapped_method in WRAPPED_AMETHODS: + wrap_package = wrapped_method.get("package") + wrap_object = wrapped_method.get("object") + wrap_method = wrapped_method.get("method") + try: + wrap_function_wrapper( + wrap_package, + f"{wrap_object}.{wrap_method}", + _awrap( + tracer, + token_histogram, + choice_counter, + duration_histogram, + wrapped_method, + ), + ) + except ModuleNotFoundError: + pass # that's ok, we don't want to fail if some methods do not exist + + def _uninstrument(self, **kwargs): + for wrapped_method in WRAPPED_METHODS: + wrap_package = wrapped_method.get("package") + wrap_object = wrapped_method.get("object") + unwrap( + f"{wrap_package}.{wrap_object}", + wrapped_method.get("method"), + ) + for wrapped_method in WRAPPED_AMETHODS: + wrap_object = wrapped_method.get("object") + unwrap( + f"groq.resources.completions.{wrap_object}", + wrapped_method.get("method"), + ) diff --git a/third_party/opentelemetry/instrumentation/groq/config.py b/third_party/opentelemetry/instrumentation/groq/config.py new file mode 100644 index 000000000..408df99ee --- /dev/null +++ b/third_party/opentelemetry/instrumentation/groq/config.py @@ -0,0 +1,7 @@ +from typing import Callable + + +class Config: + enrich_token_usage = False + exception_logger = None + get_common_metrics_attributes: Callable[[], dict] = lambda: {} diff --git a/third_party/opentelemetry/instrumentation/groq/utils.py b/third_party/opentelemetry/instrumentation/groq/utils.py new file mode 100644 index 000000000..f3049bbdc --- /dev/null +++ b/third_party/opentelemetry/instrumentation/groq/utils.py @@ -0,0 +1,80 @@ +from importlib.metadata import version +import os +import logging +import traceback +from opentelemetry import context as context_api +from opentelemetry.instrumentation.groq.config import Config +from agentops.semconv import SpanAttributes + +GEN_AI_SYSTEM = "gen_ai.system" +GEN_AI_SYSTEM_GROQ = "groq" + +_PYDANTIC_VERSION = version("pydantic") + + +def set_span_attribute(span, name, value): + if value is not None and value != "": + span.set_attribute(name, value) + + +def should_send_prompts(): + return ( + os.getenv("TRACELOOP_TRACE_CONTENT") or "true" + ).lower() == "true" or context_api.get_value("override_enable_content_tracing") + + +def dont_throw(func): + """ + A decorator that wraps the passed in function and logs exceptions instead of throwing them. + + @param func: The function to wrap + @return: The wrapper function + """ + # Obtain a logger specific to the function's module + logger = logging.getLogger(func.__module__) + + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + logger.debug( + "OpenLLMetry failed to trace in %s, error: %s", + func.__name__, + traceback.format_exc(), + ) + if Config.exception_logger: + Config.exception_logger(e) + + return wrapper + + +@dont_throw +def shared_metrics_attributes(response): + response_dict = model_as_dict(response) + + common_attributes = Config.get_common_metrics_attributes() + + return { + **common_attributes, + GEN_AI_SYSTEM: GEN_AI_SYSTEM_GROQ, + SpanAttributes.LLM_RESPONSE_MODEL: response_dict.get("model"), + } + + +@dont_throw +def error_metrics_attributes(exception): + return { + GEN_AI_SYSTEM: GEN_AI_SYSTEM_GROQ, + "error.type": exception.__class__.__name__, + } + + +def model_as_dict(model): + if _PYDANTIC_VERSION < "2.0.0": + return model.dict() + if hasattr(model, "model_dump"): + return model.model_dump() + elif hasattr(model, "parse"): # Raw API response + return model_as_dict(model.parse()) + else: + return model diff --git a/third_party/opentelemetry/instrumentation/groq/version.py b/third_party/opentelemetry/instrumentation/groq/version.py new file mode 100644 index 000000000..703f9571b --- /dev/null +++ b/third_party/opentelemetry/instrumentation/groq/version.py @@ -0,0 +1 @@ +__version__ = "0.38.7" diff --git a/third_party/opentelemetry/instrumentation/haystack/LICENSE b/third_party/opentelemetry/instrumentation/haystack/LICENSE new file mode 100644 index 000000000..0f2a333f0 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/haystack/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 openllmetry + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/opentelemetry/instrumentation/haystack/NOTICE.md b/third_party/opentelemetry/instrumentation/haystack/NOTICE.md new file mode 100644 index 000000000..ca711b794 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/haystack/NOTICE.md @@ -0,0 +1,8 @@ +This package contains code derived from the OpenLLMetry project, which is licensed under the Apache License, Version 2.0. + +Original repository: https://github.com/traceloop/openllmetry + +Copyright notice from the original project: +Copyright (c) Traceloop (https://traceloop.com) + +The Apache 2.0 license can be found in the LICENSE file in this directory. diff --git a/third_party/opentelemetry/instrumentation/haystack/__init__.py b/third_party/opentelemetry/instrumentation/haystack/__init__.py new file mode 100644 index 000000000..a34df8b77 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/haystack/__init__.py @@ -0,0 +1,120 @@ +import logging +from typing import Collection +from opentelemetry.instrumentation.haystack.config import Config +from wrapt import wrap_function_wrapper + +from opentelemetry.trace import get_tracer +from opentelemetry.metrics import get_meter +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import ( + unwrap, +) +from opentelemetry.instrumentation.haystack.wrap_openai import wrap as openai_wrapper +from opentelemetry.instrumentation.haystack.wrap_pipeline import ( + wrap as pipeline_wrapper, +) +from opentelemetry.instrumentation.haystack.version import __version__ +from agentops.semconv import Meters + +logger = logging.getLogger(__name__) + +_instruments = ("haystack-ai >= 2.0.0",) + +WRAPPED_METHODS = [ + { + "package": "haystack.components.generators.openai", + "object": "OpenAIGenerator", + "method": "run", + "wrapper": openai_wrapper, + }, + { + "package": "haystack.components.generators.chat.openai", + "object": "OpenAIChatGenerator", + "method": "run", + "wrapper": openai_wrapper, + }, + { + "package": "haystack.core.pipeline.pipeline", + "object": "Pipeline", + "method": "run", + "wrapper": pipeline_wrapper, + }, +] + +# Global metrics objects +_tokens_histogram = None +_request_counter = None +_response_time_histogram = None +_pipeline_duration_histogram = None + + +class HaystackInstrumentor(BaseInstrumentor): + """An instrumentor for the Haystack framework.""" + + def __init__(self, exception_logger=None): + super().__init__() + Config.exception_logger = exception_logger + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + tracer_provider = kwargs.get("tracer_provider") + tracer = get_tracer(__name__, __version__, tracer_provider) + + # Initialize metrics + global _tokens_histogram, _request_counter, _response_time_histogram, _pipeline_duration_histogram + meter_provider = kwargs.get("meter_provider") + if meter_provider: + meter = get_meter(__name__, __version__, meter_provider) + + _tokens_histogram = meter.create_histogram( + name=Meters.LLM_TOKEN_USAGE, + unit="token", + description="Measures number of input and output tokens used in Haystack LLM calls" + ) + + _request_counter = meter.create_counter( + name="haystack.requests", + unit="request", + description="Counts Haystack LLM API requests" + ) + + _response_time_histogram = meter.create_histogram( + name="haystack.response_time", + unit="ms", + description="Measures response time for Haystack LLM API calls" + ) + + _pipeline_duration_histogram = meter.create_histogram( + name="haystack.pipeline_duration", + unit="ms", + description="Measures duration of Haystack pipeline executions" + ) + + # Pass metrics to wrappers by updating the Config + Config.tokens_histogram = _tokens_histogram + Config.request_counter = _request_counter + Config.response_time_histogram = _response_time_histogram + Config.pipeline_duration_histogram = _pipeline_duration_histogram + + for wrapped_method in WRAPPED_METHODS: + wrap_package = wrapped_method.get("package") + wrap_object = wrapped_method.get("object") + wrap_method = wrapped_method.get("method") + wrapper = wrapped_method.get("wrapper") + wrap_function_wrapper( + wrap_package, + f"{wrap_object}.{wrap_method}" if wrap_object else wrap_method, + wrapper(tracer, wrapped_method), + ) + + def _uninstrument(self, **kwargs): + for wrapped_method in WRAPPED_METHODS: + wrap_package = wrapped_method.get("package") + wrap_object = wrapped_method.get("object") + wrap_method = wrapped_method.get("method") + unwrap( + f"{wrap_package}.{wrap_object}" if wrap_object else wrap_package, + wrap_method, + ) diff --git a/third_party/opentelemetry/instrumentation/haystack/config.py b/third_party/opentelemetry/instrumentation/haystack/config.py new file mode 100644 index 000000000..2e9e44786 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/haystack/config.py @@ -0,0 +1,6 @@ +class Config: + exception_logger = None + tokens_histogram = None + request_counter = None + response_time_histogram = None + pipeline_duration_histogram = None diff --git a/third_party/opentelemetry/instrumentation/haystack/utils.py b/third_party/opentelemetry/instrumentation/haystack/utils.py new file mode 100644 index 000000000..ce971dd2b --- /dev/null +++ b/third_party/opentelemetry/instrumentation/haystack/utils.py @@ -0,0 +1,120 @@ +import dataclasses +import json +import logging +import os +import traceback + +from opentelemetry import context as context_api +from opentelemetry.instrumentation.haystack.config import Config +from agentops.semconv import SpanAttributes + + +class EnhancedJSONEncoder(json.JSONEncoder): + def default(self, o): + if dataclasses.is_dataclass(o): + return dataclasses.asdict(o) + if hasattr(o, "to_json"): + return o.to_json() + return super().default(o) + + +def should_send_prompts(): + return ( + os.getenv("TRACELOOP_TRACE_CONTENT") or "true" + ).lower() == "true" or context_api.get_value("override_enable_content_tracing") + + +def dont_throw(func): + """ + A decorator that wraps the passed in function and logs exceptions instead of throwing them. + + @param func: The function to wrap + @return: The wrapper function + """ + # Obtain a logger specific to the function's module + logger = logging.getLogger(func.__module__) + + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + logger.debug( + "OpenLLMetry failed to trace in %s, error: %s", func.__name__, str(e) + ) + if Config.exception_logger: + Config.exception_logger(e) + + return wrapper + + +@dont_throw +def process_request(span, args, kwargs): + if should_send_prompts(): + kwargs_to_serialize = kwargs.copy() + for arg in args: + if arg and isinstance(arg, dict): + for key, value in arg.items(): + kwargs_to_serialize[key] = value + args_to_serialize = [arg for arg in args if not isinstance(arg, dict)] + input_entity = {"args": args_to_serialize, "kwargs": kwargs_to_serialize} + span.set_attribute( + SpanAttributes.AGENTOPS_ENTITY_INPUT, + json.dumps(input_entity, cls=EnhancedJSONEncoder), + ) + + +@dont_throw +def process_response(span, response): + if should_send_prompts(): + span.set_attribute( + SpanAttributes.AGENTOPS_ENTITY_OUTPUT, + json.dumps(response, cls=EnhancedJSONEncoder), + ) + + +def set_span_attribute(span, name, value): + if value is not None: + if value != "": + span.set_attribute(name, value) + return + + +def with_tracer_wrapper(func): + """Helper for providing tracer for wrapper functions.""" + + def _with_tracer(tracer, to_wrap): + def wrapper(wrapped, instance, args, kwargs): + # prevent double wrapping + if hasattr(wrapped, "__wrapped__"): + return wrapped(*args, **kwargs) + + return func(tracer, to_wrap, wrapped, instance, args, kwargs) + + return wrapper + + return _with_tracer + + +def dont_throw(func): + """ + A decorator that wraps the passed in function and logs exceptions instead of throwing them. + + @param func: The function to wrap + @return: The wrapper function + """ + # Obtain a logger specific to the function's module + logger = logging.getLogger(func.__module__) + + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + logger.debug( + "OpenLLMetry failed to trace in %s, error: %s", + func.__name__, + traceback.format_exc(), + ) + if Config.exception_logger: + Config.exception_logger(e) + + return wrapper diff --git a/third_party/opentelemetry/instrumentation/haystack/version.py b/third_party/opentelemetry/instrumentation/haystack/version.py new file mode 100644 index 000000000..703f9571b --- /dev/null +++ b/third_party/opentelemetry/instrumentation/haystack/version.py @@ -0,0 +1 @@ +__version__ = "0.38.7" diff --git a/third_party/opentelemetry/instrumentation/haystack/wrap_node.py b/third_party/opentelemetry/instrumentation/haystack/wrap_node.py new file mode 100644 index 000000000..525a36824 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/haystack/wrap_node.py @@ -0,0 +1,28 @@ +import logging +from opentelemetry import context as context_api +from opentelemetry.context import attach, set_value +from opentelemetry.instrumentation.utils import ( + _SUPPRESS_INSTRUMENTATION_KEY, +) +from opentelemetry.instrumentation.haystack.utils import with_tracer_wrapper +from agentops.semconv import SpanAttributes, AgentOpsSpanKindValues + +logger = logging.getLogger(__name__) + + +@with_tracer_wrapper +def wrap(tracer, to_wrap, wrapped, instance, args, kwargs): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + return wrapped(*args, **kwargs) + name = instance.name + attach(set_value("workflow_name", name)) + with tracer.start_as_current_span(f"{name}.task") as span: + span.set_attribute( + SpanAttributes.AGENTOPS_SPAN_KIND, + AgentOpsSpanKindValues.TASK.value, + ) + span.set_attribute(SpanAttributes.AGENTOPS_ENTITY_NAME, name) + + response = wrapped(*args, **kwargs) + + return response diff --git a/third_party/opentelemetry/instrumentation/haystack/wrap_openai.py b/third_party/opentelemetry/instrumentation/haystack/wrap_openai.py new file mode 100644 index 000000000..083798e5b --- /dev/null +++ b/third_party/opentelemetry/instrumentation/haystack/wrap_openai.py @@ -0,0 +1,160 @@ +import logging +import time + +from opentelemetry import context as context_api +from opentelemetry.trace import SpanKind +from opentelemetry.trace.status import Status, StatusCode + +from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY +from agentops.semconv import SpanAttributes, LLMRequestTypeValues +from opentelemetry.instrumentation.haystack.utils import ( + dont_throw, + with_tracer_wrapper, + set_span_attribute, +) +from opentelemetry.instrumentation.haystack.config import Config + +logger = logging.getLogger(__name__) + + +@dont_throw +def _set_input_attributes(span, llm_request_type, kwargs): + + if llm_request_type == LLMRequestTypeValues.COMPLETION: + set_span_attribute( + span, f"{SpanAttributes.LLM_PROMPTS}.0.user", kwargs.get("prompt") + ) + elif llm_request_type == LLMRequestTypeValues.CHAT: + set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.0.user", + [message.content for message in kwargs.get("messages")], + ) + + if "generation_kwargs" in kwargs and kwargs["generation_kwargs"] is not None: + generation_kwargs = kwargs["generation_kwargs"] + if "model" in generation_kwargs: + set_span_attribute( + span, SpanAttributes.LLM_REQUEST_MODEL, generation_kwargs["model"] + ) + if "temperature" in generation_kwargs: + set_span_attribute( + span, + SpanAttributes.LLM_REQUEST_TEMPERATURE, + generation_kwargs["temperature"], + ) + if "top_p" in generation_kwargs: + set_span_attribute( + span, SpanAttributes.LLM_REQUEST_TOP_P, generation_kwargs["top_p"] + ) + if "frequency_penalty" in generation_kwargs: + set_span_attribute( + span, + SpanAttributes.LLM_FREQUENCY_PENALTY, + generation_kwargs["frequency_penalty"], + ) + if "presence_penalty" in generation_kwargs: + set_span_attribute( + span, + SpanAttributes.LLM_PRESENCE_PENALTY, + generation_kwargs["presence_penalty"], + ) + + return + + +def _set_span_completions(span, llm_request_type, choices): + if choices is None: + return + + for index, message in enumerate(choices): + prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}" + + if llm_request_type == LLMRequestTypeValues.CHAT: + if message is not None: + set_span_attribute(span, f"{prefix}.role", "assistant") + set_span_attribute(span, f"{prefix}.content", message) + elif llm_request_type == LLMRequestTypeValues.COMPLETION: + set_span_attribute(span, f"{prefix}.content", message) + + +@dont_throw +def _set_response_attributes(span, llm_request_type, response): + _set_span_completions(span, llm_request_type, response) + + +def _llm_request_type_by_object(object_name): + if object_name == "OpenAIGenerator": + return LLMRequestTypeValues.COMPLETION + elif object_name == "OpenAIChatGenerator": + return LLMRequestTypeValues.CHAT + else: + return LLMRequestTypeValues.UNKNOWN + + +@with_tracer_wrapper +def wrap(tracer, to_wrap, wrapped, instance, args, kwargs): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + return wrapped(*args, **kwargs) + + start_time = time.time() + llm_request_type = _llm_request_type_by_object(to_wrap.get("object")) + + # Get model name from generation_kwargs if available + model = "unknown" + if "generation_kwargs" in kwargs and kwargs["generation_kwargs"] is not None: + if "model" in kwargs["generation_kwargs"]: + model = kwargs["generation_kwargs"]["model"] + + # Record request metric + if Config.request_counter: + Config.request_counter.add( + 1, + { + "model": model, + "provider": "openai", + "request_type": llm_request_type.value + } + ) + + with tracer.start_as_current_span( + ( + SpanAttributes.HAYSTACK_OPENAI_CHAT + if llm_request_type == LLMRequestTypeValues.CHAT + else SpanAttributes.HAYSTACK_OPENAI_COMPLETION + ), + kind=SpanKind.CLIENT, + attributes={ + SpanAttributes.LLM_SYSTEM: "OpenAI", + SpanAttributes.LLM_REQUEST_TYPE: llm_request_type.value, + SpanAttributes.LLM_REQUEST_MODEL: model, + }, + ) as span: + try: + _set_input_attributes(span, llm_request_type, kwargs) + response = wrapped(*args, **kwargs) + + # Record response time + if Config.response_time_histogram: + response_time = (time.time() - start_time) * 1000 # Convert to ms + Config.response_time_histogram.record( + response_time, + { + "model": model, + "provider": "openai", + "request_type": llm_request_type.value + } + ) + + if response: + _set_response_attributes(span, llm_request_type, response) + span.set_status(Status(StatusCode.OK)) + + # We don't have direct access to token counts in Haystack, + # but we could estimate based on response length if needed + + return response + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise diff --git a/third_party/opentelemetry/instrumentation/haystack/wrap_pipeline.py b/third_party/opentelemetry/instrumentation/haystack/wrap_pipeline.py new file mode 100644 index 000000000..a7869e096 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/haystack/wrap_pipeline.py @@ -0,0 +1,54 @@ +import logging +import time +from opentelemetry import context as context_api +from opentelemetry.context import attach, set_value +from opentelemetry.instrumentation.utils import ( + _SUPPRESS_INSTRUMENTATION_KEY, +) +from opentelemetry.instrumentation.haystack.utils import ( + with_tracer_wrapper, + process_request, + process_response, +) +from opentelemetry.instrumentation.haystack.config import Config +from agentops.semconv import SpanAttributes, AgentOpsSpanKindValues + +logger = logging.getLogger(__name__) + + +@with_tracer_wrapper +def wrap(tracer, to_wrap, wrapped, instance, args, kwargs): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + return wrapped(*args, **kwargs) + + name = "haystack_pipeline" + pipeline_name = getattr(instance, "name", name) + start_time = time.time() + + attach(set_value("workflow_name", name)) + with tracer.start_as_current_span(f"{name}.workflow") as span: + span.set_attribute( + SpanAttributes.AGENTOPS_SPAN_KIND, + AgentOpsSpanKindValues.WORKFLOW.value, + ) + span.set_attribute(SpanAttributes.AGENTOPS_ENTITY_NAME, pipeline_name) + process_request(span, args, kwargs) + + try: + response = wrapped(*args, **kwargs) + process_response(span, response) + + # Record pipeline duration + if Config.pipeline_duration_histogram: + duration = (time.time() - start_time) * 1000 # Convert to ms + Config.pipeline_duration_histogram.record( + duration, + { + "pipeline_name": pipeline_name, + } + ) + + return response + except Exception as e: + span.record_exception(e) + raise diff --git a/third_party/opentelemetry/instrumentation/mistralai/LICENSE b/third_party/opentelemetry/instrumentation/mistralai/LICENSE new file mode 100644 index 000000000..0f2a333f0 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/mistralai/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 openllmetry + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/opentelemetry/instrumentation/mistralai/NOTICE.md b/third_party/opentelemetry/instrumentation/mistralai/NOTICE.md new file mode 100644 index 000000000..ca711b794 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/mistralai/NOTICE.md @@ -0,0 +1,8 @@ +This package contains code derived from the OpenLLMetry project, which is licensed under the Apache License, Version 2.0. + +Original repository: https://github.com/traceloop/openllmetry + +Copyright notice from the original project: +Copyright (c) Traceloop (https://traceloop.com) + +The Apache 2.0 license can be found in the LICENSE file in this directory. diff --git a/third_party/opentelemetry/instrumentation/mistralai/__init__.py b/third_party/opentelemetry/instrumentation/mistralai/__init__.py new file mode 100644 index 000000000..3a26d70f5 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/mistralai/__init__.py @@ -0,0 +1,530 @@ +"""OpenTelemetry Mistral AI instrumentation""" + +import logging +import os +import json +import time +from typing import Collection +from opentelemetry.instrumentation.mistralai.config import Config +from opentelemetry.instrumentation.mistralai.utils import dont_throw +from wrapt import wrap_function_wrapper + +from opentelemetry import context as context_api +from opentelemetry.trace import get_tracer, SpanKind +from opentelemetry.trace.status import Status, StatusCode +from opentelemetry.metrics import get_meter + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import ( + _SUPPRESS_INSTRUMENTATION_KEY, + unwrap, +) + +from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import GEN_AI_RESPONSE_ID +from agentops.semconv import ( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, + SpanAttributes, + LLMRequestTypeValues, + Meters, +) +from opentelemetry.instrumentation.mistralai.version import __version__ + +from mistralai.models.chat_completion import ( + ChatMessage, + ChatCompletionResponse, + ChatCompletionResponseChoice, +) +from mistralai.models.common import UsageInfo + +logger = logging.getLogger(__name__) + +_instruments = ("mistralai >= 0.2.0, < 1",) + +WRAPPED_METHODS = [ + { + "method": "chat", + "span_name": "mistralai.chat", + "streaming": False, + }, + { + "method": "chat_stream", + "span_name": "mistralai.chat", + "streaming": True, + }, + { + "method": "embeddings", + "span_name": "mistralai.embeddings", + "streaming": False, + }, +] + +# Global metrics objects +_tokens_histogram = None +_request_counter = None +_response_time_histogram = None + +def should_send_prompts(): + return ( + os.getenv("TRACELOOP_TRACE_CONTENT") or "true" + ).lower() == "true" or context_api.get_value("override_enable_content_tracing") + + +def _set_span_attribute(span, name, value): + if value is not None: + if value != "": + span.set_attribute(name, value) + return + + +@dont_throw +def _set_input_attributes(span, llm_request_type, to_wrap, kwargs): + _set_span_attribute(span, SpanAttributes.LLM_REQUEST_MODEL, kwargs.get("model")) + _set_span_attribute( + span, + SpanAttributes.LLM_IS_STREAMING, + to_wrap.get("streaming"), + ) + + if should_send_prompts(): + if llm_request_type == LLMRequestTypeValues.CHAT: + _set_span_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.0.role", "user") + for index, message in enumerate(kwargs.get("messages")): + _set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.{index}.content", + message.content, + ) + _set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.{index}.role", + message.role, + ) + else: + input = kwargs.get("input") + + if isinstance(input, str): + _set_span_attribute( + span, f"{SpanAttributes.LLM_PROMPTS}.0.role", "user" + ) + _set_span_attribute( + span, f"{SpanAttributes.LLM_PROMPTS}.0.content", input + ) + else: + for index, prompt in enumerate(input): + _set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.{index}.role", + "user", + ) + _set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.{index}.content", + prompt, + ) + + +@dont_throw +def _set_response_attributes(span, llm_request_type, response): + _set_span_attribute(span, GEN_AI_RESPONSE_ID, response.id) + if llm_request_type == LLMRequestTypeValues.EMBEDDING: + return + + if should_send_prompts(): + for index, choice in enumerate(response.choices): + prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}" + _set_span_attribute( + span, + f"{prefix}.finish_reason", + choice.finish_reason, + ) + _set_span_attribute( + span, + f"{prefix}.content", + ( + choice.message.content + if isinstance(choice.message.content, str) + else json.dumps(choice.message.content) + ), + ) + _set_span_attribute( + span, + f"{prefix}.role", + choice.message.role, + ) + + _set_span_attribute(span, SpanAttributes.LLM_RESPONSE_MODEL, response.model) + + if not response.usage: + return + + input_tokens = response.usage.prompt_tokens + output_tokens = response.usage.completion_tokens or 0 + total_tokens = response.usage.total_tokens + + _set_span_attribute( + span, + SpanAttributes.LLM_USAGE_TOTAL_TOKENS, + total_tokens, + ) + _set_span_attribute( + span, + SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, + output_tokens, + ) + _set_span_attribute( + span, + SpanAttributes.LLM_USAGE_PROMPT_TOKENS, + input_tokens, + ) + + +def _accumulate_streaming_response(span, llm_request_type, response): + accumulated_response = ChatCompletionResponse( + id="", + object="", + created=0, + model="", + choices=[], + usage=UsageInfo(prompt_tokens=0, total_tokens=0, completion_tokens=0), + ) + + for res in response: + yield res + + if res.model: + accumulated_response.model = res.model + if res.usage: + accumulated_response.usage = res.usage + # Id is the same for all chunks, so it's safe to overwrite it every time + if res.id: + accumulated_response.id = res.id + + for idx, choice in enumerate(res.choices): + if len(accumulated_response.choices) <= idx: + accumulated_response.choices.append( + ChatCompletionResponseChoice( + index=idx, + message=ChatMessage(role="assistant", content=""), + finish_reason=None, + ) + ) + + accumulated_response.choices[idx].finish_reason = choice.finish_reason + accumulated_response.choices[idx].message.content += choice.delta.content + accumulated_response.choices[idx].message.role = choice.delta.role + + _set_response_attributes(span, llm_request_type, accumulated_response) + span.end() + + +async def _aaccumulate_streaming_response(span, llm_request_type, response): + accumulated_response = ChatCompletionResponse( + id="", + object="", + created=0, + model="", + choices=[], + usage=UsageInfo(prompt_tokens=0, total_tokens=0, completion_tokens=0), + ) + + async for res in response: + yield res + + if res.model: + accumulated_response.model = res.model + if res.usage: + accumulated_response.usage = res.usage + # Id is the same for all chunks, so it's safe to overwrite it every time + if res.id: + accumulated_response.id = res.id + + for idx, choice in enumerate(res.choices): + if len(accumulated_response.choices) <= idx: + accumulated_response.choices.append( + ChatCompletionResponseChoice( + index=idx, + message=ChatMessage(role="assistant", content=""), + finish_reason=None, + ) + ) + + accumulated_response.choices[idx].finish_reason = choice.finish_reason + accumulated_response.choices[idx].message.content += choice.delta.content + accumulated_response.choices[idx].message.role = choice.delta.role + + _set_response_attributes(span, llm_request_type, accumulated_response) + span.end() + + +def _with_tracer_wrapper(func): + """Helper for providing tracer for wrapper functions.""" + + def _with_tracer(tracer, to_wrap): + def wrapper(wrapped, instance, args, kwargs): + return func(tracer, to_wrap, wrapped, instance, args, kwargs) + + return wrapper + + return _with_tracer + + +def _llm_request_type_by_method(method_name): + if method_name == "chat" or method_name == "chat_stream": + return LLMRequestTypeValues.CHAT + elif method_name == "embeddings": + return LLMRequestTypeValues.EMBEDDING + else: + return LLMRequestTypeValues.UNKNOWN + + +@_with_tracer_wrapper +def _wrap(tracer, to_wrap, wrapped, instance, args, kwargs): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) or context_api.get_value( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + ): + return wrapped(*args, **kwargs) + + start_time = time.time() + method_name = to_wrap.get("method", "") + span_name = to_wrap.get("span_name", method_name) + llm_request_type = _llm_request_type_by_method(method_name) + model = kwargs.get("model", "unknown") + + # Record request metric + if _request_counter: + _request_counter.add( + 1, + { + "model": model, + "provider": "mistralai", + "method": method_name, + "streaming": "true" if to_wrap.get("streaming", False) else "false" + } + ) + + with tracer.start_as_current_span( + span_name, + kind=SpanKind.CLIENT, + ) as span: + _set_span_attribute(span, SpanAttributes.LLM_REQUEST_TYPE, llm_request_type) + _set_span_attribute(span, SpanAttributes.LLM_VENDOR, "mistralai") + _set_input_attributes(span, llm_request_type, to_wrap, kwargs) + + try: + response = wrapped(*args, **kwargs) + + # Record response time + if _response_time_histogram: + response_time = (time.time() - start_time) * 1000 # Convert to ms + _response_time_histogram.record( + response_time, + { + "model": model, + "provider": "mistralai", + "method": method_name, + "streaming": "true" if to_wrap.get("streaming", False) else "false" + } + ) + + if to_wrap.get("streaming", False): + response = _accumulate_streaming_response(span, llm_request_type, response) + else: + _set_response_attributes(span, llm_request_type, response) + + # Record token usage if available + if _tokens_histogram and hasattr(response, "usage") and response.usage: + if hasattr(response.usage, "prompt_tokens"): + _tokens_histogram.record( + response.usage.prompt_tokens, + { + "model": model, + "provider": "mistralai", + "token_type": "prompt" + } + ) + + if hasattr(response.usage, "completion_tokens"): + _tokens_histogram.record( + response.usage.completion_tokens, + { + "model": model, + "provider": "mistralai", + "token_type": "completion" + } + ) + + if hasattr(response.usage, "total_tokens"): + _tokens_histogram.record( + response.usage.total_tokens, + { + "model": model, + "provider": "mistralai", + "token_type": "total" + } + ) + + return response + except Exception as ex: + span.set_status(Status(StatusCode.ERROR)) + span.record_exception(ex) + raise + + +@_with_tracer_wrapper +async def _awrap(tracer, to_wrap, wrapped, instance, args, kwargs): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) or context_api.get_value( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + ): + return await wrapped(*args, **kwargs) + + start_time = time.time() + method_name = to_wrap.get("method", "") + span_name = to_wrap.get("span_name", method_name) + llm_request_type = _llm_request_type_by_method(method_name) + model = kwargs.get("model", "unknown") + + # Record request metric + if _request_counter: + _request_counter.add( + 1, + { + "model": model, + "provider": "mistralai", + "method": method_name, + "streaming": "true" if to_wrap.get("streaming", False) else "false" + } + ) + + with tracer.start_as_current_span( + span_name, + kind=SpanKind.CLIENT, + ) as span: + _set_span_attribute(span, SpanAttributes.LLM_REQUEST_TYPE, llm_request_type) + _set_span_attribute(span, SpanAttributes.LLM_VENDOR, "mistralai") + _set_input_attributes(span, llm_request_type, to_wrap, kwargs) + + try: + response = await wrapped(*args, **kwargs) + + # Record response time + if _response_time_histogram: + response_time = (time.time() - start_time) * 1000 # Convert to ms + _response_time_histogram.record( + response_time, + { + "model": model, + "provider": "mistralai", + "method": method_name, + "streaming": "true" if to_wrap.get("streaming", False) else "false" + } + ) + + if to_wrap.get("streaming", False): + response = await _aaccumulate_streaming_response(span, llm_request_type, response) + else: + _set_response_attributes(span, llm_request_type, response) + + # Record token usage if available + if _tokens_histogram and hasattr(response, "usage") and response.usage: + if hasattr(response.usage, "prompt_tokens"): + _tokens_histogram.record( + response.usage.prompt_tokens, + { + "model": model, + "provider": "mistralai", + "token_type": "prompt" + } + ) + + if hasattr(response.usage, "completion_tokens"): + _tokens_histogram.record( + response.usage.completion_tokens, + { + "model": model, + "provider": "mistralai", + "token_type": "completion" + } + ) + + if hasattr(response.usage, "total_tokens"): + _tokens_histogram.record( + response.usage.total_tokens, + { + "model": model, + "provider": "mistralai", + "token_type": "total" + } + ) + + return response + except Exception as ex: + span.set_status(Status(StatusCode.ERROR)) + span.record_exception(ex) + raise + + +class MistralAiInstrumentor(BaseInstrumentor): + """An instrumentor for Mistral AI's client library.""" + + def __init__(self, exception_logger=None): + super().__init__() + Config.exception_logger = exception_logger + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + tracer_provider = kwargs.get("tracer_provider") + tracer = get_tracer(__name__, __version__, tracer_provider) + + # Initialize metrics + global _tokens_histogram, _request_counter, _response_time_histogram + meter_provider = kwargs.get("meter_provider") + if meter_provider: + meter = get_meter(__name__, __version__, meter_provider) + + _tokens_histogram = meter.create_histogram( + name=Meters.LLM_TOKEN_USAGE, + unit="token", + description="Measures number of input and output tokens used in Mistral AI calls" + ) + + _request_counter = meter.create_counter( + name="mistralai.requests", + unit="request", + description="Counts Mistral AI API requests" + ) + + _response_time_histogram = meter.create_histogram( + name="mistralai.response_time", + unit="ms", + description="Measures response time for Mistral AI API calls" + ) + + import mistralai.client + + for wrapped_method in WRAPPED_METHODS: + wrap_function_wrapper( + "mistralai.client", + f"MistralClient.{wrapped_method['method']}", + _wrap(tracer, wrapped_method), + ) + wrap_function_wrapper( + "mistralai.async_client", + f"MistralAsyncClient.{wrapped_method['method']}", + _awrap(tracer, wrapped_method), + ) + + def _uninstrument(self, **kwargs): + import mistralai.client + import mistralai.async_client + + for wrapped_method in WRAPPED_METHODS: + unwrap( + mistralai.client.MistralClient, + wrapped_method["method"], + ) + unwrap( + mistralai.async_client.MistralAsyncClient, + wrapped_method["method"], + ) diff --git a/third_party/opentelemetry/instrumentation/mistralai/config.py b/third_party/opentelemetry/instrumentation/mistralai/config.py new file mode 100644 index 000000000..4689e9292 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/mistralai/config.py @@ -0,0 +1,2 @@ +class Config: + exception_logger = None diff --git a/third_party/opentelemetry/instrumentation/mistralai/utils.py b/third_party/opentelemetry/instrumentation/mistralai/utils.py new file mode 100644 index 000000000..7e390db71 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/mistralai/utils.py @@ -0,0 +1,28 @@ +import logging +import traceback +from opentelemetry.instrumentation.mistralai.config import Config + + +def dont_throw(func): + """ + A decorator that wraps the passed in function and logs exceptions instead of throwing them. + + @param func: The function to wrap + @return: The wrapper function + """ + # Obtain a logger specific to the function's module + logger = logging.getLogger(func.__module__) + + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + logger.debug( + "OpenLLMetry failed to trace in %s, error: %s", + func.__name__, + traceback.format_exc(), + ) + if Config.exception_logger: + Config.exception_logger(e) + + return wrapper diff --git a/third_party/opentelemetry/instrumentation/mistralai/version.py b/third_party/opentelemetry/instrumentation/mistralai/version.py new file mode 100644 index 000000000..703f9571b --- /dev/null +++ b/third_party/opentelemetry/instrumentation/mistralai/version.py @@ -0,0 +1 @@ +__version__ = "0.38.7" diff --git a/third_party/opentelemetry/instrumentation/ollama/LICENSE b/third_party/opentelemetry/instrumentation/ollama/LICENSE new file mode 100644 index 000000000..0f2a333f0 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/ollama/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 openllmetry + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/opentelemetry/instrumentation/ollama/NOTICE.md b/third_party/opentelemetry/instrumentation/ollama/NOTICE.md new file mode 100644 index 000000000..ca711b794 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/ollama/NOTICE.md @@ -0,0 +1,8 @@ +This package contains code derived from the OpenLLMetry project, which is licensed under the Apache License, Version 2.0. + +Original repository: https://github.com/traceloop/openllmetry + +Copyright notice from the original project: +Copyright (c) Traceloop (https://traceloop.com) + +The Apache 2.0 license can be found in the LICENSE file in this directory. diff --git a/third_party/opentelemetry/instrumentation/ollama/__init__.py b/third_party/opentelemetry/instrumentation/ollama/__init__.py new file mode 100644 index 000000000..3f8be3f2b --- /dev/null +++ b/third_party/opentelemetry/instrumentation/ollama/__init__.py @@ -0,0 +1,370 @@ +"""OpenTelemetry Ollama instrumentation""" + +import logging +import os +import json +from typing import Collection +from opentelemetry.instrumentation.ollama.config import Config +from opentelemetry.instrumentation.ollama.utils import dont_throw +from wrapt import wrap_function_wrapper + +from opentelemetry import context as context_api +from opentelemetry.trace import get_tracer, SpanKind +from opentelemetry.trace.status import Status, StatusCode + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import ( + _SUPPRESS_INSTRUMENTATION_KEY, + unwrap, +) + +from agentops.semconv import ( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, + SpanAttributes, + LLMRequestTypeValues, +) +from opentelemetry.instrumentation.ollama.version import __version__ + +logger = logging.getLogger(__name__) + +_instruments = ("ollama >= 0.2.0, < 1",) + +WRAPPED_METHODS = [ + { + "method": "generate", + "span_name": "ollama.completion", + }, + { + "method": "chat", + "span_name": "ollama.chat", + }, + { + "method": "embeddings", + "span_name": "ollama.embeddings", + }, +] + + +def should_send_prompts(): + return ( + os.getenv("TRACELOOP_TRACE_CONTENT") or "true" + ).lower() == "true" or context_api.get_value("override_enable_content_tracing") + + +def _set_span_attribute(span, name, value): + if value is not None: + if value != "": + span.set_attribute(name, value) + return + + +def _set_prompts(span, messages): + if not span.is_recording() or messages is None: + return + for i, msg in enumerate(messages): + prefix = f"{SpanAttributes.LLM_PROMPTS}.{i}" + + _set_span_attribute(span, f"{prefix}.role", msg.get("role")) + if msg.get("content"): + content = msg.get("content") + if isinstance(content, list): + content = json.dumps(content) + _set_span_attribute(span, f"{prefix}.content", content) + if msg.get("tool_call_id"): + _set_span_attribute(span, f"{prefix}.tool_call_id", msg.get("tool_call_id")) + tool_calls = msg.get("tool_calls") + if tool_calls: + for i, tool_call in enumerate(tool_calls): + function = tool_call.get("function") + _set_span_attribute( + span, + f"{prefix}.tool_calls.{i}.id", + tool_call.get("id"), + ) + _set_span_attribute( + span, + f"{prefix}.tool_calls.{i}.name", + function.get("name"), + ) + _set_span_attribute( + span, + f"{prefix}.tool_calls.{i}.arguments", + function.get("arguments"), + ) + + if function.get("arguments"): + function["arguments"] = json.loads(function.get("arguments")) + + +def set_tools_attributes(span, tools): + if not tools: + return + + for i, tool in enumerate(tools): + function = tool.get("function") + if not function: + continue + + prefix = f"{SpanAttributes.LLM_REQUEST_FUNCTIONS}.{i}" + _set_span_attribute(span, f"{prefix}.name", function.get("name")) + _set_span_attribute(span, f"{prefix}.description", function.get("description")) + _set_span_attribute( + span, f"{prefix}.parameters", json.dumps(function.get("parameters")) + ) + + +@dont_throw +def _set_input_attributes(span, llm_request_type, kwargs): + _set_span_attribute(span, SpanAttributes.LLM_REQUEST_MODEL, kwargs.get("model")) + _set_span_attribute( + span, SpanAttributes.LLM_IS_STREAMING, kwargs.get("stream") or False + ) + + if should_send_prompts(): + if llm_request_type == LLMRequestTypeValues.CHAT: + _set_span_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.0.role", "user") + for index, message in enumerate(kwargs.get("messages")): + _set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.{index}.content", + message.get("content"), + ) + _set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.{index}.role", + message.get("role"), + ) + _set_prompts(span, kwargs.get("messages")) + if kwargs.get("tools"): + set_tools_attributes(span, kwargs.get("tools")) + else: + _set_span_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.0.role", "user") + _set_span_attribute( + span, f"{SpanAttributes.LLM_PROMPTS}.0.content", kwargs.get("prompt") + ) + + +@dont_throw +def _set_response_attributes(span, llm_request_type, response): + if should_send_prompts(): + if llm_request_type == LLMRequestTypeValues.COMPLETION: + _set_span_attribute( + span, + f"{SpanAttributes.LLM_COMPLETIONS}.0.content", + response.get("response"), + ) + _set_span_attribute( + span, f"{SpanAttributes.LLM_COMPLETIONS}.0.role", "assistant" + ) + elif llm_request_type == LLMRequestTypeValues.CHAT: + index = 0 + prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}" + _set_span_attribute( + span, f"{prefix}.content", response.get("message").get("content") + ) + _set_span_attribute( + span, f"{prefix}.role", response.get("message").get("role") + ) + + if llm_request_type == LLMRequestTypeValues.EMBEDDING: + return + + _set_span_attribute(span, SpanAttributes.LLM_RESPONSE_MODEL, response.get("model")) + + input_tokens = response.get("prompt_eval_count") or 0 + output_tokens = response.get("eval_count") or 0 + + _set_span_attribute( + span, + SpanAttributes.LLM_USAGE_TOTAL_TOKENS, + input_tokens + output_tokens, + ) + _set_span_attribute( + span, + SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, + output_tokens, + ) + _set_span_attribute( + span, + SpanAttributes.LLM_USAGE_PROMPT_TOKENS, + input_tokens, + ) + + +def _accumulate_streaming_response(span, llm_request_type, response): + if llm_request_type == LLMRequestTypeValues.CHAT: + accumulated_response = {"message": {"content": "", "role": ""}} + elif llm_request_type == LLMRequestTypeValues.COMPLETION: + accumulated_response = {"response": ""} + + for res in response: + yield res + + if llm_request_type == LLMRequestTypeValues.CHAT: + accumulated_response["message"]["content"] += res["message"]["content"] + accumulated_response["message"]["role"] = res["message"]["role"] + elif llm_request_type == LLMRequestTypeValues.COMPLETION: + accumulated_response["response"] += res["response"] + + _set_response_attributes(span, llm_request_type, res | accumulated_response) + span.end() + + +async def _aaccumulate_streaming_response(span, llm_request_type, response): + if llm_request_type == LLMRequestTypeValues.CHAT: + accumulated_response = {"message": {"content": "", "role": ""}} + elif llm_request_type == LLMRequestTypeValues.COMPLETION: + accumulated_response = {"response": ""} + + async for res in response: + yield res + + if llm_request_type == LLMRequestTypeValues.CHAT: + accumulated_response["message"]["content"] += res["message"]["content"] + accumulated_response["message"]["role"] = res["message"]["role"] + elif llm_request_type == LLMRequestTypeValues.COMPLETION: + accumulated_response["response"] += res["response"] + + _set_response_attributes(span, llm_request_type, res | accumulated_response) + span.end() + + +def _with_tracer_wrapper(func): + """Helper for providing tracer for wrapper functions.""" + + def _with_tracer(tracer, to_wrap): + def wrapper(wrapped, instance, args, kwargs): + return func(tracer, to_wrap, wrapped, instance, args, kwargs) + + return wrapper + + return _with_tracer + + +def _llm_request_type_by_method(method_name): + if method_name == "chat": + return LLMRequestTypeValues.CHAT + elif method_name == "generate": + return LLMRequestTypeValues.COMPLETION + elif method_name == "embeddings": + return LLMRequestTypeValues.EMBEDDING + else: + return LLMRequestTypeValues.UNKNOWN + + +@_with_tracer_wrapper +def _wrap(tracer, to_wrap, wrapped, instance, args, kwargs): + """Instruments and calls every function defined in TO_WRAP.""" + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) or context_api.get_value( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + ): + return wrapped(*args, **kwargs) + + name = to_wrap.get("span_name") + llm_request_type = _llm_request_type_by_method(to_wrap.get("method")) + span = tracer.start_span( + name, + kind=SpanKind.CLIENT, + attributes={ + SpanAttributes.LLM_SYSTEM: "Ollama", + SpanAttributes.LLM_REQUEST_TYPE: llm_request_type.value, + }, + ) + if span.is_recording(): + _set_input_attributes(span, llm_request_type, kwargs) + + response = wrapped(*args, **kwargs) + + if response: + if span.is_recording(): + if kwargs.get("stream"): + return _accumulate_streaming_response(span, llm_request_type, response) + + _set_response_attributes(span, llm_request_type, response) + span.set_status(Status(StatusCode.OK)) + + span.end() + return response + + +@_with_tracer_wrapper +async def _awrap(tracer, to_wrap, wrapped, instance, args, kwargs): + """Instruments and calls every function defined in TO_WRAP.""" + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) or context_api.get_value( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + ): + return await wrapped(*args, **kwargs) + + name = to_wrap.get("span_name") + llm_request_type = _llm_request_type_by_method(to_wrap.get("method")) + span = tracer.start_span( + name, + kind=SpanKind.CLIENT, + attributes={ + SpanAttributes.LLM_SYSTEM: "Ollama", + SpanAttributes.LLM_REQUEST_TYPE: llm_request_type.value, + }, + ) + + if span.is_recording(): + _set_input_attributes(span, llm_request_type, kwargs) + + response = await wrapped(*args, **kwargs) + + if response: + if span.is_recording(): + if kwargs.get("stream"): + return _aaccumulate_streaming_response(span, llm_request_type, response) + + _set_response_attributes(span, llm_request_type, response) + span.set_status(Status(StatusCode.OK)) + + span.end() + return response + + +class OllamaInstrumentor(BaseInstrumentor): + """An instrumentor for Ollama's client library.""" + + def __init__(self, exception_logger=None): + super().__init__() + Config.exception_logger = exception_logger + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + tracer_provider = kwargs.get("tracer_provider") + tracer = get_tracer(__name__, __version__, tracer_provider) + for wrapped_method in WRAPPED_METHODS: + wrap_method = wrapped_method.get("method") + wrap_function_wrapper( + "ollama._client", + f"Client.{wrap_method}", + _wrap(tracer, wrapped_method), + ) + wrap_function_wrapper( + "ollama._client", + f"AsyncClient.{wrap_method}", + _awrap(tracer, wrapped_method), + ) + wrap_function_wrapper( + "ollama", + f"{wrap_method}", + _wrap(tracer, wrapped_method), + ) + + def _uninstrument(self, **kwargs): + for wrapped_method in WRAPPED_METHODS: + unwrap( + "ollama._client.Client", + wrapped_method.get("method"), + ) + unwrap( + "ollama._client.AsyncClient", + wrapped_method.get("method"), + ) + unwrap( + "ollama", + wrapped_method.get("method"), + ) diff --git a/third_party/opentelemetry/instrumentation/ollama/config.py b/third_party/opentelemetry/instrumentation/ollama/config.py new file mode 100644 index 000000000..4689e9292 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/ollama/config.py @@ -0,0 +1,2 @@ +class Config: + exception_logger = None diff --git a/third_party/opentelemetry/instrumentation/ollama/utils.py b/third_party/opentelemetry/instrumentation/ollama/utils.py new file mode 100644 index 000000000..5af16c43f --- /dev/null +++ b/third_party/opentelemetry/instrumentation/ollama/utils.py @@ -0,0 +1,28 @@ +import logging +import traceback +from opentelemetry.instrumentation.ollama.config import Config + + +def dont_throw(func): + """ + A decorator that wraps the passed in function and logs exceptions instead of throwing them. + + @param func: The function to wrap + @return: The wrapper function + """ + # Obtain a logger specific to the function's module + logger = logging.getLogger(func.__module__) + + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + logger.debug( + "OpenLLMetry failed to trace in %s, error: %s", + func.__name__, + traceback.format_exc(), + ) + if Config.exception_logger: + Config.exception_logger(e) + + return wrapper diff --git a/third_party/opentelemetry/instrumentation/ollama/version.py b/third_party/opentelemetry/instrumentation/ollama/version.py new file mode 100644 index 000000000..703f9571b --- /dev/null +++ b/third_party/opentelemetry/instrumentation/ollama/version.py @@ -0,0 +1 @@ +__version__ = "0.38.7" diff --git a/third_party/opentelemetry/instrumentation/openai/LICENSE b/third_party/opentelemetry/instrumentation/openai/LICENSE new file mode 100644 index 000000000..0f2a333f0 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/openai/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 openllmetry + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/third_party/opentelemetry/instrumentation/openai/NOTICE.md b/third_party/opentelemetry/instrumentation/openai/NOTICE.md new file mode 100644 index 000000000..ca711b794 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/openai/NOTICE.md @@ -0,0 +1,8 @@ +This package contains code derived from the OpenLLMetry project, which is licensed under the Apache License, Version 2.0. + +Original repository: https://github.com/traceloop/openllmetry + +Copyright notice from the original project: +Copyright (c) Traceloop (https://traceloop.com) + +The Apache 2.0 license can be found in the LICENSE file in this directory. diff --git a/third_party/opentelemetry/instrumentation/openai/__init__.py b/third_party/opentelemetry/instrumentation/openai/__init__.py new file mode 100644 index 000000000..be37afabf --- /dev/null +++ b/third_party/opentelemetry/instrumentation/openai/__init__.py @@ -0,0 +1,55 @@ +from typing import Callable, Collection, Optional +from typing_extensions import Coroutine + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor + +from opentelemetry.instrumentation.openai.shared.config import Config +from opentelemetry.instrumentation.openai.utils import is_openai_v1 + +_instruments = ("openai >= 0.27.0",) + + +class OpenAIInstrumentor(BaseInstrumentor): + """An instrumentor for OpenAI's client library.""" + + def __init__( + self, + enrich_assistant: bool = False, + enrich_token_usage: bool = False, + exception_logger=None, + get_common_metrics_attributes: Callable[[], dict] = lambda: {}, + upload_base64_image: Optional[ + Callable[[str, str, str, str], Coroutine[None, None, str]] + ] = lambda *args: "", + enable_trace_context_propagation: bool = True, + ): + super().__init__() + Config.enrich_assistant = enrich_assistant + Config.enrich_token_usage = enrich_token_usage + Config.exception_logger = exception_logger + Config.get_common_metrics_attributes = get_common_metrics_attributes + Config.upload_base64_image = upload_base64_image + Config.enable_trace_context_propagation = enable_trace_context_propagation + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + if is_openai_v1(): + from opentelemetry.instrumentation.openai.v1 import OpenAIV1Instrumentor + + OpenAIV1Instrumentor().instrument(**kwargs) + else: + from opentelemetry.instrumentation.openai.v0 import OpenAIV0Instrumentor + + OpenAIV0Instrumentor().instrument(**kwargs) + + def _uninstrument(self, **kwargs): + if is_openai_v1(): + from opentelemetry.instrumentation.openai.v1 import OpenAIV1Instrumentor + + OpenAIV1Instrumentor().uninstrument(**kwargs) + else: + from opentelemetry.instrumentation.openai.v0 import OpenAIV0Instrumentor + + OpenAIV0Instrumentor().uninstrument(**kwargs) diff --git a/third_party/opentelemetry/instrumentation/openai/shared/__init__.py b/third_party/opentelemetry/instrumentation/openai/shared/__init__.py new file mode 100644 index 000000000..4eb293551 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/openai/shared/__init__.py @@ -0,0 +1,316 @@ +import os +import openai +import json +import types +import logging + +from importlib.metadata import version + +from opentelemetry import context as context_api +from opentelemetry.trace.propagation import set_span_in_context +from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator + +from opentelemetry.instrumentation.openai.shared.config import Config +from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import ( + GEN_AI_RESPONSE_ID, +) +from agentops.semconv import SpanAttributes +from opentelemetry.instrumentation.openai.utils import ( + dont_throw, + is_openai_v1, + should_record_stream_token_usage, +) + +OPENAI_LLM_USAGE_TOKEN_TYPES = ["prompt_tokens", "completion_tokens"] +PROMPT_FILTER_KEY = "prompt_filter_results" +PROMPT_ERROR = "prompt_error" + +_PYDANTIC_VERSION = version("pydantic") + +# tiktoken encodings map for different model, key is model_name, value is tiktoken encoding +tiktoken_encodings = {} + +logger = logging.getLogger(__name__) + + +def should_send_prompts(): + return ( + os.getenv("TRACELOOP_TRACE_CONTENT") or "true" + ).lower() == "true" or context_api.get_value("override_enable_content_tracing") + + +def _set_span_attribute(span, name, value): + if value is None or value == "": + return + + if hasattr(openai, "NOT_GIVEN") and value == openai.NOT_GIVEN: + return + + span.set_attribute(name, value) + + +def _set_client_attributes(span, instance): + if not span.is_recording(): + return + + if not is_openai_v1(): + return + + client = instance._client # pylint: disable=protected-access + if isinstance(client, (openai.AsyncOpenAI, openai.OpenAI)): + _set_span_attribute( + span, SpanAttributes.LLM_OPENAI_API_BASE, str(client.base_url) + ) + if isinstance(client, (openai.AsyncAzureOpenAI, openai.AzureOpenAI)): + _set_span_attribute( + span, SpanAttributes.LLM_OPENAI_API_VERSION, client._api_version + ) # pylint: disable=protected-access + + +def _set_api_attributes(span): + if not span.is_recording(): + return + + if is_openai_v1(): + return + + base_url = openai.base_url if hasattr(openai, "base_url") else openai.api_base + + _set_span_attribute(span, SpanAttributes.LLM_OPENAI_API_BASE, base_url) + _set_span_attribute(span, SpanAttributes.LLM_OPENAI_API_TYPE, openai.api_type) + _set_span_attribute(span, SpanAttributes.LLM_OPENAI_API_VERSION, openai.api_version) + + return + + +def _set_functions_attributes(span, functions): + if not functions: + return + + for i, function in enumerate(functions): + prefix = f"{SpanAttributes.LLM_REQUEST_FUNCTIONS}.{i}" + _set_span_attribute(span, f"{prefix}.name", function.get("name")) + _set_span_attribute(span, f"{prefix}.description", function.get("description")) + _set_span_attribute( + span, f"{prefix}.parameters", json.dumps(function.get("parameters")) + ) + + +def set_tools_attributes(span, tools): + if not tools: + return + + for i, tool in enumerate(tools): + function = tool.get("function") + if not function: + continue + + prefix = f"{SpanAttributes.LLM_REQUEST_FUNCTIONS}.{i}" + _set_span_attribute(span, f"{prefix}.name", function.get("name")) + _set_span_attribute(span, f"{prefix}.description", function.get("description")) + _set_span_attribute( + span, f"{prefix}.parameters", json.dumps(function.get("parameters")) + ) + + +def _set_request_attributes(span, kwargs): + if not span.is_recording(): + return + + _set_api_attributes(span) + _set_span_attribute(span, SpanAttributes.LLM_SYSTEM, "OpenAI") + _set_span_attribute(span, SpanAttributes.LLM_REQUEST_MODEL, kwargs.get("model")) + _set_span_attribute( + span, SpanAttributes.LLM_REQUEST_MAX_TOKENS, kwargs.get("max_tokens") + ) + _set_span_attribute( + span, SpanAttributes.LLM_REQUEST_TEMPERATURE, kwargs.get("temperature") + ) + _set_span_attribute(span, SpanAttributes.LLM_REQUEST_TOP_P, kwargs.get("top_p")) + _set_span_attribute( + span, SpanAttributes.LLM_FREQUENCY_PENALTY, kwargs.get("frequency_penalty") + ) + _set_span_attribute( + span, SpanAttributes.LLM_PRESENCE_PENALTY, kwargs.get("presence_penalty") + ) + _set_span_attribute(span, SpanAttributes.LLM_USER, kwargs.get("user")) + _set_span_attribute(span, SpanAttributes.LLM_HEADERS, str(kwargs.get("headers"))) + # The new OpenAI SDK removed the `headers` and create new field called `extra_headers` + if kwargs.get("extra_headers") is not None: + _set_span_attribute( + span, SpanAttributes.LLM_HEADERS, str(kwargs.get("extra_headers")) + ) + _set_span_attribute( + span, SpanAttributes.LLM_IS_STREAMING, kwargs.get("stream") or False + ) + + +@dont_throw +def _set_response_attributes(span, response): + if not span.is_recording(): + return + + if "error" in response: + _set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.{PROMPT_ERROR}", + json.dumps(response.get("error")), + ) + return + + _set_span_attribute(span, SpanAttributes.LLM_RESPONSE_MODEL, response.get("model")) + _set_span_attribute(span, GEN_AI_RESPONSE_ID, response.get("id")) + + _set_span_attribute( + span, + SpanAttributes.LLM_OPENAI_RESPONSE_SYSTEM_FINGERPRINT, + response.get("system_fingerprint"), + ) + _log_prompt_filter(span, response) + usage = response.get("usage") + if not usage: + return + + if is_openai_v1() and not isinstance(usage, dict): + usage = usage.__dict__ + + _set_span_attribute( + span, SpanAttributes.LLM_USAGE_TOTAL_TOKENS, usage.get("total_tokens") + ) + _set_span_attribute( + span, + SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, + usage.get("completion_tokens"), + ) + _set_span_attribute( + span, SpanAttributes.LLM_USAGE_PROMPT_TOKENS, usage.get("prompt_tokens") + ) + return + + +def _log_prompt_filter(span, response_dict): + if response_dict.get("prompt_filter_results"): + _set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.{PROMPT_FILTER_KEY}", + json.dumps(response_dict.get("prompt_filter_results")), + ) + + +@dont_throw +def _set_span_stream_usage(span, prompt_tokens, completion_tokens): + if not span.is_recording(): + return + + if type(completion_tokens) is int and completion_tokens >= 0: + _set_span_attribute( + span, SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, completion_tokens + ) + + if type(prompt_tokens) is int and prompt_tokens >= 0: + _set_span_attribute(span, SpanAttributes.LLM_USAGE_PROMPT_TOKENS, prompt_tokens) + + if ( + type(prompt_tokens) is int + and type(completion_tokens) is int + and completion_tokens + prompt_tokens >= 0 + ): + _set_span_attribute( + span, + SpanAttributes.LLM_USAGE_TOTAL_TOKENS, + completion_tokens + prompt_tokens, + ) + + +def _get_openai_base_url(instance): + if hasattr(instance, "_client"): + client = instance._client # pylint: disable=protected-access + if isinstance(client, (openai.AsyncOpenAI, openai.OpenAI)): + return str(client.base_url) + + return "" + + +def is_streaming_response(response): + if is_openai_v1(): + return isinstance(response, openai.Stream) or isinstance( + response, openai.AsyncStream + ) + + return isinstance(response, types.GeneratorType) or isinstance( + response, types.AsyncGeneratorType + ) + + +def model_as_dict(model): + if isinstance(model, dict): + return model + if _PYDANTIC_VERSION < "2.0.0": + return model.dict() + if hasattr(model, "model_dump"): + return model.model_dump() + elif hasattr(model, "parse"): # Raw API response + return model_as_dict(model.parse()) + else: + return model + + +def get_token_count_from_string(string: str, model_name: str): + if not should_record_stream_token_usage(): + return None + + import tiktoken + + if tiktoken_encodings.get(model_name) is None: + try: + encoding = tiktoken.encoding_for_model(model_name) + except KeyError as ex: + # no such model_name in tiktoken + logger.warning( + f"Failed to get tiktoken encoding for model_name {model_name}, error: {str(ex)}" + ) + return None + + tiktoken_encodings[model_name] = encoding + else: + encoding = tiktoken_encodings.get(model_name) + + token_count = len(encoding.encode(string)) + return token_count + + +def _token_type(token_type: str): + if token_type == "prompt_tokens": + return "input" + elif token_type == "completion_tokens": + return "output" + + return None + + +def metric_shared_attributes( + response_model: str, operation: str, server_address: str, is_streaming: bool = False +): + attributes = Config.get_common_metrics_attributes() + + return { + **attributes, + SpanAttributes.LLM_SYSTEM: "openai", + SpanAttributes.LLM_RESPONSE_MODEL: response_model, + "gen_ai.operation.name": operation, + "server.address": server_address, + "stream": is_streaming, + } + + +def propagate_trace_context(span, kwargs): + if is_openai_v1(): + extra_headers = kwargs.get("extra_headers", {}) + ctx = set_span_in_context(span) + TraceContextTextMapPropagator().inject(extra_headers, context=ctx) + kwargs["extra_headers"] = extra_headers + else: + headers = kwargs.get("headers", {}) + ctx = set_span_in_context(span) + TraceContextTextMapPropagator().inject(headers, context=ctx) + kwargs["headers"] = headers diff --git a/third_party/opentelemetry/instrumentation/openai/shared/chat_wrappers.py b/third_party/opentelemetry/instrumentation/openai/shared/chat_wrappers.py new file mode 100644 index 000000000..cf369a0ad --- /dev/null +++ b/third_party/opentelemetry/instrumentation/openai/shared/chat_wrappers.py @@ -0,0 +1,886 @@ +import copy +import json +import logging +import time +from opentelemetry.instrumentation.openai.shared.config import Config +from wrapt import ObjectProxy + + +from opentelemetry import context as context_api +from opentelemetry.metrics import Counter, Histogram +from agentops.semconv import ( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, + SpanAttributes, + LLMRequestTypeValues, +) + +from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY +from opentelemetry.instrumentation.openai.utils import ( + _with_chat_telemetry_wrapper, + dont_throw, + run_async, +) +from opentelemetry.instrumentation.openai.shared import ( + metric_shared_attributes, + _set_client_attributes, + _set_request_attributes, + _set_span_attribute, + _set_functions_attributes, + _token_type, + set_tools_attributes, + _set_response_attributes, + is_streaming_response, + should_send_prompts, + model_as_dict, + _get_openai_base_url, + OPENAI_LLM_USAGE_TOKEN_TYPES, + should_record_stream_token_usage, + get_token_count_from_string, + _set_span_stream_usage, + propagate_trace_context, +) +from opentelemetry.trace import SpanKind, Tracer +from opentelemetry.trace.status import Status, StatusCode + +from opentelemetry.instrumentation.openai.utils import is_openai_v1 + +SPAN_NAME = "openai.chat" +PROMPT_FILTER_KEY = "prompt_filter_results" +CONTENT_FILTER_KEY = "content_filter_results" + +LLM_REQUEST_TYPE = LLMRequestTypeValues.CHAT + +logger = logging.getLogger(__name__) + + +@_with_chat_telemetry_wrapper +def chat_wrapper( + tracer: Tracer, + token_counter: Counter, + choice_counter: Counter, + duration_histogram: Histogram, + exception_counter: Counter, + streaming_time_to_first_token: Histogram, + streaming_time_to_generate: Histogram, + wrapped, + instance, + args, + kwargs, +): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) or context_api.get_value( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + ): + return wrapped(*args, **kwargs) + # span needs to be opened and closed manually because the response is a generator + + span = tracer.start_span( + SPAN_NAME, + kind=SpanKind.CLIENT, + attributes={SpanAttributes.LLM_REQUEST_TYPE: LLM_REQUEST_TYPE.value}, + ) + + run_async(_handle_request(span, kwargs, instance)) + + try: + start_time = time.time() + response = wrapped(*args, **kwargs) + end_time = time.time() + except Exception as e: # pylint: disable=broad-except + end_time = time.time() + duration = end_time - start_time if "start_time" in locals() else 0 + + attributes = { + "error.type": e.__class__.__name__, + } + + if duration > 0 and duration_histogram: + duration_histogram.record(duration, attributes=attributes) + if exception_counter: + exception_counter.add(1, attributes=attributes) + + span.set_status(Status(StatusCode.ERROR, str(e))) + span.end() + + raise e + + if is_streaming_response(response): + # span will be closed after the generator is done + if is_openai_v1(): + return ChatStream( + span, + response, + instance, + token_counter, + choice_counter, + duration_histogram, + streaming_time_to_first_token, + streaming_time_to_generate, + start_time, + kwargs, + ) + else: + return _build_from_streaming_response( + span, + response, + instance, + token_counter, + choice_counter, + duration_histogram, + streaming_time_to_first_token, + streaming_time_to_generate, + start_time, + kwargs, + ) + + duration = end_time - start_time + + _handle_response( + response, + span, + instance, + token_counter, + choice_counter, + duration_histogram, + duration, + ) + span.end() + + return response + + +@_with_chat_telemetry_wrapper +async def achat_wrapper( + tracer: Tracer, + token_counter: Counter, + choice_counter: Counter, + duration_histogram: Histogram, + exception_counter: Counter, + streaming_time_to_first_token: Histogram, + streaming_time_to_generate: Histogram, + wrapped, + instance, + args, + kwargs, +): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) or context_api.get_value( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + ): + return await wrapped(*args, **kwargs) + + span = tracer.start_span( + SPAN_NAME, + kind=SpanKind.CLIENT, + attributes={SpanAttributes.LLM_REQUEST_TYPE: LLM_REQUEST_TYPE.value}, + ) + await _handle_request(span, kwargs, instance) + + try: + start_time = time.time() + response = await wrapped(*args, **kwargs) + end_time = time.time() + except Exception as e: # pylint: disable=broad-except + end_time = time.time() + duration = end_time - start_time if "start_time" in locals() else 0 + + common_attributes = Config.get_common_metrics_attributes() + attributes = { + **common_attributes, + "error.type": e.__class__.__name__, + } + + if duration > 0 and duration_histogram: + duration_histogram.record(duration, attributes=attributes) + if exception_counter: + exception_counter.add(1, attributes=attributes) + + span.set_status(Status(StatusCode.ERROR, str(e))) + span.end() + + raise e + + if is_streaming_response(response): + # span will be closed after the generator is done + if is_openai_v1(): + return ChatStream( + span, + response, + instance, + token_counter, + choice_counter, + duration_histogram, + streaming_time_to_first_token, + streaming_time_to_generate, + start_time, + kwargs, + ) + else: + return _abuild_from_streaming_response( + span, + response, + instance, + token_counter, + choice_counter, + duration_histogram, + streaming_time_to_first_token, + streaming_time_to_generate, + start_time, + kwargs, + ) + + duration = end_time - start_time + + _handle_response( + response, + span, + instance, + token_counter, + choice_counter, + duration_histogram, + duration, + ) + span.end() + + return response + + +@dont_throw +async def _handle_request(span, kwargs, instance): + _set_request_attributes(span, kwargs) + _set_client_attributes(span, instance) + if should_send_prompts(): + await _set_prompts(span, kwargs.get("messages")) + if kwargs.get("functions"): + _set_functions_attributes(span, kwargs.get("functions")) + elif kwargs.get("tools"): + set_tools_attributes(span, kwargs.get("tools")) + if Config.enable_trace_context_propagation: + propagate_trace_context(span, kwargs) + + +@dont_throw +def _handle_response( + response, + span, + instance=None, + token_counter=None, + choice_counter=None, + duration_histogram=None, + duration=None, +): + if is_openai_v1(): + response_dict = model_as_dict(response) + else: + response_dict = response + + # metrics record + _set_chat_metrics( + instance, + token_counter, + choice_counter, + duration_histogram, + response_dict, + duration, + ) + + # span attributes + _set_response_attributes(span, response_dict) + + if should_send_prompts(): + _set_completions(span, response_dict.get("choices")) + + return response + + +def _set_chat_metrics( + instance, token_counter, choice_counter, duration_histogram, response_dict, duration +): + shared_attributes = metric_shared_attributes( + response_model=response_dict.get("model") or None, + operation="chat", + server_address=_get_openai_base_url(instance), + is_streaming=False, + ) + + # token metrics + usage = response_dict.get("usage") # type: dict + if usage and token_counter: + _set_token_counter_metrics(token_counter, usage, shared_attributes) + + # choices metrics + choices = response_dict.get("choices") + if choices and choice_counter: + _set_choice_counter_metrics(choice_counter, choices, shared_attributes) + + # duration metrics + if duration and isinstance(duration, (float, int)) and duration_histogram: + duration_histogram.record(duration, attributes=shared_attributes) + + +def _set_choice_counter_metrics(choice_counter, choices, shared_attributes): + choice_counter.add(len(choices), attributes=shared_attributes) + for choice in choices: + attributes_with_reason = {**shared_attributes} + if choice.get("finish_reason"): + attributes_with_reason[SpanAttributes.LLM_RESPONSE_FINISH_REASON] = ( + choice.get("finish_reason") + ) + choice_counter.add(1, attributes=attributes_with_reason) + + +def _set_token_counter_metrics(token_counter, usage, shared_attributes): + for name, val in usage.items(): + if name in OPENAI_LLM_USAGE_TOKEN_TYPES: + attributes_with_token_type = { + **shared_attributes, + SpanAttributes.LLM_TOKEN_TYPE: _token_type(name), + } + token_counter.record(val, attributes=attributes_with_token_type) + + +def _is_base64_image(item): + if not isinstance(item, dict): + return False + + if not isinstance(item.get("image_url"), dict): + return False + + if "data:image/" not in item.get("image_url", {}).get("url", ""): + return False + + return True + + +async def _process_image_item(item, trace_id, span_id, message_index, content_index): + if not Config.upload_base64_image: + return item + + image_format = item["image_url"]["url"].split(";")[0].split("/")[1] + image_name = f"message_{message_index}_content_{content_index}.{image_format}" + base64_string = item["image_url"]["url"].split(",")[1] + url = await Config.upload_base64_image(trace_id, span_id, image_name, base64_string) + + return {"type": "image_url", "image_url": {"url": url}} + + +@dont_throw +async def _set_prompts(span, messages): + if not span.is_recording() or messages is None: + return + + for i, msg in enumerate(messages): + prefix = f"{SpanAttributes.LLM_PROMPTS}.{i}" + + _set_span_attribute(span, f"{prefix}.role", msg.get("role")) + if msg.get("content"): + content = copy.deepcopy(msg.get("content")) + if isinstance(content, list): + content = [ + ( + await _process_image_item( + item, span.context.trace_id, span.context.span_id, i, j + ) + if _is_base64_image(item) + else item + ) + for j, item in enumerate(content) + ] + + content = json.dumps(content) + _set_span_attribute(span, f"{prefix}.content", content) + if msg.get("tool_call_id"): + _set_span_attribute(span, f"{prefix}.tool_call_id", msg.get("tool_call_id")) + tool_calls = msg.get("tool_calls") + if tool_calls: + for i, tool_call in enumerate(tool_calls): + if is_openai_v1(): + tool_call = model_as_dict(tool_call) + + function = tool_call.get("function") + _set_span_attribute( + span, + f"{prefix}.tool_calls.{i}.id", + tool_call.get("id"), + ) + _set_span_attribute( + span, + f"{prefix}.tool_calls.{i}.name", + function.get("name"), + ) + _set_span_attribute( + span, + f"{prefix}.tool_calls.{i}.arguments", + function.get("arguments"), + ) + + +def _set_completions(span, choices): + if choices is None: + return + + for choice in choices: + index = choice.get("index") + prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}" + _set_span_attribute( + span, f"{prefix}.finish_reason", choice.get("finish_reason") + ) + + if choice.get("content_filter_results"): + _set_span_attribute( + span, + f"{prefix}.{CONTENT_FILTER_KEY}", + json.dumps(choice.get("content_filter_results")), + ) + + if choice.get("finish_reason") == "content_filter": + _set_span_attribute(span, f"{prefix}.role", "assistant") + _set_span_attribute(span, f"{prefix}.content", "FILTERED") + + return + + message = choice.get("message") + if not message: + return + + _set_span_attribute(span, f"{prefix}.role", message.get("role")) + + if message.get("refusal"): + _set_span_attribute(span, f"{prefix}.refusal", message.get("refusal")) + else: + _set_span_attribute(span, f"{prefix}.content", message.get("content")) + + function_call = message.get("function_call") + if function_call: + _set_span_attribute( + span, f"{prefix}.tool_calls.0.name", function_call.get("name") + ) + _set_span_attribute( + span, + f"{prefix}.tool_calls.0.arguments", + function_call.get("arguments"), + ) + + tool_calls = message.get("tool_calls") + if tool_calls: + for i, tool_call in enumerate(tool_calls): + function = tool_call.get("function") + _set_span_attribute( + span, + f"{prefix}.tool_calls.{i}.id", + tool_call.get("id"), + ) + _set_span_attribute( + span, + f"{prefix}.tool_calls.{i}.name", + function.get("name"), + ) + _set_span_attribute( + span, + f"{prefix}.tool_calls.{i}.arguments", + function.get("arguments"), + ) + + +@dont_throw +def _set_streaming_token_metrics( + request_kwargs, complete_response, span, token_counter, shared_attributes +): + # use tiktoken calculate token usage + if not should_record_stream_token_usage(): + return + + # kwargs={'model': 'gpt-3.5', 'messages': [{'role': 'user', 'content': '...'}], 'stream': True} + prompt_usage = -1 + completion_usage = -1 + + # prompt_usage + if request_kwargs and request_kwargs.get("messages"): + prompt_content = "" + # setting the default model_name as gpt-4. As this uses the embedding "cl100k_base" that + # is used by most of the other model. + model_name = ( + complete_response.get("model") or request_kwargs.get("model") or "gpt-4" + ) + for msg in request_kwargs.get("messages"): + if msg.get("content"): + prompt_content += msg.get("content") + if model_name: + prompt_usage = get_token_count_from_string(prompt_content, model_name) + + # completion_usage + if complete_response.get("choices"): + completion_content = "" + # setting the default model_name as gpt-4. As this uses the embedding "cl100k_base" that + # is used by most of the other model. + model_name = complete_response.get("model") or "gpt-4" + + for choice in complete_response.get("choices"): + if choice.get("message") and choice.get("message").get("content"): + completion_content += choice["message"]["content"] + + if model_name: + completion_usage = get_token_count_from_string( + completion_content, model_name + ) + + # span record + _set_span_stream_usage(span, prompt_usage, completion_usage) + + # metrics record + if token_counter: + if type(prompt_usage) is int and prompt_usage >= 0: + attributes_with_token_type = { + **shared_attributes, + SpanAttributes.LLM_TOKEN_TYPE: "input", + } + token_counter.record(prompt_usage, attributes=attributes_with_token_type) + + if type(completion_usage) is int and completion_usage >= 0: + attributes_with_token_type = { + **shared_attributes, + SpanAttributes.LLM_TOKEN_TYPE: "output", + } + token_counter.record( + completion_usage, attributes=attributes_with_token_type + ) + + +class ChatStream(ObjectProxy): + _span = None + _instance = None + _token_counter = None + _choice_counter = None + _duration_histogram = None + _streaming_time_to_first_token = None + _streaming_time_to_generate = None + _start_time = None + _request_kwargs = None + + def __init__( + self, + span, + response, + instance=None, + token_counter=None, + choice_counter=None, + duration_histogram=None, + streaming_time_to_first_token=None, + streaming_time_to_generate=None, + start_time=None, + request_kwargs=None, + ): + super().__init__(response) + + self._span = span + self._instance = instance + self._token_counter = token_counter + self._choice_counter = choice_counter + self._duration_histogram = duration_histogram + self._streaming_time_to_first_token = streaming_time_to_first_token + self._streaming_time_to_generate = streaming_time_to_generate + self._start_time = start_time + self._request_kwargs = request_kwargs + + self._first_token = True + # will be updated when first token is received + self._time_of_first_token = self._start_time + self._complete_response = {"choices": [], "model": ""} + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.__wrapped__.__exit__(exc_type, exc_val, exc_tb) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.__wrapped__.__aexit__(exc_type, exc_val, exc_tb) + + def __iter__(self): + return self + + def __aiter__(self): + return self + + def __next__(self): + try: + chunk = self.__wrapped__.__next__() + except Exception as e: + if isinstance(e, StopIteration): + self._close_span() + raise e + else: + self._process_item(chunk) + return chunk + + async def __anext__(self): + try: + chunk = await self.__wrapped__.__anext__() + except Exception as e: + if isinstance(e, StopAsyncIteration): + self._close_span() + raise e + else: + self._process_item(chunk) + return chunk + + def _process_item(self, item): + self._span.add_event(name=f"{SpanAttributes.LLM_CONTENT_COMPLETION_CHUNK}") + + if self._first_token and self._streaming_time_to_first_token: + self._time_of_first_token = time.time() + self._streaming_time_to_first_token.record( + self._time_of_first_token - self._start_time, + attributes=self._shared_attributes(), + ) + self._first_token = False + + _accumulate_stream_items(item, self._complete_response) + + def _shared_attributes(self): + return metric_shared_attributes( + response_model=self._complete_response.get("model") + or self._request_kwargs.get("model") + or None, + operation="chat", + server_address=_get_openai_base_url(self._instance), + is_streaming=True, + ) + + @dont_throw + def _close_span(self): + _set_streaming_token_metrics( + self._request_kwargs, + self._complete_response, + self._span, + self._token_counter, + self._shared_attributes(), + ) + + # choice metrics + if self._choice_counter and self._complete_response.get("choices"): + _set_choice_counter_metrics( + self._choice_counter, + self._complete_response.get("choices"), + self._shared_attributes(), + ) + + # duration metrics + if self._start_time and isinstance(self._start_time, (float, int)): + duration = time.time() - self._start_time + else: + duration = None + if duration and isinstance(duration, (float, int)) and self._duration_histogram: + self._duration_histogram.record( + duration, attributes=self._shared_attributes() + ) + if self._streaming_time_to_generate and self._time_of_first_token: + self._streaming_time_to_generate.record( + time.time() - self._time_of_first_token, + attributes=self._shared_attributes(), + ) + + _set_response_attributes(self._span, self._complete_response) + + if should_send_prompts(): + _set_completions(self._span, self._complete_response.get("choices")) + + self._span.set_status(Status(StatusCode.OK)) + self._span.end() + + +# Backward compatibility with OpenAI v0 + + +@dont_throw +def _build_from_streaming_response( + span, + response, + instance=None, + token_counter=None, + choice_counter=None, + duration_histogram=None, + streaming_time_to_first_token=None, + streaming_time_to_generate=None, + start_time=None, + request_kwargs=None, +): + complete_response = {"choices": [], "model": "", "id": ""} + + first_token = True + time_of_first_token = start_time # will be updated when first token is received + + for item in response: + span.add_event(name=f"{SpanAttributes.LLM_CONTENT_COMPLETION_CHUNK}") + + item_to_yield = item + + if first_token and streaming_time_to_first_token: + time_of_first_token = time.time() + streaming_time_to_first_token.record(time_of_first_token - start_time) + first_token = False + + _accumulate_stream_items(item, complete_response) + + yield item_to_yield + + shared_attributes = { + SpanAttributes.LLM_RESPONSE_MODEL: complete_response.get("model") or None, + "server.address": _get_openai_base_url(instance), + "stream": True, + } + + _set_streaming_token_metrics( + request_kwargs, complete_response, span, token_counter, shared_attributes + ) + + # choice metrics + if choice_counter and complete_response.get("choices"): + _set_choice_counter_metrics( + choice_counter, complete_response.get("choices"), shared_attributes + ) + + # duration metrics + if start_time and isinstance(start_time, (float, int)): + duration = time.time() - start_time + else: + duration = None + if duration and isinstance(duration, (float, int)) and duration_histogram: + duration_histogram.record(duration, attributes=shared_attributes) + if streaming_time_to_generate and time_of_first_token: + streaming_time_to_generate.record(time.time() - time_of_first_token) + + _set_response_attributes(span, complete_response) + + if should_send_prompts(): + _set_completions(span, complete_response.get("choices")) + + span.set_status(Status(StatusCode.OK)) + span.end() + + +@dont_throw +async def _abuild_from_streaming_response( + span, + response, + instance=None, + token_counter=None, + choice_counter=None, + duration_histogram=None, + streaming_time_to_first_token=None, + streaming_time_to_generate=None, + start_time=None, + request_kwargs=None, +): + complete_response = {"choices": [], "model": "", "id": ""} + + first_token = True + time_of_first_token = start_time # will be updated when first token is received + + async for item in response: + span.add_event(name=f"{SpanAttributes.LLM_CONTENT_COMPLETION_CHUNK}") + + item_to_yield = item + + if first_token and streaming_time_to_first_token: + time_of_first_token = time.time() + streaming_time_to_first_token.record(time_of_first_token - start_time) + first_token = False + + _accumulate_stream_items(item, complete_response) + + yield item_to_yield + + shared_attributes = { + SpanAttributes.LLM_RESPONSE_MODEL: complete_response.get("model") or None, + "server.address": _get_openai_base_url(instance), + "stream": True, + } + + _set_streaming_token_metrics( + request_kwargs, complete_response, span, token_counter, shared_attributes + ) + + # choice metrics + if choice_counter and complete_response.get("choices"): + _set_choice_counter_metrics( + choice_counter, complete_response.get("choices"), shared_attributes + ) + + # duration metrics + if start_time and isinstance(start_time, (float, int)): + duration = time.time() - start_time + else: + duration = None + if duration and isinstance(duration, (float, int)) and duration_histogram: + duration_histogram.record(duration, attributes=shared_attributes) + if streaming_time_to_generate and time_of_first_token: + streaming_time_to_generate.record(time.time() - time_of_first_token) + + _set_response_attributes(span, complete_response) + + if should_send_prompts(): + _set_completions(span, complete_response.get("choices")) + + span.set_status(Status(StatusCode.OK)) + span.end() + + +def _accumulate_stream_items(item, complete_response): + if is_openai_v1(): + item = model_as_dict(item) + + complete_response["model"] = item.get("model") + complete_response["id"] = item.get("id") + + # prompt filter results + if item.get("prompt_filter_results"): + complete_response["prompt_filter_results"] = item.get("prompt_filter_results") + + for choice in item.get("choices"): + index = choice.get("index") + if len(complete_response.get("choices")) <= index: + complete_response["choices"].append( + {"index": index, "message": {"content": "", "role": ""}} + ) + complete_choice = complete_response.get("choices")[index] + if choice.get("finish_reason"): + complete_choice["finish_reason"] = choice.get("finish_reason") + if choice.get("content_filter_results"): + complete_choice["content_filter_results"] = choice.get( + "content_filter_results" + ) + + delta = choice.get("delta") + + if delta and delta.get("content"): + complete_choice["message"]["content"] += delta.get("content") + + if delta and delta.get("role"): + complete_choice["message"]["role"] = delta.get("role") + if delta and delta.get("tool_calls"): + tool_calls = delta.get("tool_calls") + if not isinstance(tool_calls, list) or len(tool_calls) == 0: + continue + + if not complete_choice["message"].get("tool_calls"): + complete_choice["message"]["tool_calls"] = [] + + for tool_call in tool_calls: + i = int(tool_call["index"]) + if len(complete_choice["message"]["tool_calls"]) <= i: + complete_choice["message"]["tool_calls"].append( + {"id": "", "function": {"name": "", "arguments": ""}} + ) + + span_tool_call = complete_choice["message"]["tool_calls"][i] + span_function = span_tool_call["function"] + tool_call_function = tool_call.get("function") + + if tool_call.get("id"): + span_tool_call["id"] = tool_call.get("id") + if tool_call_function and tool_call_function.get("name"): + span_function["name"] = tool_call_function.get("name") + if tool_call_function and tool_call_function.get("arguments"): + span_function["arguments"] += tool_call_function.get("arguments") diff --git a/third_party/opentelemetry/instrumentation/openai/shared/completion_wrappers.py b/third_party/opentelemetry/instrumentation/openai/shared/completion_wrappers.py new file mode 100644 index 000000000..e3eb23137 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/openai/shared/completion_wrappers.py @@ -0,0 +1,240 @@ +import logging + +from opentelemetry import context as context_api + +from agentops.semconv import ( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, + SpanAttributes, + LLMRequestTypeValues, +) + +from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY +from opentelemetry.instrumentation.openai.utils import _with_tracer_wrapper, dont_throw +from opentelemetry.instrumentation.openai.shared import ( + _set_client_attributes, + _set_request_attributes, + _set_span_attribute, + _set_functions_attributes, + _set_response_attributes, + is_streaming_response, + should_send_prompts, + model_as_dict, + should_record_stream_token_usage, + get_token_count_from_string, + _set_span_stream_usage, + propagate_trace_context, +) + +from opentelemetry.instrumentation.openai.utils import is_openai_v1 + +from opentelemetry.trace import SpanKind +from opentelemetry.trace.status import Status, StatusCode + +from opentelemetry.instrumentation.openai.shared.config import Config + +SPAN_NAME = "openai.completion" +LLM_REQUEST_TYPE = LLMRequestTypeValues.COMPLETION + +logger = logging.getLogger(__name__) + + +@_with_tracer_wrapper +def completion_wrapper(tracer, wrapped, instance, args, kwargs): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) or context_api.get_value( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + ): + return wrapped(*args, **kwargs) + + # span needs to be opened and closed manually because the response is a generator + span = tracer.start_span( + SPAN_NAME, + kind=SpanKind.CLIENT, + attributes={SpanAttributes.LLM_REQUEST_TYPE: LLM_REQUEST_TYPE.value}, + ) + + _handle_request(span, kwargs, instance) + try: + response = wrapped(*args, **kwargs) + except Exception as e: + span.set_status(Status(StatusCode.ERROR, str(e))) + span.end() + raise e + + if is_streaming_response(response): + # span will be closed after the generator is done + return _build_from_streaming_response(span, kwargs, response) + else: + _handle_response(response, span) + + span.end() + return response + + +@_with_tracer_wrapper +async def acompletion_wrapper(tracer, wrapped, instance, args, kwargs): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) or context_api.get_value( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + ): + return await wrapped(*args, **kwargs) + + span = tracer.start_span( + name=SPAN_NAME, + kind=SpanKind.CLIENT, + attributes={SpanAttributes.LLM_REQUEST_TYPE: LLM_REQUEST_TYPE.value}, + ) + + _handle_request(span, kwargs, instance) + try: + response = await wrapped(*args, **kwargs) + except Exception as e: + span.set_status(Status(StatusCode.ERROR, str(e))) + span.end() + raise e + + if is_streaming_response(response): + # span will be closed after the generator is done + return _abuild_from_streaming_response(span, kwargs, response) + else: + _handle_response(response, span) + + span.end() + return response + + +@dont_throw +def _handle_request(span, kwargs, instance): + _set_request_attributes(span, kwargs) + if should_send_prompts(): + _set_prompts(span, kwargs.get("prompt")) + _set_functions_attributes(span, kwargs.get("functions")) + _set_client_attributes(span, instance) + if Config.enable_trace_context_propagation: + propagate_trace_context(span, kwargs) + + +@dont_throw +def _handle_response(response, span): + if is_openai_v1(): + response_dict = model_as_dict(response) + else: + response_dict = response + + _set_response_attributes(span, response_dict) + + if should_send_prompts(): + _set_completions(span, response_dict.get("choices")) + + +def _set_prompts(span, prompt): + if not span.is_recording() or not prompt: + return + + _set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.0.user", + prompt[0] if isinstance(prompt, list) else prompt, + ) + + +@dont_throw +def _set_completions(span, choices): + if not span.is_recording() or not choices: + return + + for choice in choices: + index = choice.get("index") + prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}" + _set_span_attribute( + span, f"{prefix}.finish_reason", choice.get("finish_reason") + ) + _set_span_attribute(span, f"{prefix}.content", choice.get("text")) + + +@dont_throw +def _build_from_streaming_response(span, request_kwargs, response): + complete_response = {"choices": [], "model": "", "id": ""} + for item in response: + yield item + _accumulate_streaming_response(complete_response, item) + + _set_response_attributes(span, complete_response) + + _set_token_usage(span, request_kwargs, complete_response) + + if should_send_prompts(): + _set_completions(span, complete_response.get("choices")) + + span.set_status(Status(StatusCode.OK)) + span.end() + + +@dont_throw +async def _abuild_from_streaming_response(span, request_kwargs, response): + complete_response = {"choices": [], "model": "", "id": ""} + async for item in response: + yield item + _accumulate_streaming_response(complete_response, item) + + _set_response_attributes(span, complete_response) + + _set_token_usage(span, request_kwargs, complete_response) + + if should_send_prompts(): + _set_completions(span, complete_response.get("choices")) + + span.set_status(Status(StatusCode.OK)) + span.end() + + +@dont_throw +def _set_token_usage(span, request_kwargs, complete_response): + # use tiktoken calculate token usage + if should_record_stream_token_usage(): + prompt_usage = -1 + completion_usage = -1 + + # prompt_usage + if request_kwargs and request_kwargs.get("prompt"): + prompt_content = request_kwargs.get("prompt") + model_name = complete_response.get("model") or None + + if model_name: + prompt_usage = get_token_count_from_string(prompt_content, model_name) + + # completion_usage + if complete_response.get("choices"): + completion_content = "" + model_name = complete_response.get("model") or None + + for choice in complete_response.get("choices"): + if choice.get("text"): + completion_content += choice.get("text") + + if model_name: + completion_usage = get_token_count_from_string( + completion_content, model_name + ) + + # span record + _set_span_stream_usage(span, prompt_usage, completion_usage) + + +@dont_throw +def _accumulate_streaming_response(complete_response, item): + if is_openai_v1(): + item = model_as_dict(item) + + complete_response["model"] = item.get("model") + complete_response["id"] = item.get("id") + for choice in item.get("choices"): + index = choice.get("index") + if len(complete_response.get("choices")) <= index: + complete_response["choices"].append({"index": index, "text": ""}) + complete_choice = complete_response.get("choices")[index] + if choice.get("finish_reason"): + complete_choice["finish_reason"] = choice.get("finish_reason") + + if choice.get("text"): + complete_choice["text"] += choice.get("text") + + return complete_response diff --git a/third_party/opentelemetry/instrumentation/openai/shared/config.py b/third_party/opentelemetry/instrumentation/openai/shared/config.py new file mode 100644 index 000000000..18f44690c --- /dev/null +++ b/third_party/opentelemetry/instrumentation/openai/shared/config.py @@ -0,0 +1,10 @@ +from typing import Callable + + +class Config: + enrich_token_usage = False + enrich_assistant = False + exception_logger = None + get_common_metrics_attributes: Callable[[], dict] = lambda: {} + upload_base64_image: Callable[[str, str, str], str] = lambda trace_id, span_id, base64_image_url: str + enable_trace_context_propagation: bool = True diff --git a/third_party/opentelemetry/instrumentation/openai/shared/embeddings_wrappers.py b/third_party/opentelemetry/instrumentation/openai/shared/embeddings_wrappers.py new file mode 100644 index 000000000..ee4972dfb --- /dev/null +++ b/third_party/opentelemetry/instrumentation/openai/shared/embeddings_wrappers.py @@ -0,0 +1,257 @@ +import logging +import time + +from opentelemetry import context as context_api +from opentelemetry.metrics import Counter, Histogram +from agentops.semconv import ( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, + SpanAttributes, + LLMRequestTypeValues, +) + +from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY +from opentelemetry.instrumentation.openai.utils import ( + dont_throw, + start_as_current_span_async, + _with_embeddings_telemetry_wrapper, +) +from opentelemetry.instrumentation.openai.shared import ( + metric_shared_attributes, + _set_client_attributes, + _set_request_attributes, + _set_span_attribute, + _set_response_attributes, + _token_type, + should_send_prompts, + model_as_dict, + _get_openai_base_url, + OPENAI_LLM_USAGE_TOKEN_TYPES, + propagate_trace_context, +) + +from opentelemetry.instrumentation.openai.shared.config import Config + +from opentelemetry.instrumentation.openai.utils import is_openai_v1 + +from opentelemetry.trace import SpanKind +from opentelemetry.trace import Status, StatusCode + +SPAN_NAME = "openai.embeddings" +LLM_REQUEST_TYPE = LLMRequestTypeValues.EMBEDDING + +logger = logging.getLogger(__name__) + + +@_with_embeddings_telemetry_wrapper +def embeddings_wrapper( + tracer, + token_counter: Counter, + vector_size_counter: Counter, + duration_histogram: Histogram, + exception_counter: Counter, + wrapped, + instance, + args, + kwargs, +): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) or context_api.get_value( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + ): + return wrapped(*args, **kwargs) + + with tracer.start_as_current_span( + name=SPAN_NAME, + kind=SpanKind.CLIENT, + attributes={SpanAttributes.LLM_REQUEST_TYPE: LLM_REQUEST_TYPE.value}, + ) as span: + _handle_request(span, kwargs, instance) + + try: + # record time for duration + start_time = time.time() + response = wrapped(*args, **kwargs) + end_time = time.time() + except Exception as e: # pylint: disable=broad-except + end_time = time.time() + duration = end_time - start_time if "start_time" in locals() else 0 + attributes = { + "error.type": e.__class__.__name__, + } + + # if there are legal duration, record it + if duration > 0 and duration_histogram: + duration_histogram.record(duration, attributes=attributes) + if exception_counter: + exception_counter.add(1, attributes=attributes) + + span.set_status(Status(StatusCode.ERROR, str(e))) + span.end() + + raise e + + duration = end_time - start_time + + _handle_response( + response, + span, + instance, + token_counter, + vector_size_counter, + duration_histogram, + duration, + ) + + return response + + +@_with_embeddings_telemetry_wrapper +async def aembeddings_wrapper( + tracer, + token_counter: Counter, + vector_size_counter: Counter, + duration_histogram: Histogram, + exception_counter: Counter, + wrapped, + instance, + args, + kwargs, +): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) or context_api.get_value( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + ): + return await wrapped(*args, **kwargs) + + async with start_as_current_span_async( + tracer=tracer, + name=SPAN_NAME, + kind=SpanKind.CLIENT, + attributes={SpanAttributes.LLM_REQUEST_TYPE: LLM_REQUEST_TYPE.value}, + ) as span: + _handle_request(span, kwargs, instance) + try: + # record time for duration + start_time = time.time() + response = await wrapped(*args, **kwargs) + end_time = time.time() + except Exception as e: # pylint: disable=broad-except + end_time = time.time() + duration = end_time - start_time if "start_time" in locals() else 0 + attributes = { + "error.type": e.__class__.__name__, + } + + # if there are legal duration, record it + if duration > 0 and duration_histogram: + duration_histogram.record(duration, attributes=attributes) + if exception_counter: + exception_counter.add(1, attributes=attributes) + + span.set_status(Status(StatusCode.ERROR, str(e))) + span.end() + + raise e + + duration = end_time - start_time + _handle_response( + response, + span, + instance, + token_counter, + vector_size_counter, + duration_histogram, + duration, + ) + + return response + + +@dont_throw +def _handle_request(span, kwargs, instance): + _set_request_attributes(span, kwargs) + if should_send_prompts(): + _set_prompts(span, kwargs.get("input")) + _set_client_attributes(span, instance) + if Config.enable_trace_context_propagation: + propagate_trace_context(span, kwargs) + + +@dont_throw +def _handle_response( + response, + span, + instance=None, + token_counter=None, + vector_size_counter=None, + duration_histogram=None, + duration=None, +): + if is_openai_v1(): + response_dict = model_as_dict(response) + else: + response_dict = response + # metrics record + _set_embeddings_metrics( + instance, + token_counter, + vector_size_counter, + duration_histogram, + response_dict, + duration, + ) + # span attributes + _set_response_attributes(span, response_dict) + + +def _set_embeddings_metrics( + instance, + token_counter, + vector_size_counter, + duration_histogram, + response_dict, + duration, +): + shared_attributes = metric_shared_attributes( + response_model=response_dict.get("model") or None, + operation="embeddings", + server_address=_get_openai_base_url(instance), + ) + + # token count metrics + usage = response_dict.get("usage") + if usage and token_counter: + for name, val in usage.items(): + if name in OPENAI_LLM_USAGE_TOKEN_TYPES: + if val is None: + logging.error(f"Received None value for {name} in usage") + continue + attributes_with_token_type = { + **shared_attributes, + SpanAttributes.LLM_TOKEN_TYPE: _token_type(name), + } + token_counter.record(val, attributes=attributes_with_token_type) + + # vec size metrics + # should use counter for vector_size? + vec_embedding = (response_dict.get("data") or [{}])[0].get("embedding", []) + vec_size = len(vec_embedding) + if vector_size_counter: + vector_size_counter.add(vec_size, attributes=shared_attributes) + + # duration metrics + if duration and isinstance(duration, (float, int)) and duration_histogram: + duration_histogram.record(duration, attributes=shared_attributes) + + +def _set_prompts(span, prompt): + if not span.is_recording() or not prompt: + return + + if isinstance(prompt, list): + for i, p in enumerate(prompt): + _set_span_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.{i}.content", p) + else: + _set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.0.content", + prompt, + ) diff --git a/third_party/opentelemetry/instrumentation/openai/shared/image_gen_wrappers.py b/third_party/opentelemetry/instrumentation/openai/shared/image_gen_wrappers.py new file mode 100644 index 000000000..a25d16861 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/openai/shared/image_gen_wrappers.py @@ -0,0 +1,68 @@ +import time + +from opentelemetry import context as context_api +from opentelemetry.instrumentation.openai import is_openai_v1 +from opentelemetry.instrumentation.openai.shared import ( + _get_openai_base_url, + metric_shared_attributes, + model_as_dict, +) +from opentelemetry.instrumentation.openai.utils import ( + _with_image_gen_metric_wrapper, +) +from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY +from opentelemetry.metrics import Counter, Histogram +from agentops.semconv import SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + + +@_with_image_gen_metric_wrapper +def image_gen_metrics_wrapper( + duration_histogram: Histogram, + exception_counter: Counter, + wrapped, + instance, + args, + kwargs, +): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY) or context_api.get_value( + SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY + ): + return wrapped(*args, **kwargs) + + try: + # record time for duration + start_time = time.time() + response = wrapped(*args, **kwargs) + end_time = time.time() + except Exception as e: # pylint: disable=broad-except + end_time = time.time() + duration = end_time - start_time if "start_time" in locals() else 0 + + attributes = { + "error.type": e.__class__.__name__, + } + + if duration > 0 and duration_histogram: + duration_histogram.record(duration, attributes=attributes) + if exception_counter: + exception_counter.add(1, attributes=attributes) + + raise e + + if is_openai_v1(): + response_dict = model_as_dict(response) + else: + response_dict = response + + # not provide response.model in ImagesResponse response, use model in request kwargs + shared_attributes = metric_shared_attributes( + response_model=kwargs.get("model") or None, + operation="image_gen", + server_address=_get_openai_base_url(instance), + ) + + duration = end_time - start_time + if duration_histogram: + duration_histogram.record(duration, attributes=shared_attributes) + + return response diff --git a/third_party/opentelemetry/instrumentation/openai/utils.py b/third_party/opentelemetry/instrumentation/openai/utils.py new file mode 100644 index 000000000..e0ab375a1 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/openai/utils.py @@ -0,0 +1,159 @@ +import asyncio +from importlib.metadata import version +from contextlib import asynccontextmanager +import logging +import os +import threading +import traceback + +import openai +from opentelemetry.instrumentation.openai.shared.config import Config + +_OPENAI_VERSION = version("openai") + + +def is_openai_v1(): + return _OPENAI_VERSION >= "1.0.0" + + +def is_azure_openai(instance): + return is_openai_v1() and isinstance( + instance._client, (openai.AsyncAzureOpenAI, openai.AzureOpenAI) + ) + + +def is_metrics_enabled() -> bool: + return (os.getenv("TRACELOOP_METRICS_ENABLED") or "true").lower() == "true" + + +def should_record_stream_token_usage(): + return Config.enrich_token_usage + + +def _with_image_gen_metric_wrapper(func): + def _with_metric(duration_histogram, exception_counter): + def wrapper(wrapped, instance, args, kwargs): + return func( + duration_histogram, exception_counter, wrapped, instance, args, kwargs + ) + + return wrapper + + return _with_metric + + +def _with_embeddings_telemetry_wrapper(func): + def _with_embeddings_telemetry( + tracer, + token_counter, + vector_size_counter, + duration_histogram, + exception_counter, + ): + def wrapper(wrapped, instance, args, kwargs): + return func( + tracer, + token_counter, + vector_size_counter, + duration_histogram, + exception_counter, + wrapped, + instance, + args, + kwargs, + ) + + return wrapper + + return _with_embeddings_telemetry + + +def _with_chat_telemetry_wrapper(func): + def _with_chat_telemetry( + tracer, + token_counter, + choice_counter, + duration_histogram, + exception_counter, + streaming_time_to_first_token, + streaming_time_to_generate, + ): + def wrapper(wrapped, instance, args, kwargs): + return func( + tracer, + token_counter, + choice_counter, + duration_histogram, + exception_counter, + streaming_time_to_first_token, + streaming_time_to_generate, + wrapped, + instance, + args, + kwargs, + ) + + return wrapper + + return _with_chat_telemetry + + +def _with_tracer_wrapper(func): + def _with_tracer(tracer): + def wrapper(wrapped, instance, args, kwargs): + return func(tracer, wrapped, instance, args, kwargs) + + return wrapper + + return _with_tracer + + +@asynccontextmanager +async def start_as_current_span_async(tracer, *args, **kwargs): + with tracer.start_as_current_span(*args, **kwargs) as span: + yield span + + +def dont_throw(func): + """ + A decorator that wraps the passed in function and logs exceptions instead of throwing them. + Works for both synchronous and asynchronous functions. + """ + logger = logging.getLogger(func.__module__) + + async def async_wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except Exception as e: + _handle_exception(e, func, logger) + + def sync_wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + _handle_exception(e, func, logger) + + def _handle_exception(e, func, logger): + logger.debug( + "OpenLLMetry failed to trace in %s, error: %s", + func.__name__, + traceback.format_exc(), + ) + if Config.exception_logger: + Config.exception_logger(e) + + return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper + + +def run_async(method): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop and loop.is_running(): + thread = threading.Thread(target=lambda: asyncio.run(method)) + thread.start() + thread.join() + else: + asyncio.run(method) diff --git a/third_party/opentelemetry/instrumentation/openai/v0/__init__.py b/third_party/opentelemetry/instrumentation/openai/v0/__init__.py new file mode 100644 index 000000000..792bb4025 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/openai/v0/__init__.py @@ -0,0 +1,155 @@ +from typing import Collection + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.trace import get_tracer +from opentelemetry.metrics import get_meter +from wrapt import wrap_function_wrapper + +from opentelemetry.instrumentation.openai.shared.chat_wrappers import ( + chat_wrapper, + achat_wrapper, +) +from opentelemetry.instrumentation.openai.shared.completion_wrappers import ( + completion_wrapper, + acompletion_wrapper, +) +from opentelemetry.instrumentation.openai.shared.embeddings_wrappers import ( + embeddings_wrapper, + aembeddings_wrapper, +) +from opentelemetry.instrumentation.openai.utils import is_metrics_enabled +from opentelemetry.instrumentation.openai.version import __version__ +from agentops.semconv import Meters + +_instruments = ("openai >= 0.27.0", "openai < 1.0.0") + + +class OpenAIV0Instrumentor(BaseInstrumentor): + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + tracer_provider = kwargs.get("tracer_provider") + tracer = get_tracer(__name__, __version__, tracer_provider) + + meter_provider = kwargs.get("meter_provider") + meter = get_meter(__name__, __version__, meter_provider) + + if is_metrics_enabled(): + tokens_histogram = meter.create_histogram( + name=Meters.LLM_TOKEN_USAGE, + unit="token", + description="Measures number of input and output tokens used", + ) + + chat_choice_counter = meter.create_counter( + name=Meters.LLM_GENERATION_CHOICES, + unit="choice", + description="Number of choices returned by chat completions call", + ) + + duration_histogram = meter.create_histogram( + name=Meters.LLM_OPERATION_DURATION, + unit="s", + description="GenAI operation duration", + ) + + chat_exception_counter = meter.create_counter( + name=Meters.LLM_COMPLETIONS_EXCEPTIONS, + unit="time", + description="Number of exceptions occurred during chat completions", + ) + + streaming_time_to_first_token = meter.create_histogram( + name=Meters.LLM_STREAMING_TIME_TO_FIRST_TOKEN, + unit="s", + description="Time to first token in streaming chat completions", + ) + streaming_time_to_generate = meter.create_histogram( + name=Meters.LLM_STREAMING_TIME_TO_GENERATE, + unit="s", + description="Time between first token and completion in streaming chat completions", + ) + else: + ( + tokens_histogram, + chat_choice_counter, + duration_histogram, + chat_exception_counter, + streaming_time_to_first_token, + streaming_time_to_generate, + ) = (None, None, None, None, None, None) + + if is_metrics_enabled(): + embeddings_vector_size_counter = meter.create_counter( + name=Meters.LLM_EMBEDDINGS_VECTOR_SIZE, + unit="element", + description="he size of returned vector", + ) + embeddings_exception_counter = meter.create_counter( + name=Meters.LLM_EMBEDDINGS_EXCEPTIONS, + unit="time", + description="Number of exceptions occurred during embeddings operation", + ) + else: + ( + tokens_histogram, + embeddings_vector_size_counter, + embeddings_exception_counter, + ) = (None, None, None) + + wrap_function_wrapper("openai", "Completion.create", completion_wrapper(tracer)) + wrap_function_wrapper( + "openai", "Completion.acreate", acompletion_wrapper(tracer) + ) + wrap_function_wrapper( + "openai", + "ChatCompletion.create", + chat_wrapper( + tracer, + tokens_histogram, + chat_choice_counter, + duration_histogram, + chat_exception_counter, + streaming_time_to_first_token, + streaming_time_to_generate, + ), + ) + wrap_function_wrapper( + "openai", + "ChatCompletion.acreate", + achat_wrapper( + tracer, + tokens_histogram, + chat_choice_counter, + duration_histogram, + chat_exception_counter, + streaming_time_to_first_token, + streaming_time_to_generate, + ), + ) + wrap_function_wrapper( + "openai", + "Embedding.create", + embeddings_wrapper( + tracer, + tokens_histogram, + embeddings_vector_size_counter, + duration_histogram, + embeddings_exception_counter, + ), + ) + wrap_function_wrapper( + "openai", + "Embedding.acreate", + aembeddings_wrapper( + tracer, + tokens_histogram, + embeddings_vector_size_counter, + duration_histogram, + embeddings_exception_counter, + ), + ) + + def _uninstrument(self, **kwargs): + pass diff --git a/third_party/opentelemetry/instrumentation/openai/v1/__init__.py b/third_party/opentelemetry/instrumentation/openai/v1/__init__.py new file mode 100644 index 000000000..cf38553d5 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/openai/v1/__init__.py @@ -0,0 +1,250 @@ +from typing import Collection + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.trace import get_tracer + +from opentelemetry.metrics import get_meter + +from wrapt import wrap_function_wrapper + +from opentelemetry.instrumentation.openai.shared.chat_wrappers import ( + chat_wrapper, + achat_wrapper, +) +from opentelemetry.instrumentation.openai.shared.completion_wrappers import ( + completion_wrapper, + acompletion_wrapper, +) +from opentelemetry.instrumentation.openai.shared.embeddings_wrappers import ( + embeddings_wrapper, + aembeddings_wrapper, +) +from opentelemetry.instrumentation.openai.shared.image_gen_wrappers import ( + image_gen_metrics_wrapper, +) +from opentelemetry.instrumentation.openai.v1.assistant_wrappers import ( + assistants_create_wrapper, + runs_create_wrapper, + runs_retrieve_wrapper, + runs_create_and_stream_wrapper, + messages_list_wrapper, +) + +from opentelemetry.instrumentation.openai.utils import is_metrics_enabled +from opentelemetry.instrumentation.openai.version import __version__ + +from agentops.semconv import Meters + +_instruments = ("openai >= 1.0.0",) + + +class OpenAIV1Instrumentor(BaseInstrumentor): + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + tracer_provider = kwargs.get("tracer_provider") + tracer = get_tracer(__name__, __version__, tracer_provider) + + # meter and counters are inited here + meter_provider = kwargs.get("meter_provider") + meter = get_meter(__name__, __version__, meter_provider) + + if is_metrics_enabled(): + tokens_histogram = meter.create_histogram( + name=Meters.LLM_TOKEN_USAGE, + unit="token", + description="Measures number of input and output tokens used", + ) + + chat_choice_counter = meter.create_counter( + name=Meters.LLM_GENERATION_CHOICES, + unit="choice", + description="Number of choices returned by chat completions call", + ) + + duration_histogram = meter.create_histogram( + name=Meters.LLM_OPERATION_DURATION, + unit="s", + description="GenAI operation duration", + ) + + chat_exception_counter = meter.create_counter( + name=Meters.LLM_COMPLETIONS_EXCEPTIONS, + unit="time", + description="Number of exceptions occurred during chat completions", + ) + + streaming_time_to_first_token = meter.create_histogram( + name=Meters.LLM_STREAMING_TIME_TO_FIRST_TOKEN, + unit="s", + description="Time to first token in streaming chat completions", + ) + streaming_time_to_generate = meter.create_histogram( + name=Meters.LLM_STREAMING_TIME_TO_GENERATE, + unit="s", + description="Time between first token and completion in streaming chat completions", + ) + else: + ( + tokens_histogram, + chat_choice_counter, + duration_histogram, + chat_exception_counter, + streaming_time_to_first_token, + streaming_time_to_generate, + ) = (None, None, None, None, None, None) + + wrap_function_wrapper( + "openai.resources.chat.completions", + "Completions.create", + chat_wrapper( + tracer, + tokens_histogram, + chat_choice_counter, + duration_histogram, + chat_exception_counter, + streaming_time_to_first_token, + streaming_time_to_generate, + ), + ) + + wrap_function_wrapper( + "openai.resources.completions", + "Completions.create", + completion_wrapper(tracer), + ) + + if is_metrics_enabled(): + embeddings_vector_size_counter = meter.create_counter( + name=Meters.LLM_EMBEDDINGS_VECTOR_SIZE, + unit="element", + description="he size of returned vector", + ) + embeddings_exception_counter = meter.create_counter( + name=Meters.LLM_EMBEDDINGS_EXCEPTIONS, + unit="time", + description="Number of exceptions occurred during embeddings operation", + ) + else: + ( + tokens_histogram, + embeddings_vector_size_counter, + embeddings_exception_counter, + ) = (None, None, None) + + wrap_function_wrapper( + "openai.resources.embeddings", + "Embeddings.create", + embeddings_wrapper( + tracer, + tokens_histogram, + embeddings_vector_size_counter, + duration_histogram, + embeddings_exception_counter, + ), + ) + + wrap_function_wrapper( + "openai.resources.chat.completions", + "AsyncCompletions.create", + achat_wrapper( + tracer, + tokens_histogram, + chat_choice_counter, + duration_histogram, + chat_exception_counter, + streaming_time_to_first_token, + streaming_time_to_generate, + ), + ) + wrap_function_wrapper( + "openai.resources.completions", + "AsyncCompletions.create", + acompletion_wrapper(tracer), + ) + wrap_function_wrapper( + "openai.resources.embeddings", + "AsyncEmbeddings.create", + aembeddings_wrapper( + tracer, + tokens_histogram, + embeddings_vector_size_counter, + duration_histogram, + embeddings_exception_counter, + ), + ) + + if is_metrics_enabled(): + image_gen_exception_counter = meter.create_counter( + name=Meters.LLM_IMAGE_GENERATIONS_EXCEPTIONS, + unit="time", + description="Number of exceptions occurred during image generations operation", + ) + else: + image_gen_exception_counter = None + + wrap_function_wrapper( + "openai.resources.images", + "Images.generate", + image_gen_metrics_wrapper(duration_histogram, image_gen_exception_counter), + ) + + # Beta APIs may not be available consistently in all versions + try: + wrap_function_wrapper( + "openai.resources.beta.assistants", + "Assistants.create", + assistants_create_wrapper(tracer), + ) + wrap_function_wrapper( + "openai.resources.beta.chat.completions", + "Completions.parse", + chat_wrapper( + tracer, + tokens_histogram, + chat_choice_counter, + duration_histogram, + chat_exception_counter, + streaming_time_to_first_token, + streaming_time_to_generate, + ), + ) + wrap_function_wrapper( + "openai.resources.beta.chat.completions", + "AsyncCompletions.parse", + achat_wrapper( + tracer, + tokens_histogram, + chat_choice_counter, + duration_histogram, + chat_exception_counter, + streaming_time_to_first_token, + streaming_time_to_generate, + ), + ) + wrap_function_wrapper( + "openai.resources.beta.threads.runs", + "Runs.create", + runs_create_wrapper(tracer), + ) + wrap_function_wrapper( + "openai.resources.beta.threads.runs", + "Runs.retrieve", + runs_retrieve_wrapper(tracer), + ) + wrap_function_wrapper( + "openai.resources.beta.threads.runs", + "Runs.create_and_stream", + runs_create_and_stream_wrapper(tracer), + ) + wrap_function_wrapper( + "openai.resources.beta.threads.messages", + "Messages.list", + messages_list_wrapper(tracer), + ) + except (AttributeError, ModuleNotFoundError): + pass + + def _uninstrument(self, **kwargs): + pass diff --git a/third_party/opentelemetry/instrumentation/openai/v1/assistant_wrappers.py b/third_party/opentelemetry/instrumentation/openai/v1/assistant_wrappers.py new file mode 100644 index 000000000..8427b01a3 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/openai/v1/assistant_wrappers.py @@ -0,0 +1,231 @@ +import logging +import time +from opentelemetry import context as context_api +from opentelemetry.instrumentation.openai.shared import ( + _set_span_attribute, + model_as_dict, +) +from opentelemetry.trace import SpanKind +from opentelemetry.instrumentation.utils import _SUPPRESS_INSTRUMENTATION_KEY + +from agentops.semconv import SpanAttributes, LLMRequestTypeValues + +from opentelemetry.instrumentation.openai.utils import _with_tracer_wrapper, dont_throw +from opentelemetry.instrumentation.openai.shared.config import Config + +from openai._legacy_response import LegacyAPIResponse +from openai.types.beta.threads.run import Run + +logger = logging.getLogger(__name__) + +assistants = {} +runs = {} + + +@_with_tracer_wrapper +def assistants_create_wrapper(tracer, wrapped, instance, args, kwargs): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + return wrapped(*args, **kwargs) + + response = wrapped(*args, **kwargs) + + assistants[response.id] = { + "model": kwargs.get("model"), + "instructions": kwargs.get("instructions"), + } + + return response + + +@_with_tracer_wrapper +def runs_create_wrapper(tracer, wrapped, instance, args, kwargs): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + return wrapped(*args, **kwargs) + + thread_id = kwargs.get("thread_id") + instructions = kwargs.get("instructions") + + response = wrapped(*args, **kwargs) + response_dict = model_as_dict(response) + + runs[thread_id] = { + "start_time": time.time_ns(), + "assistant_id": kwargs.get("assistant_id"), + "instructions": instructions, + "run_id": response_dict.get("id"), + } + + return response + + +@_with_tracer_wrapper +def runs_retrieve_wrapper(tracer, wrapped, instance, args, kwargs): + @dont_throw + def process_response(response): + if type(response) is LegacyAPIResponse: + parsed_response = response.parse() + else: + parsed_response = response + assert type(parsed_response) is Run + + if parsed_response.thread_id in runs: + thread_id = parsed_response.thread_id + runs[thread_id]["end_time"] = time.time_ns() + if parsed_response.usage: + runs[thread_id]["usage"] = parsed_response.usage + + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + return wrapped(*args, **kwargs) + + response = wrapped(*args, **kwargs) + process_response(response) + + return response + + +@_with_tracer_wrapper +def messages_list_wrapper(tracer, wrapped, instance, args, kwargs): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + return wrapped(*args, **kwargs) + + id = kwargs.get("thread_id") + + response = wrapped(*args, **kwargs) + + response_dict = model_as_dict(response) + if id not in runs: + return response + + run = runs[id] + messages = sorted(response_dict["data"], key=lambda x: x["created_at"]) + + span = tracer.start_span( + "openai.assistant.run", + kind=SpanKind.CLIENT, + attributes={SpanAttributes.LLM_REQUEST_TYPE: LLMRequestTypeValues.CHAT.value}, + start_time=run.get("start_time"), + ) + + i = 0 + if assistants.get(run["assistant_id"]) is not None or Config.enrich_assistant: + if Config.enrich_assistant: + assistant = model_as_dict( + instance._client.beta.assistants.retrieve(run["assistant_id"]) + ) + assistants[run["assistant_id"]] = assistant + else: + assistant = assistants[run["assistant_id"]] + + _set_span_attribute( + span, + SpanAttributes.LLM_SYSTEM, + "openai", + ) + _set_span_attribute( + span, + SpanAttributes.LLM_REQUEST_MODEL, + assistant["model"], + ) + _set_span_attribute( + span, + SpanAttributes.LLM_RESPONSE_MODEL, + assistant["model"], + ) + _set_span_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.{i}.role", "system") + _set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.{i}.content", + assistant["instructions"], + ) + i += 1 + _set_span_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.{i}.role", "system") + _set_span_attribute( + span, f"{SpanAttributes.LLM_PROMPTS}.{i}.content", run["instructions"] + ) + + for i, msg in enumerate(messages): + prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{i}" + content = msg.get("content") + + _set_span_attribute(span, f"{prefix}.role", msg.get("role")) + _set_span_attribute( + span, f"{prefix}.content", content[0].get("text").get("value") + ) + _set_span_attribute(span, f"gen_ai.response.{i}.id", msg.get("id")) + + if run.get("usage"): + usage_dict = model_as_dict(run.get("usage")) + _set_span_attribute( + span, + SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, + usage_dict.get("completion_tokens"), + ) + _set_span_attribute( + span, + SpanAttributes.LLM_USAGE_PROMPT_TOKENS, + usage_dict.get("prompt_tokens"), + ) + + span.end(run.get("end_time")) + + return response + + +@_with_tracer_wrapper +def runs_create_and_stream_wrapper(tracer, wrapped, instance, args, kwargs): + if context_api.get_value(_SUPPRESS_INSTRUMENTATION_KEY): + return wrapped(*args, **kwargs) + + assistant_id = kwargs.get("assistant_id") + instructions = kwargs.get("instructions") + + span = tracer.start_span( + "openai.assistant.run_stream", + kind=SpanKind.CLIENT, + attributes={SpanAttributes.LLM_REQUEST_TYPE: LLMRequestTypeValues.CHAT.value}, + ) + + i = 0 + if assistants.get(assistant_id) is not None or Config.enrich_assistant: + if Config.enrich_assistant: + assistant = model_as_dict( + instance._client.beta.assistants.retrieve(assistant_id) + ) + assistants[assistant_id] = assistant + else: + assistant = assistants[assistant_id] + + _set_span_attribute( + span, SpanAttributes.LLM_REQUEST_MODEL, assistants[assistant_id]["model"] + ) + _set_span_attribute( + span, + SpanAttributes.LLM_SYSTEM, + "openai", + ) + _set_span_attribute( + span, + SpanAttributes.LLM_RESPONSE_MODEL, + assistants[assistant_id]["model"], + ) + _set_span_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.{i}.role", "system") + _set_span_attribute( + span, + f"{SpanAttributes.LLM_PROMPTS}.{i}.content", + assistants[assistant_id]["instructions"], + ) + i += 1 + _set_span_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.{i}.role", "system") + _set_span_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.{i}.content", instructions) + + from opentelemetry.instrumentation.openai.v1.event_handler_wrapper import ( + EventHandleWrapper, + ) + + kwargs["event_handler"] = EventHandleWrapper( + original_handler=kwargs["event_handler"], span=span + ) + + response = wrapped(*args, **kwargs) + + return response diff --git a/third_party/opentelemetry/instrumentation/openai/v1/event_handler_wrapper.py b/third_party/opentelemetry/instrumentation/openai/v1/event_handler_wrapper.py new file mode 100644 index 000000000..1aca71a3d --- /dev/null +++ b/third_party/opentelemetry/instrumentation/openai/v1/event_handler_wrapper.py @@ -0,0 +1,116 @@ +from opentelemetry.instrumentation.openai.shared import ( + _set_span_attribute, +) +from agentops.semconv import SpanAttributes +from openai import AssistantEventHandler +from typing_extensions import override + + +class EventHandleWrapper(AssistantEventHandler): + + _current_text_index = 0 + _prompt_tokens = 0 + _completion_tokens = 0 + + def __init__(self, original_handler, span): + super().__init__() + self._original_handler = original_handler + self._span = span + + @override + def on_end(self): + _set_span_attribute( + self._span, + SpanAttributes.LLM_USAGE_PROMPT_TOKENS, + self._prompt_tokens, + ) + _set_span_attribute( + self._span, + SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, + self._completion_tokens, + ) + self._original_handler.on_end() + self._span.end() + + @override + def on_event(self, event): + self._original_handler.on_event(event) + + @override + def on_run_step_created(self, run_step): + self._original_handler.on_run_step_created(run_step) + + @override + def on_run_step_delta(self, delta, snapshot): + self._original_handler.on_run_step_delta(delta, snapshot) + + @override + def on_run_step_done(self, run_step): + if run_step.usage: + self._prompt_tokens += run_step.usage.prompt_tokens + self._completion_tokens += run_step.usage.completion_tokens + self._original_handler.on_run_step_done(run_step) + + @override + def on_tool_call_created(self, tool_call): + self._original_handler.on_tool_call_created(tool_call) + + @override + def on_tool_call_delta(self, delta, snapshot): + self._original_handler.on_tool_call_delta(delta, snapshot) + + @override + def on_tool_call_done(self, tool_call): + self._original_handler.on_tool_call_done(tool_call) + + @override + def on_exception(self, exception: Exception): + self._original_handler.on_exception(exception) + + @override + def on_timeout(self): + self._original_handler.on_timeout() + + @override + def on_message_created(self, message): + self._original_handler.on_message_created(message) + + @override + def on_message_delta(self, delta, snapshot): + self._original_handler.on_message_delta(delta, snapshot) + + @override + def on_message_done(self, message): + _set_span_attribute( + self._span, + f"gen_ai.response.{self._current_text_index}.id", + message.id, + ) + self._original_handler.on_message_done(message) + self._current_text_index += 1 + + @override + def on_text_created(self, text): + self._original_handler.on_text_created(text) + + @override + def on_text_delta(self, delta, snapshot): + self._original_handler.on_text_delta(delta, snapshot) + + @override + def on_text_done(self, text): + self._original_handler.on_text_done(text) + _set_span_attribute( + self._span, + f"{SpanAttributes.LLM_COMPLETIONS}.{self._current_text_index}.role", + "assistant", + ) + _set_span_attribute( + self._span, + f"{SpanAttributes.LLM_COMPLETIONS}.{self._current_text_index}.content", + text.value, + ) + + @override + def on_image_file_done(self, image_file): + self._original_handler.on_image_file_done(image_file) diff --git a/third_party/opentelemetry/instrumentation/openai/version.py b/third_party/opentelemetry/instrumentation/openai/version.py new file mode 100644 index 000000000..b997ca922 --- /dev/null +++ b/third_party/opentelemetry/instrumentation/openai/version.py @@ -0,0 +1 @@ +__version__ = "0.38.5" diff --git a/uv.lock b/uv.lock index 59c59052d..8ed72b7f0 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,13 @@ version = 1 +revision = 1 requires-python = ">=3.9, <3.14" resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", ] @@ -23,26 +26,35 @@ constraints = [ [[package]] name = "agentops" -version = "0.3.23" +version = "0.3.26" source = { editable = "." } dependencies = [ { name = "opentelemetry-api", version = "1.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "opentelemetry-api", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", version = "1.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "opentelemetry-exporter-otlp-proto-http", version = "1.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "opentelemetry-exporter-otlp-proto-http", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-instrumentation", version = "0.48b0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "opentelemetry-instrumentation", version = "0.50b0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "opentelemetry-sdk", version = "1.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "opentelemetry-sdk", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-semantic-conventions", version = "0.43b0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "opentelemetry-semantic-conventions", version = "0.50b0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-semantic-conventions-ai" }, + { name = "ordered-set" }, + { name = "packaging" }, { name = "psutil" }, { name = "pyyaml" }, { name = "requests" }, { name = "termcolor" }, + { name = "wrapt" }, ] [package.dev-dependencies] -ci = [ - { name = "tach" }, -] dev = [ + { name = "ipython", version = "8.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "ipython", version = "8.32.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "mypy" }, { name = "pdbpp" }, { name = "pyfakefs" }, @@ -65,6 +77,7 @@ test = [ { name = "autogen" }, { name = "cohere" }, { name = "fastapi", extra = ["standard"] }, + { name = "google-generativeai" }, { name = "groq" }, { name = "litellm" }, { name = "mistralai" }, @@ -77,19 +90,27 @@ test = [ requires-dist = [ { name = "opentelemetry-api", marker = "python_full_version < '3.10'", specifier = "==1.22.0" }, { name = "opentelemetry-api", marker = "python_full_version >= '3.10'", specifier = ">=1.27.0" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "python_full_version < '3.10'", specifier = "==1.22.0" }, + { name = "opentelemetry-exporter-otlp-proto-grpc", marker = "python_full_version >= '3.10'", specifier = ">=1.27.0" }, { name = "opentelemetry-exporter-otlp-proto-http", marker = "python_full_version < '3.10'", specifier = "==1.22.0" }, { name = "opentelemetry-exporter-otlp-proto-http", marker = "python_full_version >= '3.10'", specifier = ">=1.27.0" }, + { name = "opentelemetry-instrumentation", specifier = ">=0.48b0" }, { name = "opentelemetry-sdk", marker = "python_full_version < '3.10'", specifier = "==1.22.0" }, { name = "opentelemetry-sdk", marker = "python_full_version >= '3.10'", specifier = ">=1.27.0" }, + { name = "opentelemetry-semantic-conventions", specifier = ">=0.43b0" }, + { name = "opentelemetry-semantic-conventions-ai", specifier = ">=0.4.2" }, + { name = "ordered-set", specifier = ">=4.0.0,<5.0.0" }, + { name = "packaging", specifier = ">=21.0,<25.0" }, { name = "psutil", specifier = ">=5.9.8,<6.1.0" }, { name = "pyyaml", specifier = ">=5.3,<7.0" }, { name = "requests", specifier = ">=2.0.0,<3.0.0" }, { name = "termcolor", specifier = ">=2.3.0,<2.5.0" }, + { name = "wrapt", specifier = ">=1.0.0,<2.0.0" }, ] [package.metadata.requires-dev] -ci = [{ name = "tach", specifier = "~=0.9" }] dev = [ + { name = "ipython", specifier = ">=8.18.1" }, { name = "mypy" }, { name = "pdbpp", specifier = ">=0.10.3" }, { name = "pyfakefs" }, @@ -103,7 +124,7 @@ dev = [ { name = "requests-mock", specifier = ">=1.11.0" }, { name = "ruff" }, { name = "types-requests" }, - { name = "vcrpy", git = "https://github.com/kevin1024/vcrpy.git?rev=5f1b20c4ca4a18c1fc8cfe049d7df12ca0659c9b#5f1b20c4ca4a18c1fc8cfe049d7df12ca0659c9b" }, + { name = "vcrpy", git = "https://github.com/kevin1024/vcrpy.git?rev=5f1b20c4ca4a18c1fc8cfe049d7df12ca0659c9b" }, ] test = [ { name = "ai21", specifier = ">=3.0.0" }, @@ -111,9 +132,10 @@ test = [ { name = "autogen", specifier = "<0.4.0" }, { name = "cohere" }, { name = "fastapi", extras = ["standard"] }, + { name = "google-generativeai", specifier = ">=0.1.0" }, { name = "groq" }, { name = "litellm" }, - { name = "mistralai" }, + { name = "mistralai", specifier = ">=0.2.0,<1.0.0" }, { name = "ollama" }, { name = "openai", specifier = ">=1.0.0" }, { name = "pytest-cov" }, @@ -305,6 +327,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 }, ] +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -353,6 +384,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148 }, ] +[[package]] +name = "cachetools" +version = "5.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/74/57df1ab0ce6bc5f6fa868e08de20df8ac58f9c44330c7671ad922d2bbeae/cachetools-5.5.1.tar.gz", hash = "sha256:70f238fbba50383ef62e55c6aff6d9673175fe59f7c6782c7a0b9e38f4a9df95", size = 28044 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/4e/de4ff18bcf55857ba18d3a4bd48c8a9fde6bb0980c9d20b263f05387fd88/cachetools-5.5.1-py3-none-any.whl", hash = "sha256:b76651fdc3b24ead3c648bbdeeb940c1b04d365b38b4af66788f9ec4a81d42bb", size = 9530 }, +] + [[package]] name = "certifi" version = "2024.12.14" @@ -553,6 +593,15 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "decorator" +version = "5.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/0c/8d907af351aa16b42caae42f9d6aa37b900c67308052d10fdce809f8d952/decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330", size = 35016 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/50/83c593b07763e1161326b3b8c6686f0f4b0f24d5526546bee538c89837d6/decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186", size = 9073 }, +] + [[package]] name = "deprecated" version = "1.2.15" @@ -621,21 +670,21 @@ wheels = [ ] [[package]] -name = "eval-type-backport" -version = "0.2.2" +name = "exceptiongroup" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/ea/8b0ac4469d4c347c6a385ff09dc3c048c2d021696664e26c7ee6791631b5/eval_type_backport-0.2.2.tar.gz", hash = "sha256:f0576b4cf01ebb5bd358d02314d31846af5e07678387486e2c798af0e7d849c1", size = 9079 } +sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/31/55cd413eaccd39125368be33c46de24a1f639f2e12349b0361b4678f3915/eval_type_backport-0.2.2-py3-none-any.whl", hash = "sha256:cb6ad7c393517f476f96d456d0412ea80f0a8cf96f6892834cd9340149111b0a", size = 5830 }, + { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, ] [[package]] -name = "exceptiongroup" -version = "1.2.2" +name = "executing" +version = "2.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 } +sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693 } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 }, + { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702 }, ] [[package]] @@ -856,27 +905,105 @@ wheels = [ ] [[package]] -name = "gitdb" -version = "4.0.12" +name = "google-ai-generativelanguage" +version = "0.6.15" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "smmap" }, + { name = "google-api-core", extra = ["grpc"] }, + { name = "google-auth" }, + { name = "proto-plus" }, + { name = "protobuf", version = "4.25.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "protobuf", version = "5.29.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684 } +sdist = { url = "https://files.pythonhosted.org/packages/11/d1/48fe5d7a43d278e9f6b5ada810b0a3530bbeac7ed7fcbcd366f932f05316/google_ai_generativelanguage-0.6.15.tar.gz", hash = "sha256:8f6d9dc4c12b065fe2d0289026171acea5183ebf2d0b11cefe12f3821e159ec3", size = 1375443 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794 }, + { url = "https://files.pythonhosted.org/packages/7c/a3/67b8a6ff5001a1d8864922f2d6488dc2a14367ceb651bc3f09a947f2f306/google_ai_generativelanguage-0.6.15-py3-none-any.whl", hash = "sha256:5a03ef86377aa184ffef3662ca28f19eeee158733e45d7947982eb953c6ebb6c", size = 1327356 }, ] [[package]] -name = "gitpython" -version = "3.1.44" +name = "google-api-core" +version = "2.24.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "gitdb" }, + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf", version = "4.25.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "protobuf", version = "5.29.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c0/89/37df0b71473153574a5cdef8f242de422a0f5d26d7a9e231e6f169b4ad14/gitpython-3.1.44.tar.gz", hash = "sha256:c87e30b26253bf5418b01b0660f818967f3c503193838337fe5e573331249269", size = 214196 } +sdist = { url = "https://files.pythonhosted.org/packages/81/56/d70d66ed1b5ab5f6c27bf80ec889585ad8f865ff32acbafd3b2ef0bfb5d0/google_api_core-2.24.0.tar.gz", hash = "sha256:e255640547a597a4da010876d333208ddac417d60add22b6851a0c66a831fcaf", size = 162647 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/9a/4114a9057db2f1462d5c8f8390ab7383925fe1ac012eaa42402ad65c2963/GitPython-3.1.44-py3-none-any.whl", hash = "sha256:9e0e10cda9bed1ee64bc9a6de50e7e38a9c9943241cd7f585f6df3ed28011110", size = 207599 }, + { url = "https://files.pythonhosted.org/packages/a1/76/65b8b94e74bf1b6d1cc38d916089670c4da5029d25762441d8c5c19e51dd/google_api_core-2.24.0-py3-none-any.whl", hash = "sha256:10d82ac0fca69c82a25b3efdeefccf6f28e02ebb97925a8cce8edbfe379929d9", size = 158576 }, +] + +[package.optional-dependencies] +grpc = [ + { name = "grpcio" }, + { name = "grpcio-status", version = "1.62.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "grpcio-status", version = "1.70.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] + +[[package]] +name = "google-api-python-client" +version = "2.159.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "httplib2" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/9f/12b58cca5a93d63fd6a7abed570423bdf2db4349eb9361ac5214d42ed7d6/google_api_python_client-2.159.0.tar.gz", hash = "sha256:55197f430f25c907394b44fa078545ffef89d33fd4dca501b7db9f0d8e224bd6", size = 12302576 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/ab/d0671375afe79e6e8c51736e115a69bb6b4bcdc80cd5c01bf667486cd24c/google_api_python_client-2.159.0-py2.py3-none-any.whl", hash = "sha256:baef0bb631a60a0bd7c0bf12a5499e3a40cd4388484de7ee55c1950bf820a0cf", size = 12814228 }, +] + +[[package]] +name = "google-auth" +version = "2.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/eb/d504ba1daf190af6b204a9d4714d457462b486043744901a6eeea711f913/google_auth-2.38.0.tar.gz", hash = "sha256:8285113607d3b80a3f1543b75962447ba8a09fe85783432a784fdeef6ac094c4", size = 270866 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/47/603554949a37bca5b7f894d51896a9c534b9eab808e2520a748e081669d0/google_auth-2.38.0-py2.py3-none-any.whl", hash = "sha256:e7dae6694313f434a2727bf2906f27ad259bae090d7aa896590d86feec3d9d4a", size = 210770 }, +] + +[[package]] +name = "google-auth-httplib2" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "httplib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/be/217a598a818567b28e859ff087f347475c807a5649296fb5a817c58dacef/google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05", size = 10842 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/8a/fe34d2f3f9470a27b01c9e76226965863f153d5fbe276f83608562e49c04/google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d", size = 9253 }, +] + +[[package]] +name = "google-generativeai" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-ai-generativelanguage" }, + { name = "google-api-core" }, + { name = "google-api-python-client" }, + { name = "google-auth" }, + { name = "protobuf", version = "4.25.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "protobuf", version = "5.29.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pydantic" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/b0/6c6af327a8a6ef3be6fe79be1d6f1e2914d6c363aa6b081b93396f4460a7/google_generativeai-0.8.4-py3-none-any.whl", hash = "sha256:e987b33ea6decde1e69191ddcaec6ef974458864d243de7191db50c21a7c5b82", size = 175409 }, ] [[package]] @@ -909,6 +1036,99 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/e7/662ca14bfe05faf40375969fbb1113bba97fe3ff22d38f44eedeeff2c0b0/groq-0.15.0-py3-none-any.whl", hash = "sha256:c200558b67fee4b4f2bb89cc166337e3419a68c23280065770f8f8b0729c79ef", size = 109563 }, ] +[[package]] +name = "grpcio" +version = "1.70.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/e1/4b21b5017c33f3600dcc32b802bb48fe44a4d36d6c066f52650c7c2690fa/grpcio-1.70.0.tar.gz", hash = "sha256:8d1584a68d5922330025881e63a6c1b54cc8117291d382e4fa69339b6d914c56", size = 12788932 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/e9/f72408bac1f7b05b25e4df569b02d6b200c8e7857193aa9f1df7a3744add/grpcio-1.70.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:95469d1977429f45fe7df441f586521361e235982a0b39e33841549143ae2851", size = 5229736 }, + { url = "https://files.pythonhosted.org/packages/b3/17/e65139ea76dac7bcd8a3f17cbd37e3d1a070c44db3098d0be5e14c5bd6a1/grpcio-1.70.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:ed9718f17fbdb472e33b869c77a16d0b55e166b100ec57b016dc7de9c8d236bf", size = 11432751 }, + { url = "https://files.pythonhosted.org/packages/a0/12/42de6082b4ab14a59d30b2fc7786882fdaa75813a4a4f3d4a8c4acd6ed59/grpcio-1.70.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:374d014f29f9dfdb40510b041792e0e2828a1389281eb590df066e1cc2b404e5", size = 5711439 }, + { url = "https://files.pythonhosted.org/packages/34/f8/b5a19524d273cbd119274a387bb72d6fbb74578e13927a473bc34369f079/grpcio-1.70.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2af68a6f5c8f78d56c145161544ad0febbd7479524a59c16b3e25053f39c87f", size = 6330777 }, + { url = "https://files.pythonhosted.org/packages/1a/67/3d6c0ad786238aac7fa93b79246fc452978fbfe9e5f86f70da8e8a2797d0/grpcio-1.70.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce7df14b2dcd1102a2ec32f621cc9fab6695effef516efbc6b063ad749867295", size = 5944639 }, + { url = "https://files.pythonhosted.org/packages/76/0d/d9f7cbc41c2743cf18236a29b6a582f41bd65572a7144d92b80bc1e68479/grpcio-1.70.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c78b339869f4dbf89881e0b6fbf376313e4f845a42840a7bdf42ee6caed4b11f", size = 6643543 }, + { url = "https://files.pythonhosted.org/packages/fc/24/bdd7e606b3400c14330e33a4698fa3a49e38a28c9e0a831441adbd3380d2/grpcio-1.70.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:58ad9ba575b39edef71f4798fdb5c7b6d02ad36d47949cd381d4392a5c9cbcd3", size = 6199897 }, + { url = "https://files.pythonhosted.org/packages/d1/33/8132eb370087960c82d01b89faeb28f3e58f5619ffe19889f57c58a19c18/grpcio-1.70.0-cp310-cp310-win32.whl", hash = "sha256:2b0d02e4b25a5c1f9b6c7745d4fa06efc9fd6a611af0fb38d3ba956786b95199", size = 3617513 }, + { url = "https://files.pythonhosted.org/packages/99/bc/0fce5cfc0ca969df66f5dca6cf8d2258abb88146bf9ab89d8cf48e970137/grpcio-1.70.0-cp310-cp310-win_amd64.whl", hash = "sha256:0de706c0a5bb9d841e353f6343a9defc9fc35ec61d6eb6111802f3aa9fef29e1", size = 4303342 }, + { url = "https://files.pythonhosted.org/packages/65/c4/1f67d23d6bcadd2fd61fb460e5969c52b3390b4a4e254b5e04a6d1009e5e/grpcio-1.70.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:17325b0be0c068f35770f944124e8839ea3185d6d54862800fc28cc2ffad205a", size = 5229017 }, + { url = "https://files.pythonhosted.org/packages/e4/bd/cc36811c582d663a740fb45edf9f99ddbd99a10b6ba38267dc925e1e193a/grpcio-1.70.0-cp311-cp311-macosx_10_14_universal2.whl", hash = "sha256:dbe41ad140df911e796d4463168e33ef80a24f5d21ef4d1e310553fcd2c4a386", size = 11472027 }, + { url = "https://files.pythonhosted.org/packages/7e/32/8538bb2ace5cd72da7126d1c9804bf80b4fe3be70e53e2d55675c24961a8/grpcio-1.70.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:5ea67c72101d687d44d9c56068328da39c9ccba634cabb336075fae2eab0d04b", size = 5707785 }, + { url = "https://files.pythonhosted.org/packages/ce/5c/a45f85f2a0dfe4a6429dee98717e0e8bd7bd3f604315493c39d9679ca065/grpcio-1.70.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb5277db254ab7586769e490b7b22f4ddab3876c490da0a1a9d7c695ccf0bf77", size = 6331599 }, + { url = "https://files.pythonhosted.org/packages/9f/e5/5316b239380b8b2ad30373eb5bb25d9fd36c0375e94a98a0a60ea357d254/grpcio-1.70.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7831a0fc1beeeb7759f737f5acd9fdcda520e955049512d68fda03d91186eea", size = 5940834 }, + { url = "https://files.pythonhosted.org/packages/05/33/dbf035bc6d167068b4a9f2929dfe0b03fb763f0f861ecb3bb1709a14cb65/grpcio-1.70.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:27cc75e22c5dba1fbaf5a66c778e36ca9b8ce850bf58a9db887754593080d839", size = 6641191 }, + { url = "https://files.pythonhosted.org/packages/4c/c4/684d877517e5bfd6232d79107e5a1151b835e9f99051faef51fed3359ec4/grpcio-1.70.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d63764963412e22f0491d0d32833d71087288f4e24cbcddbae82476bfa1d81fd", size = 6198744 }, + { url = "https://files.pythonhosted.org/packages/e9/43/92fe5eeaf340650a7020cfb037402c7b9209e7a0f3011ea1626402219034/grpcio-1.70.0-cp311-cp311-win32.whl", hash = "sha256:bb491125103c800ec209d84c9b51f1c60ea456038e4734688004f377cfacc113", size = 3617111 }, + { url = "https://files.pythonhosted.org/packages/55/15/b6cf2c9515c028aff9da6984761a3ab484a472b0dc6435fcd07ced42127d/grpcio-1.70.0-cp311-cp311-win_amd64.whl", hash = "sha256:d24035d49e026353eb042bf7b058fb831db3e06d52bee75c5f2f3ab453e71aca", size = 4304604 }, + { url = "https://files.pythonhosted.org/packages/4c/a4/ddbda79dd176211b518f0f3795af78b38727a31ad32bc149d6a7b910a731/grpcio-1.70.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:ef4c14508299b1406c32bdbb9fb7b47612ab979b04cf2b27686ea31882387cff", size = 5198135 }, + { url = "https://files.pythonhosted.org/packages/30/5c/60eb8a063ea4cb8d7670af8fac3f2033230fc4b75f62669d67c66ac4e4b0/grpcio-1.70.0-cp312-cp312-macosx_10_14_universal2.whl", hash = "sha256:aa47688a65643afd8b166928a1da6247d3f46a2784d301e48ca1cc394d2ffb40", size = 11447529 }, + { url = "https://files.pythonhosted.org/packages/fb/b9/1bf8ab66729f13b44e8f42c9de56417d3ee6ab2929591cfee78dce749b57/grpcio-1.70.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:880bfb43b1bb8905701b926274eafce5c70a105bc6b99e25f62e98ad59cb278e", size = 5664484 }, + { url = "https://files.pythonhosted.org/packages/d1/06/2f377d6906289bee066d96e9bdb91e5e96d605d173df9bb9856095cccb57/grpcio-1.70.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e654c4b17d07eab259d392e12b149c3a134ec52b11ecdc6a515b39aceeec898", size = 6303739 }, + { url = "https://files.pythonhosted.org/packages/ae/50/64c94cfc4db8d9ed07da71427a936b5a2bd2b27c66269b42fbda82c7c7a4/grpcio-1.70.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2394e3381071045a706ee2eeb6e08962dd87e8999b90ac15c55f56fa5a8c9597", size = 5910417 }, + { url = "https://files.pythonhosted.org/packages/53/89/8795dfc3db4389c15554eb1765e14cba8b4c88cc80ff828d02f5572965af/grpcio-1.70.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:b3c76701428d2df01964bc6479422f20e62fcbc0a37d82ebd58050b86926ef8c", size = 6626797 }, + { url = "https://files.pythonhosted.org/packages/9c/b2/6a97ac91042a2c59d18244c479ee3894e7fb6f8c3a90619bb5a7757fa30c/grpcio-1.70.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ac073fe1c4cd856ebcf49e9ed6240f4f84d7a4e6ee95baa5d66ea05d3dd0df7f", size = 6190055 }, + { url = "https://files.pythonhosted.org/packages/86/2b/28db55c8c4d156053a8c6f4683e559cd0a6636f55a860f87afba1ac49a51/grpcio-1.70.0-cp312-cp312-win32.whl", hash = "sha256:cd24d2d9d380fbbee7a5ac86afe9787813f285e684b0271599f95a51bce33528", size = 3600214 }, + { url = "https://files.pythonhosted.org/packages/17/c3/a7a225645a965029ed432e5b5e9ed959a574e62100afab553eef58be0e37/grpcio-1.70.0-cp312-cp312-win_amd64.whl", hash = "sha256:0495c86a55a04a874c7627fd33e5beaee771917d92c0e6d9d797628ac40e7655", size = 4292538 }, + { url = "https://files.pythonhosted.org/packages/68/38/66d0f32f88feaf7d83f8559cd87d899c970f91b1b8a8819b58226de0a496/grpcio-1.70.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:aa573896aeb7d7ce10b1fa425ba263e8dddd83d71530d1322fd3a16f31257b4a", size = 5199218 }, + { url = "https://files.pythonhosted.org/packages/c1/96/947df763a0b18efb5cc6c2ae348e56d97ca520dc5300c01617b234410173/grpcio-1.70.0-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:d405b005018fd516c9ac529f4b4122342f60ec1cee181788249372524e6db429", size = 11445983 }, + { url = "https://files.pythonhosted.org/packages/fd/5b/f3d4b063e51b2454bedb828e41f3485800889a3609c49e60f2296cc8b8e5/grpcio-1.70.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:f32090238b720eb585248654db8e3afc87b48d26ac423c8dde8334a232ff53c9", size = 5663954 }, + { url = "https://files.pythonhosted.org/packages/bd/0b/dab54365fcedf63e9f358c1431885478e77d6f190d65668936b12dd38057/grpcio-1.70.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dfa089a734f24ee5f6880c83d043e4f46bf812fcea5181dcb3a572db1e79e01c", size = 6304323 }, + { url = "https://files.pythonhosted.org/packages/76/a8/8f965a7171ddd336ce32946e22954aa1bbc6f23f095e15dadaa70604ba20/grpcio-1.70.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f19375f0300b96c0117aca118d400e76fede6db6e91f3c34b7b035822e06c35f", size = 5910939 }, + { url = "https://files.pythonhosted.org/packages/1b/05/0bbf68be8b17d1ed6f178435a3c0c12e665a1e6054470a64ce3cb7896596/grpcio-1.70.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:7c73c42102e4a5ec76608d9b60227d917cea46dff4d11d372f64cbeb56d259d0", size = 6631405 }, + { url = "https://files.pythonhosted.org/packages/79/6a/5df64b6df405a1ed1482cb6c10044b06ec47fd28e87c2232dbcf435ecb33/grpcio-1.70.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:0a5c78d5198a1f0aa60006cd6eb1c912b4a1520b6a3968e677dbcba215fabb40", size = 6190982 }, + { url = "https://files.pythonhosted.org/packages/42/aa/aeaac87737e6d25d1048c53b8ec408c056d3ed0c922e7c5efad65384250c/grpcio-1.70.0-cp313-cp313-win32.whl", hash = "sha256:fe9dbd916df3b60e865258a8c72ac98f3ac9e2a9542dcb72b7a34d236242a5ce", size = 3598359 }, + { url = "https://files.pythonhosted.org/packages/1f/79/8edd2442d2de1431b4a3de84ef91c37002f12de0f9b577fb07b452989dbc/grpcio-1.70.0-cp313-cp313-win_amd64.whl", hash = "sha256:4119fed8abb7ff6c32e3d2255301e59c316c22d31ab812b3fbcbaf3d0d87cc68", size = 4293938 }, + { url = "https://files.pythonhosted.org/packages/9d/0e/64061c9746a2dd6e07cb0a0f3829f0a431344add77ec36397cc452541ff6/grpcio-1.70.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:4f1937f47c77392ccd555728f564a49128b6a197a05a5cd527b796d36f3387d0", size = 5231123 }, + { url = "https://files.pythonhosted.org/packages/72/9f/c93501d5f361aecee0146ab19300d5acb1c2747b00217c641f06fffbcd62/grpcio-1.70.0-cp39-cp39-macosx_10_14_universal2.whl", hash = "sha256:0cd430b9215a15c10b0e7d78f51e8a39d6cf2ea819fd635a7214fae600b1da27", size = 11467217 }, + { url = "https://files.pythonhosted.org/packages/0a/1a/980d115b701023450a304881bf3f6309f6fb15787f9b78d2728074f3bf86/grpcio-1.70.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:e27585831aa6b57b9250abaf147003e126cd3a6c6ca0c531a01996f31709bed1", size = 5710913 }, + { url = "https://files.pythonhosted.org/packages/a0/84/af420067029808f9790e98143b3dd0f943bebba434a4706755051a520c91/grpcio-1.70.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c1af8e15b0f0fe0eac75195992a63df17579553b0c4af9f8362cc7cc99ccddf4", size = 6330947 }, + { url = "https://files.pythonhosted.org/packages/24/1c/e1f06a7d29a1fa5053dcaf5352a50f8e1f04855fd194a65422a9d685d375/grpcio-1.70.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cbce24409beaee911c574a3d75d12ffb8c3e3dd1b813321b1d7a96bbcac46bf4", size = 5943913 }, + { url = "https://files.pythonhosted.org/packages/41/8f/de13838e4467519a50cd0693e98b0b2bcc81d656013c38a1dd7dcb801526/grpcio-1.70.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ff4a8112a79464919bb21c18e956c54add43ec9a4850e3949da54f61c241a4a6", size = 6643236 }, + { url = "https://files.pythonhosted.org/packages/ac/73/d68c745d34e43a80440da4f3d79fa02c56cb118c2a26ba949f3cfd8316d7/grpcio-1.70.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5413549fdf0b14046c545e19cfc4eb1e37e9e1ebba0ca390a8d4e9963cab44d2", size = 6199038 }, + { url = "https://files.pythonhosted.org/packages/7e/dd/991f100b8c31636b4bb2a941dbbf54dbcc55d69c722cfa038c3d017eaa0c/grpcio-1.70.0-cp39-cp39-win32.whl", hash = "sha256:b745d2c41b27650095e81dea7091668c040457483c9bdb5d0d9de8f8eb25e59f", size = 3617512 }, + { url = "https://files.pythonhosted.org/packages/4d/80/1aa2ba791207a13e314067209b48e1a0893ed8d1f43ef012e194aaa6c2de/grpcio-1.70.0-cp39-cp39-win_amd64.whl", hash = "sha256:a31d7e3b529c94e930a117b2175b2efd179d96eb3c7a21ccb0289a8ab05b645c", size = 4303506 }, +] + +[[package]] +name = "grpcio-status" +version = "1.62.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "googleapis-common-protos", marker = "python_full_version < '3.10'" }, + { name = "grpcio", marker = "python_full_version < '3.10'" }, + { name = "protobuf", version = "4.25.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/d7/013ef01c5a1c2fd0932c27c904934162f69f41ca0f28396d3ffe4d386123/grpcio-status-1.62.3.tar.gz", hash = "sha256:289bdd7b2459794a12cf95dc0cb727bd4a1742c37bd823f760236c937e53a485", size = 13063 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/40/972271de05f9315c0d69f9f7ebbcadd83bc85322f538637d11bb8c67803d/grpcio_status-1.62.3-py3-none-any.whl", hash = "sha256:f9049b762ba8de6b1086789d8315846e094edac2c50beaf462338b301a8fd4b8", size = 14448 }, +] + +[[package]] +name = "grpcio-status" +version = "1.70.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "googleapis-common-protos", marker = "python_full_version >= '3.10'" }, + { name = "grpcio", marker = "python_full_version >= '3.10'" }, + { name = "protobuf", version = "5.29.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/d1/2397797c810020eac424e1aac10fbdc5edb6b9b4ad6617e0ed53ca907653/grpcio_status-1.70.0.tar.gz", hash = "sha256:0e7b42816512433b18b9d764285ff029bde059e9d41f8fe10a60631bd8348101", size = 13681 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/34/49e558040e069feebac70cdd1b605f38738c0277ac5d38e2ce3d03e1b1ec/grpcio_status-1.70.0-py3-none-any.whl", hash = "sha256:fc5a2ae2b9b1c1969cc49f3262676e6854aa2398ec69cb5bd6c47cd501904a85", size = 14429 }, +] + [[package]] name = "h11" version = "0.14.0" @@ -931,6 +1151,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, ] +[[package]] +name = "httplib2" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/ad/2371116b22d616c194aa25ec410c9c6c37f23599dcd590502b74db197584/httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81", size = 351116 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/6c/d2fbdaaa5959339d53ba38e94c123e4e84b8fbc4b84beb0e70d7c1608486/httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc", size = 96854 }, +] + [[package]] name = "httptools" version = "0.6.4" @@ -1049,8 +1281,10 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", ] dependencies = [ { name = "zipp", marker = "python_full_version >= '3.10'" }, @@ -1069,6 +1303,74 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, ] +[[package]] +name = "ipython" +version = "8.18.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.10'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.10'" }, + { name = "jedi", marker = "python_full_version < '3.10'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.10'" }, + { name = "pexpect", marker = "python_full_version < '3.10' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.10'" }, + { name = "pygments", marker = "python_full_version < '3.10'" }, + { name = "stack-data", marker = "python_full_version < '3.10'" }, + { name = "traitlets", marker = "python_full_version < '3.10'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/b9/3ba6c45a6df813c09a48bac313c22ff83efa26cbb55011218d925a46e2ad/ipython-8.18.1.tar.gz", hash = "sha256:ca6f079bb33457c66e233e4580ebfc4128855b4cf6370dddd73842a9563e8a27", size = 5486330 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/6b/d9fdcdef2eb6a23f391251fde8781c38d42acd82abe84d054cb74f7863b0/ipython-8.18.1-py3-none-any.whl", hash = "sha256:e8267419d72d81955ec1177f8a29aaa90ac80ad647499201119e2f05e99aa397", size = 808161 }, +] + +[[package]] +name = "ipython" +version = "8.32.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.10'" }, + { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" }, + { name = "jedi", marker = "python_full_version >= '3.10'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.10'" }, + { name = "pexpect", marker = "python_full_version >= '3.10' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.10'" }, + { name = "pygments", marker = "python_full_version >= '3.10'" }, + { name = "stack-data", marker = "python_full_version >= '3.10'" }, + { name = "traitlets", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10' and python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/80/4d2a072e0db7d250f134bc11676517299264ebe16d62a8619d49a78ced73/ipython-8.32.0.tar.gz", hash = "sha256:be2c91895b0b9ea7ba49d33b23e2040c352b33eb6a519cca7ce6e0c743444251", size = 5507441 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e1/f4474a7ecdb7745a820f6f6039dc43c66add40f1bcc66485607d93571af6/ipython-8.32.0-py3-none-any.whl", hash = "sha256:cae85b0c61eff1fc48b0a8002de5958b6528fa9c8defb1894da63f42613708aa", size = 825524 }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, +] + [[package]] name = "jinja2" version = "3.1.5" @@ -1152,15 +1454,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/b7/a3cde72c644fd1caf9da07fb38cf2c130f43484d8f91011940b7c4f42c8f/jiter-0.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:1c0dfbd1be3cbefc7510102370d86e35d1d53e5a93d48519688b1bf0f761160a", size = 207527 }, ] -[[package]] -name = "jsonpath-python" -version = "1.0.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/49/e582e50b0c54c1b47e714241c4a4767bf28758bf90212248aea8e1ce8516/jsonpath-python-1.0.6.tar.gz", hash = "sha256:dd5be4a72d8a2995c3f583cf82bf3cd1a9544cfdabf2d22595b67aff07349666", size = 18121 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/8a/d63959f4eff03893a00e6e63592e3a9f15b9266ed8e0275ab77f8c7dbc94/jsonpath_python-1.0.6-py3-none-any.whl", hash = "sha256:1e3b78df579f5efc23565293612decee04214609208a2335884b3ee3f786b575", size = 7552 }, -] - [[package]] name = "jsonschema" version = "4.23.0" @@ -1291,6 +1584,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/73/085399401383ce949f727afec55ec3abd76648d04b9f22e1c0e99cb4bec3/MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a", size = 15506 }, ] +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -1302,19 +1607,16 @@ wheels = [ [[package]] name = "mistralai" -version = "1.3.1" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "eval-type-backport" }, { name = "httpx" }, - { name = "jsonpath-python" }, + { name = "orjson" }, { name = "pydantic" }, - { name = "python-dateutil" }, - { name = "typing-inspect" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2f/50/59669ee8d21fd27a4f887148b1efb19d9be5ed22ec19c8e6eb842407ac0f/mistralai-1.3.1.tar.gz", hash = "sha256:1c30385656393f993625943045ad20de2aff4c6ab30fc6e8c727d735c22b1c08", size = 133338 } +sdist = { url = "https://files.pythonhosted.org/packages/fa/20/4204f461588310b3a7ffbbbb7fa573493dc1c8185d376ee72516c04575bf/mistralai-0.4.2.tar.gz", hash = "sha256:5eb656710517168ae053f9847b0bb7f617eda07f1f93f946ad6c91a4d407fd93", size = 14234 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/b4/a76b6942b78383d5499f776d880a166296542383f6f952feeef96d0ea692/mistralai-1.3.1-py3-none-any.whl", hash = "sha256:35e74feadf835b7d2145095114b9cf3ba86c4cf1044f28f49b02cd6ddd0a5733", size = 261271 }, + { url = "https://files.pythonhosted.org/packages/4f/fe/79dad76b8d94b62d9e2aab8446183190e1dc384c617d06c3c93307850e11/mistralai-0.4.2-py3-none-any.whl", hash = "sha256:63c98eea139585f0a3b2c4c6c09c453738bac3958055e6f2362d3866e96b0168", size = 20334 }, ] [[package]] @@ -1477,8 +1779,10 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", ] sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368 } wheels = [ @@ -1584,8 +1888,10 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", ] dependencies = [ { name = "deprecated", marker = "python_full_version >= '3.10'" }, @@ -1620,8 +1926,10 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", ] dependencies = [ { name = "opentelemetry-proto", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -1631,6 +1939,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/75/7609bda3d72bf307839570b226180513e854c01443ebe265ed732a4980fc/opentelemetry_exporter_otlp_proto_common-1.29.0-py3-none-any.whl", hash = "sha256:a9d7376c06b4da9cf350677bcddb9618ed4b8255c3f6476975f5e38274ecd3aa", size = 18459 }, ] +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "backoff", marker = "python_full_version < '3.10'" }, + { name = "deprecated", marker = "python_full_version < '3.10'" }, + { name = "googleapis-common-protos", marker = "python_full_version < '3.10'" }, + { name = "grpcio", marker = "python_full_version < '3.10'" }, + { name = "opentelemetry-api", version = "1.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "opentelemetry-exporter-otlp-proto-common", version = "1.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "opentelemetry-proto", version = "1.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "opentelemetry-sdk", version = "1.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/ba/701ecae5572ed827d3a114fc231c10ff9e3a7c8a5cdf62bdc735919666dd/opentelemetry_exporter_otlp_proto_grpc-1.22.0.tar.gz", hash = "sha256:1e0e5aa4bbabc74942f06f268deffd94851d12a8dc30b02527472ef1729fe5b1", size = 25310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/76/9057dce1afb24204cbe7f1c04629980f7b0f9aa5f5114c39d2e25f24209a/opentelemetry_exporter_otlp_proto_grpc-1.22.0-py3-none-any.whl", hash = "sha256:b5bcadc129272004316a455e9081216d3380c1fc2231a928ea6a70aa90e173fb", size = 18281 }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-grpc" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "deprecated", marker = "python_full_version >= '3.10'" }, + { name = "googleapis-common-protos", marker = "python_full_version >= '3.10'" }, + { name = "grpcio", marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-api", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-exporter-otlp-proto-common", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-proto", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-sdk", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/41/aa/b3f2190613141f35fe15145bf438334fdd1eac8aeeee4f7ecbc887999443/opentelemetry_exporter_otlp_proto_grpc-1.29.0.tar.gz", hash = "sha256:3d324d07d64574d72ed178698de3d717f62a059a93b6b7685ee3e303384e73ea", size = 26224 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/de/4b4127a25d1594851d99032f3a9acb09cb512d11edec713410fb906607f4/opentelemetry_exporter_otlp_proto_grpc-1.29.0-py3-none-any.whl", hash = "sha256:5a2a3a741a2543ed162676cf3eefc2b4150e6f4f0a193187afb0d0e65039c69c", size = 18520 }, +] + [[package]] name = "opentelemetry-exporter-otlp-proto-http" version = "1.22.0" @@ -1661,8 +2018,10 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", ] dependencies = [ { name = "deprecated", marker = "python_full_version >= '3.10'" }, @@ -1678,6 +2037,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/49/a1c3d24e8fe73b5f422e21b46c24aed3db7fd9427371c06442e7bdfe4d3b/opentelemetry_exporter_otlp_proto_http-1.29.0-py3-none-any.whl", hash = "sha256:b228bdc0f0cfab82eeea834a7f0ffdd2a258b26aa33d89fb426c29e8e934d9d0", size = 17217 }, ] +[[package]] +name = "opentelemetry-instrumentation" +version = "0.48b0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", + "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "opentelemetry-api", version = "1.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "setuptools", marker = "python_full_version < '3.10'" }, + { name = "wrapt", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0e/d9394839af5d55c8feb3b22cd11138b953b49739b20678ca96289e30f904/opentelemetry_instrumentation-0.48b0.tar.gz", hash = "sha256:94929685d906380743a71c3970f76b5f07476eea1834abd5dd9d17abfe23cc35", size = 24724 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7f/405c41d4f359121376c9d5117dcf68149b8122d3f6c718996d037bd4d800/opentelemetry_instrumentation-0.48b0-py3-none-any.whl", hash = "sha256:a69750dc4ba6a5c3eb67986a337185a25b739966d80479befe37b546fc870b44", size = 29449 }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.50b0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", +] +dependencies = [ + { name = "opentelemetry-api", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "opentelemetry-semantic-conventions", version = "0.50b0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging", marker = "python_full_version >= '3.10'" }, + { name = "wrapt", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/2e/2e59a7cb636dc394bd7cf1758ada5e8ed87590458ca6bb2f9c26e0243847/opentelemetry_instrumentation-0.50b0.tar.gz", hash = "sha256:7d98af72de8dec5323e5202e46122e5f908592b22c6d24733aad619f07d82979", size = 26539 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/b1/55a77152a83ec8998e520a3a575f44af1020cfe4bdc000b7538583293b85/opentelemetry_instrumentation-0.50b0-py3-none-any.whl", hash = "sha256:b8f9fc8812de36e1c6dffa5bfc6224df258841fb387b6dfe5df15099daa10630", size = 30728 }, +] + [[package]] name = "opentelemetry-proto" version = "1.22.0" @@ -1701,8 +2101,10 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", ] dependencies = [ { name = "protobuf", version = "5.29.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -1737,8 +2139,10 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", ] dependencies = [ { name = "opentelemetry-api", version = "1.29.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, @@ -1770,8 +2174,10 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", ] dependencies = [ { name = "deprecated", marker = "python_full_version >= '3.10'" }, @@ -1782,6 +2188,97 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/fb/dc15fad105450a015e913cfa4f5c27b6a5f1bea8fb649f8cae11e699c8af/opentelemetry_semantic_conventions-0.50b0-py3-none-any.whl", hash = "sha256:e87efba8fdb67fb38113efea6a349531e75ed7ffc01562f65b802fcecb5e115e", size = 166602 }, ] +[[package]] +name = "opentelemetry-semantic-conventions-ai" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/5f/76a9f82b08cdc05482a162d2bf67b5c0bbcc4118d4654f4b366f10fd71af/opentelemetry_semantic_conventions_ai-0.4.2.tar.gz", hash = "sha256:90b969c7d838e03e30a9150ffe46543d8e58e9d7370c7221fd30d4ce4d7a1b96", size = 4570 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/bb/6b578a23c46ec87f364c809343cd8e80fcbcc7fc22129ee3dd1461aada81/opentelemetry_semantic_conventions_ai-0.4.2-py3-none-any.whl", hash = "sha256:0a5432aacd441eb7dbdf62e0de3f3d90ed4f69595b687a6dd2ccc4c5b94c5861", size = 5262 }, +] + +[[package]] +name = "ordered-set" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634 }, +] + +[[package]] +name = "orjson" +version = "3.10.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/5dea21763eeff8c1590076918a446ea3d6140743e0e36f58f369928ed0f4/orjson-3.10.15.tar.gz", hash = "sha256:05ca7fe452a2e9d8d9d706a2984c95b9c2ebc5db417ce0b7a49b91d50642a23e", size = 5282482 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/09/e5ff18ad009e6f97eb7edc5f67ef98b3ce0c189da9c3eaca1f9587cd4c61/orjson-3.10.15-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:552c883d03ad185f720d0c09583ebde257e41b9521b74ff40e08b7dec4559c04", size = 249532 }, + { url = "https://files.pythonhosted.org/packages/bd/b8/a75883301fe332bd433d9b0ded7d2bb706ccac679602c3516984f8814fb5/orjson-3.10.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e3e8d438d02e4854f70bfdc03a6bcdb697358dbaa6bcd19cbe24d24ece1f8", size = 125229 }, + { url = "https://files.pythonhosted.org/packages/83/4b/22f053e7a364cc9c685be203b1e40fc5f2b3f164a9b2284547504eec682e/orjson-3.10.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c2c79fa308e6edb0ffab0a31fd75a7841bf2a79a20ef08a3c6e3b26814c8ca8", size = 150148 }, + { url = "https://files.pythonhosted.org/packages/63/64/1b54fc75ca328b57dd810541a4035fe48c12a161d466e3cf5b11a8c25649/orjson-3.10.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cb85490aa6bf98abd20607ab5c8324c0acb48d6da7863a51be48505646c814", size = 139748 }, + { url = "https://files.pythonhosted.org/packages/5e/ff/ff0c5da781807bb0a5acd789d9a7fbcb57f7b0c6e1916595da1f5ce69f3c/orjson-3.10.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:763dadac05e4e9d2bc14938a45a2d0560549561287d41c465d3c58aec818b164", size = 154559 }, + { url = "https://files.pythonhosted.org/packages/4e/9a/11e2974383384ace8495810d4a2ebef5f55aacfc97b333b65e789c9d362d/orjson-3.10.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a330b9b4734f09a623f74a7490db713695e13b67c959713b78369f26b3dee6bf", size = 130349 }, + { url = "https://files.pythonhosted.org/packages/2d/c4/dd9583aea6aefee1b64d3aed13f51d2aadb014028bc929fe52936ec5091f/orjson-3.10.15-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a61a4622b7ff861f019974f73d8165be1bd9a0855e1cad18ee167acacabeb061", size = 138514 }, + { url = "https://files.pythonhosted.org/packages/53/3e/dcf1729230654f5c5594fc752de1f43dcf67e055ac0d300c8cdb1309269a/orjson-3.10.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acd271247691574416b3228db667b84775c497b245fa275c6ab90dc1ffbbd2b3", size = 130940 }, + { url = "https://files.pythonhosted.org/packages/e8/2b/b9759fe704789937705c8a56a03f6c03e50dff7df87d65cba9a20fec5282/orjson-3.10.15-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4759b109c37f635aa5c5cc93a1b26927bfde24b254bcc0e1149a9fada253d2d", size = 414713 }, + { url = "https://files.pythonhosted.org/packages/a7/6b/b9dfdbd4b6e20a59238319eb203ae07c3f6abf07eef909169b7a37ae3bba/orjson-3.10.15-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9e992fd5cfb8b9f00bfad2fd7a05a4299db2bbe92e6440d9dd2fab27655b3182", size = 141028 }, + { url = "https://files.pythonhosted.org/packages/7c/b5/40f5bbea619c7caf75eb4d652a9821875a8ed04acc45fe3d3ef054ca69fb/orjson-3.10.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f95fb363d79366af56c3f26b71df40b9a583b07bbaaf5b317407c4d58497852e", size = 129715 }, + { url = "https://files.pythonhosted.org/packages/38/60/2272514061cbdf4d672edbca6e59c7e01cd1c706e881427d88f3c3e79761/orjson-3.10.15-cp310-cp310-win32.whl", hash = "sha256:f9875f5fea7492da8ec2444839dcc439b0ef298978f311103d0b7dfd775898ab", size = 142473 }, + { url = "https://files.pythonhosted.org/packages/11/5d/be1490ff7eafe7fef890eb4527cf5bcd8cfd6117f3efe42a3249ec847b60/orjson-3.10.15-cp310-cp310-win_amd64.whl", hash = "sha256:17085a6aa91e1cd70ca8533989a18b5433e15d29c574582f76f821737c8d5806", size = 133564 }, + { url = "https://files.pythonhosted.org/packages/7a/a2/21b25ce4a2c71dbb90948ee81bd7a42b4fbfc63162e57faf83157d5540ae/orjson-3.10.15-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c4cc83960ab79a4031f3119cc4b1a1c627a3dc09df125b27c4201dff2af7eaa6", size = 249533 }, + { url = "https://files.pythonhosted.org/packages/b2/85/2076fc12d8225698a51278009726750c9c65c846eda741e77e1761cfef33/orjson-3.10.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ddbeef2481d895ab8be5185f2432c334d6dec1f5d1933a9c83014d188e102cef", size = 125230 }, + { url = "https://files.pythonhosted.org/packages/06/df/a85a7955f11274191eccf559e8481b2be74a7c6d43075d0a9506aa80284d/orjson-3.10.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9e590a0477b23ecd5b0ac865b1b907b01b3c5535f5e8a8f6ab0e503efb896334", size = 150148 }, + { url = "https://files.pythonhosted.org/packages/37/b3/94c55625a29b8767c0eed194cb000b3787e3c23b4cdd13be17bae6ccbb4b/orjson-3.10.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6be38bd103d2fd9bdfa31c2720b23b5d47c6796bcb1d1b598e3924441b4298d", size = 139749 }, + { url = "https://files.pythonhosted.org/packages/53/ba/c608b1e719971e8ddac2379f290404c2e914cf8e976369bae3cad88768b1/orjson-3.10.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ff4f6edb1578960ed628a3b998fa54d78d9bb3e2eb2cfc5c2a09732431c678d0", size = 154558 }, + { url = "https://files.pythonhosted.org/packages/b2/c4/c1fb835bb23ad788a39aa9ebb8821d51b1c03588d9a9e4ca7de5b354fdd5/orjson-3.10.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0482b21d0462eddd67e7fce10b89e0b6ac56570424662b685a0d6fccf581e13", size = 130349 }, + { url = "https://files.pythonhosted.org/packages/78/14/bb2b48b26ab3c570b284eb2157d98c1ef331a8397f6c8bd983b270467f5c/orjson-3.10.15-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bb5cc3527036ae3d98b65e37b7986a918955f85332c1ee07f9d3f82f3a6899b5", size = 138513 }, + { url = "https://files.pythonhosted.org/packages/4a/97/d5b353a5fe532e92c46467aa37e637f81af8468aa894cd77d2ec8a12f99e/orjson-3.10.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d569c1c462912acdd119ccbf719cf7102ea2c67dd03b99edcb1a3048651ac96b", size = 130942 }, + { url = "https://files.pythonhosted.org/packages/b5/5d/a067bec55293cca48fea8b9928cfa84c623be0cce8141d47690e64a6ca12/orjson-3.10.15-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1e6d33efab6b71d67f22bf2962895d3dc6f82a6273a965fab762e64fa90dc399", size = 414717 }, + { url = "https://files.pythonhosted.org/packages/6f/9a/1485b8b05c6b4c4db172c438cf5db5dcfd10e72a9bc23c151a1137e763e0/orjson-3.10.15-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c33be3795e299f565681d69852ac8c1bc5c84863c0b0030b2b3468843be90388", size = 141033 }, + { url = "https://files.pythonhosted.org/packages/f8/d2/fc67523656e43a0c7eaeae9007c8b02e86076b15d591e9be11554d3d3138/orjson-3.10.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:eea80037b9fae5339b214f59308ef0589fc06dc870578b7cce6d71eb2096764c", size = 129720 }, + { url = "https://files.pythonhosted.org/packages/79/42/f58c7bd4e5b54da2ce2ef0331a39ccbbaa7699b7f70206fbf06737c9ed7d/orjson-3.10.15-cp311-cp311-win32.whl", hash = "sha256:d5ac11b659fd798228a7adba3e37c010e0152b78b1982897020a8e019a94882e", size = 142473 }, + { url = "https://files.pythonhosted.org/packages/00/f8/bb60a4644287a544ec81df1699d5b965776bc9848d9029d9f9b3402ac8bb/orjson-3.10.15-cp311-cp311-win_amd64.whl", hash = "sha256:cf45e0214c593660339ef63e875f32ddd5aa3b4adc15e662cdb80dc49e194f8e", size = 133570 }, + { url = "https://files.pythonhosted.org/packages/66/85/22fe737188905a71afcc4bf7cc4c79cd7f5bbe9ed1fe0aac4ce4c33edc30/orjson-3.10.15-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9d11c0714fc85bfcf36ada1179400862da3288fc785c30e8297844c867d7505a", size = 249504 }, + { url = "https://files.pythonhosted.org/packages/48/b7/2622b29f3afebe938a0a9037e184660379797d5fd5234e5998345d7a5b43/orjson-3.10.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dba5a1e85d554e3897fa9fe6fbcff2ed32d55008973ec9a2b992bd9a65d2352d", size = 125080 }, + { url = "https://files.pythonhosted.org/packages/ce/8f/0b72a48f4403d0b88b2a41450c535b3e8989e8a2d7800659a967efc7c115/orjson-3.10.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7723ad949a0ea502df656948ddd8b392780a5beaa4c3b5f97e525191b102fff0", size = 150121 }, + { url = "https://files.pythonhosted.org/packages/06/ec/acb1a20cd49edb2000be5a0404cd43e3c8aad219f376ac8c60b870518c03/orjson-3.10.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6fd9bc64421e9fe9bd88039e7ce8e58d4fead67ca88e3a4014b143cec7684fd4", size = 139796 }, + { url = "https://files.pythonhosted.org/packages/33/e1/f7840a2ea852114b23a52a1c0b2bea0a1ea22236efbcdb876402d799c423/orjson-3.10.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dadba0e7b6594216c214ef7894c4bd5f08d7c0135f4dd0145600be4fbcc16767", size = 154636 }, + { url = "https://files.pythonhosted.org/packages/fa/da/31543337febd043b8fa80a3b67de627669b88c7b128d9ad4cc2ece005b7a/orjson-3.10.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b48f59114fe318f33bbaee8ebeda696d8ccc94c9e90bc27dbe72153094e26f41", size = 130621 }, + { url = "https://files.pythonhosted.org/packages/ed/78/66115dc9afbc22496530d2139f2f4455698be444c7c2475cb48f657cefc9/orjson-3.10.15-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:035fb83585e0f15e076759b6fedaf0abb460d1765b6a36f48018a52858443514", size = 138516 }, + { url = "https://files.pythonhosted.org/packages/22/84/cd4f5fb5427ffcf823140957a47503076184cb1ce15bcc1165125c26c46c/orjson-3.10.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d13b7fe322d75bf84464b075eafd8e7dd9eae05649aa2a5354cfa32f43c59f17", size = 130762 }, + { url = "https://files.pythonhosted.org/packages/93/1f/67596b711ba9f56dd75d73b60089c5c92057f1130bb3a25a0f53fb9a583b/orjson-3.10.15-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:7066b74f9f259849629e0d04db6609db4cf5b973248f455ba5d3bd58a4daaa5b", size = 414700 }, + { url = "https://files.pythonhosted.org/packages/7c/0c/6a3b3271b46443d90efb713c3e4fe83fa8cd71cda0d11a0f69a03f437c6e/orjson-3.10.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88dc3f65a026bd3175eb157fea994fca6ac7c4c8579fc5a86fc2114ad05705b7", size = 141077 }, + { url = "https://files.pythonhosted.org/packages/3b/9b/33c58e0bfc788995eccd0d525ecd6b84b40d7ed182dd0751cd4c1322ac62/orjson-3.10.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b342567e5465bd99faa559507fe45e33fc76b9fb868a63f1642c6bc0735ad02a", size = 129898 }, + { url = "https://files.pythonhosted.org/packages/01/c1/d577ecd2e9fa393366a1ea0a9267f6510d86e6c4bb1cdfb9877104cac44c/orjson-3.10.15-cp312-cp312-win32.whl", hash = "sha256:0a4f27ea5617828e6b58922fdbec67b0aa4bb844e2d363b9244c47fa2180e665", size = 142566 }, + { url = "https://files.pythonhosted.org/packages/ed/eb/a85317ee1732d1034b92d56f89f1de4d7bf7904f5c8fb9dcdd5b1c83917f/orjson-3.10.15-cp312-cp312-win_amd64.whl", hash = "sha256:ef5b87e7aa9545ddadd2309efe6824bd3dd64ac101c15dae0f2f597911d46eaa", size = 133732 }, + { url = "https://files.pythonhosted.org/packages/06/10/fe7d60b8da538e8d3d3721f08c1b7bff0491e8fa4dd3bf11a17e34f4730e/orjson-3.10.15-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:bae0e6ec2b7ba6895198cd981b7cca95d1487d0147c8ed751e5632ad16f031a6", size = 249399 }, + { url = "https://files.pythonhosted.org/packages/6b/83/52c356fd3a61abd829ae7e4366a6fe8e8863c825a60d7ac5156067516edf/orjson-3.10.15-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f93ce145b2db1252dd86af37d4165b6faa83072b46e3995ecc95d4b2301b725a", size = 125044 }, + { url = "https://files.pythonhosted.org/packages/55/b2/d06d5901408e7ded1a74c7c20d70e3a127057a6d21355f50c90c0f337913/orjson-3.10.15-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7c203f6f969210128af3acae0ef9ea6aab9782939f45f6fe02d05958fe761ef9", size = 150066 }, + { url = "https://files.pythonhosted.org/packages/75/8c/60c3106e08dc593a861755781c7c675a566445cc39558677d505878d879f/orjson-3.10.15-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8918719572d662e18b8af66aef699d8c21072e54b6c82a3f8f6404c1f5ccd5e0", size = 139737 }, + { url = "https://files.pythonhosted.org/packages/6a/8c/ae00d7d0ab8a4490b1efeb01ad4ab2f1982e69cc82490bf8093407718ff5/orjson-3.10.15-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f71eae9651465dff70aa80db92586ad5b92df46a9373ee55252109bb6b703307", size = 154804 }, + { url = "https://files.pythonhosted.org/packages/22/86/65dc69bd88b6dd254535310e97bc518aa50a39ef9c5a2a5d518e7a223710/orjson-3.10.15-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e117eb299a35f2634e25ed120c37c641398826c2f5a3d3cc39f5993b96171b9e", size = 130583 }, + { url = "https://files.pythonhosted.org/packages/bb/00/6fe01ededb05d52be42fabb13d93a36e51f1fd9be173bd95707d11a8a860/orjson-3.10.15-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:13242f12d295e83c2955756a574ddd6741c81e5b99f2bef8ed8d53e47a01e4b7", size = 138465 }, + { url = "https://files.pythonhosted.org/packages/db/2f/4cc151c4b471b0cdc8cb29d3eadbce5007eb0475d26fa26ed123dca93b33/orjson-3.10.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7946922ada8f3e0b7b958cc3eb22cfcf6c0df83d1fe5521b4a100103e3fa84c8", size = 130742 }, + { url = "https://files.pythonhosted.org/packages/9f/13/8a6109e4b477c518498ca37963d9c0eb1508b259725553fb53d53b20e2ea/orjson-3.10.15-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b7155eb1623347f0f22c38c9abdd738b287e39b9982e1da227503387b81b34ca", size = 414669 }, + { url = "https://files.pythonhosted.org/packages/22/7b/1d229d6d24644ed4d0a803de1b0e2df832032d5beda7346831c78191b5b2/orjson-3.10.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:208beedfa807c922da4e81061dafa9c8489c6328934ca2a562efa707e049e561", size = 141043 }, + { url = "https://files.pythonhosted.org/packages/cc/d3/6dc91156cf12ed86bed383bcb942d84d23304a1e57b7ab030bf60ea130d6/orjson-3.10.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eca81f83b1b8c07449e1d6ff7074e82e3fd6777e588f1a6632127f286a968825", size = 129826 }, + { url = "https://files.pythonhosted.org/packages/b3/38/c47c25b86f6996f1343be721b6ea4367bc1c8bc0fc3f6bbcd995d18cb19d/orjson-3.10.15-cp313-cp313-win32.whl", hash = "sha256:c03cd6eea1bd3b949d0d007c8d57049aa2b39bd49f58b4b2af571a5d3833d890", size = 142542 }, + { url = "https://files.pythonhosted.org/packages/27/f1/1d7ec15b20f8ce9300bc850de1e059132b88990e46cd0ccac29cbf11e4f9/orjson-3.10.15-cp313-cp313-win_amd64.whl", hash = "sha256:fd56a26a04f6ba5fb2045b0acc487a63162a958ed837648c5781e1fe3316cfbf", size = 133444 }, + { url = "https://files.pythonhosted.org/packages/56/39/b2123d8d98a62ee89626dc7ecb782d9b60a5edb0b5721bc894ee3470df5a/orjson-3.10.15-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:ffe19f3e8d68111e8644d4f4e267a069ca427926855582ff01fc012496d19969", size = 250031 }, + { url = "https://files.pythonhosted.org/packages/65/4d/a058dc6476713cbd5647e5fd0be8d40c27e9ed77d37a788b594c424caa0e/orjson-3.10.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d433bf32a363823863a96561a555227c18a522a8217a6f9400f00ddc70139ae2", size = 125021 }, + { url = "https://files.pythonhosted.org/packages/3d/cb/4d1450bb2c3276f8bf9524df6b01af4d01f55e9a9772555cf119275eb1d0/orjson-3.10.15-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:da03392674f59a95d03fa5fb9fe3a160b0511ad84b7a3914699ea5a1b3a38da2", size = 149957 }, + { url = "https://files.pythonhosted.org/packages/93/7b/d1fae6d4393a9fa8f5d3fb173f0a9c778135569c50e5390811b74c45b4b3/orjson-3.10.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a63bb41559b05360ded9132032239e47983a39b151af1201f07ec9370715c82", size = 139515 }, + { url = "https://files.pythonhosted.org/packages/7f/b2/e0c0b8197c709983093700f9a59aa64478d80edc55fe620bceadb92004e3/orjson-3.10.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3766ac4702f8f795ff3fa067968e806b4344af257011858cc3d6d8721588b53f", size = 154314 }, + { url = "https://files.pythonhosted.org/packages/db/94/eeb94ca3aa7564f753fe352101bcfc8179febaa1888f55ba3cad25b05f71/orjson-3.10.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a1c73dcc8fadbd7c55802d9aa093b36878d34a3b3222c41052ce6b0fc65f8e8", size = 130145 }, + { url = "https://files.pythonhosted.org/packages/ca/10/54c0118a38eaa5ae832c27306834bdc13954bd0a443b80da63faebf17ffe/orjson-3.10.15-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b299383825eafe642cbab34be762ccff9fd3408d72726a6b2a4506d410a71ab3", size = 138344 }, + { url = "https://files.pythonhosted.org/packages/78/87/3c15eeb315171aa27f96bcca87ed54ee292b72d755973a66e3a6800e8ae9/orjson-3.10.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:abc7abecdbf67a173ef1316036ebbf54ce400ef2300b4e26a7b843bd446c2480", size = 130730 }, + { url = "https://files.pythonhosted.org/packages/8a/dc/522430fb24445b9cc8301a5954f80ce8ee244c5159ba913578acc36b078f/orjson-3.10.15-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:3614ea508d522a621384c1d6639016a5a2e4f027f3e4a1c93a51867615d28829", size = 414482 }, + { url = "https://files.pythonhosted.org/packages/c8/01/83b2e80b9c96ca9753d06e01d325037b2f3e404b14c7a8e875b2f2b7c171/orjson-3.10.15-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:295c70f9dc154307777ba30fe29ff15c1bcc9dfc5c48632f37d20a607e9ba85a", size = 140792 }, + { url = "https://files.pythonhosted.org/packages/96/40/f211084b0e0267b6b515f05967048d8957839d80ff534bde0dc7f9df9ae0/orjson-3.10.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:63309e3ff924c62404923c80b9e2048c1f74ba4b615e7584584389ada50ed428", size = 129536 }, + { url = "https://files.pythonhosted.org/packages/b2/8c/014d96f5c6446adcd2403fe2d4007ff582f8867f5028b0cd994f0174d61c/orjson-3.10.15-cp39-cp39-win32.whl", hash = "sha256:a2f708c62d026fb5340788ba94a55c23df4e1869fec74be455e0b2f5363b8507", size = 142302 }, + { url = "https://files.pythonhosted.org/packages/47/bd/81da73ef8e66434c51a4ea7db45e3a0b62bff2c3e7ebc723aa4eeead2feb/orjson-3.10.15-cp39-cp39-win_amd64.whl", hash = "sha256:efcf6c735c3d22ef60c4aa27a5238f1a477df85e9b15f2142f9d669beb2d13fd", size = 133401 }, +] + [[package]] name = "packaging" version = "24.2" @@ -1800,6 +2297,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/2f/804f58f0b856ab3bf21617cccf5b39206e6c4c94c2cd227bde125ea6105f/parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b", size = 20475 }, ] +[[package]] +name = "parso" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, +] + [[package]] name = "pdbpp" version = "0.10.3" @@ -1814,6 +2320,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/93/ee/491e63a57fffa78b9de1c337b06c97d0cd0753e88c00571c7b011680332a/pdbpp-0.10.3-py2.py3-none-any.whl", hash = "sha256:79580568e33eb3d6f6b462b1187f53e10cd8e4538f7d31495c9181e2cf9665d1", size = 23961 }, ] +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, +] + [[package]] name = "pluggy" version = "1.5.0" @@ -1825,14 +2343,14 @@ wheels = [ [[package]] name = "prompt-toolkit" -version = "3.0.48" +version = "3.0.50" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2d/4f/feb5e137aff82f7c7f3248267b97451da3644f6cdc218edfe549fb354127/prompt_toolkit-3.0.48.tar.gz", hash = "sha256:d6623ab0477a80df74e646bdbc93621143f5caf104206aa29294d53de1a03d90", size = 424684 } +sdist = { url = "https://files.pythonhosted.org/packages/a1/e1/bd15cb8ffdcfeeb2bdc215de3c3cffca11408d829e4b8416dcfe71ba8854/prompt_toolkit-3.0.50.tar.gz", hash = "sha256:544748f3860a2623ca5cd6d2795e7a14f3d0e1c3c9728359013f79877fc89bab", size = 429087 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/6a/fd08d94654f7e67c52ca30523a178b3f8ccc4237fce4be90d39c938a831a/prompt_toolkit-3.0.48-py3-none-any.whl", hash = "sha256:f49a827f90062e411f1ce1f854f2aedb3c23353244f8108b89283587397ac10e", size = 386595 }, + { url = "https://files.pythonhosted.org/packages/e4/ea/d836f008d33151c7a1f62caf3d8dd782e4d15f6a43897f64480c2b8de2ad/prompt_toolkit-3.0.50-py3-none-any.whl", hash = "sha256:9b6427eb19e479d98acff65196a307c555eb567989e6d88ebbb1b509d9779198", size = 387816 }, ] [[package]] @@ -1924,6 +2442,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/b6/c5319caea262f4821995dca2107483b94a3345d4607ad797c76cb9c36bcc/propcache-0.2.1-py3-none-any.whl", hash = "sha256:52277518d6aae65536e9cea52d4e7fd2f7a66f4aa2d30ed3f2fcea620ace3c54", size = 11818 }, ] +[[package]] +name = "proto-plus" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf", version = "4.25.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "protobuf", version = "5.29.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/79/a5c6cbb42268cfd3ddc652dc526889044a8798c688a03ff58e5e92b743c8/proto_plus-1.26.0.tar.gz", hash = "sha256:6e93d5f5ca267b54300880fff156b6a3386b3fa3f43b1da62e680fc0c586ef22", size = 56136 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/c3/59308ccc07b34980f9d532f7afc718a9f32b40e52cde7a740df8d55632fb/proto_plus-1.26.0-py3-none-any.whl", hash = "sha256:bf2dfaa3da281fc3187d12d224c707cb57214fb2c22ba854eb0c105a3fb2d4d7", size = 50166 }, +] + [[package]] name = "protobuf" version = "4.25.5" @@ -1951,8 +2482,10 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", ] sdist = { url = "https://files.pythonhosted.org/packages/f7/d1/e0a911544ca9993e0f17ce6d3cc0932752356c1b0a834397f28e63479344/protobuf-5.29.3.tar.gz", hash = "sha256:5da0f41edaf117bde316404bad1a486cb4ededf8e4a54891296f648e8e076620", size = 424945 } wheels = [ @@ -1981,6 +2514,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/06/63872a64c312a24fb9b4af123ee7007a306617da63ff13bcc1432386ead7/psutil-6.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:ffe7fc9b6b36beadc8c322f84e1caff51e8703b88eee1da46d1e3a6ae11b4fd0", size = 251988 }, ] +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/67/6afbf0d507f73c32d21084a79946bfcfca5fbc62a72057e9c23797a737c9/pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c", size = 310028 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/89/bc88a6711935ba795a679ea6ebee07e128050d6382eaa35a0a47c8032bdc/pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd", size = 181537 }, +] + [[package]] name = "pydantic" version = "2.10.5" @@ -2092,18 +2664,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/0c/c5c5cd3689c32ed1fe8c5d234b079c12c281c051759770c05b8bed6412b5/pydantic_core-2.27.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7d0c8399fcc1848491f00e0314bd59fb34a9c008761bcb422a057670c3f65e35", size = 2004961 }, ] -[[package]] -name = "pydot" -version = "3.0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyparsing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/dd/e0e6a4fb84c22050f6a9701ad9fd6a67ef82faa7ba97b97eb6fdc6b49b34/pydot-3.0.4.tar.gz", hash = "sha256:3ce88b2558f3808b0376f22bfa6c263909e1c3981e2a7b629b65b451eee4a25d", size = 168167 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/5f/1ebfd430df05c4f9e438dd3313c4456eab937d976f6ab8ce81a98f9fb381/pydot-3.0.4-py3-none-any.whl", hash = "sha256:bfa9c3fc0c44ba1d132adce131802d7df00429d1a79cc0346b0a5cd374dbe9c6", size = 35776 }, -] - [[package]] name = "pyfakefs" version = "5.7.4" @@ -2240,18 +2800,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/fb/889f1b69da2f13691de09a111c16c4766a433382d44aa0ecf221deded44a/pytest_sugar-1.0.0-py3-none-any.whl", hash = "sha256:70ebcd8fc5795dc457ff8b69d266a4e2e8a74ae0c3edc749381c64b5246c8dfd", size = 10171 }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, -] - [[package]] name = "python-dotenv" version = "1.0.1" @@ -2608,6 +3156,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/ad/03b5ccd1ab492c9dece85b3bf1c96453ab8c47983936fae6880f688f60b3/rpds_py-0.22.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:5246b14ca64a8675e0a7161f7af68fe3e910e6b90542b4bfb5439ba752191df6", size = 233013 }, ] +[[package]] +name = "rsa" +version = "4.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/65/7d973b89c4d2351d7fb232c2e452547ddfa243e93131e7cfa766da627b52/rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21", size = 29711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/97/fa78e3d2f65c02c8e1268b9aba606569fe97f6c8f7c2d74394553347c145/rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", size = 34315 }, +] + [[package]] name = "ruff" version = "0.9.1" @@ -2674,30 +3234,21 @@ wheels = [ ] [[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, -] - -[[package]] -name = "six" -version = "1.17.0" +name = "setuptools" +version = "75.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +sdist = { url = "https://files.pythonhosted.org/packages/92/ec/089608b791d210aec4e7f97488e67ab0d33add3efccb83a056cbafe3a2a6/setuptools-75.8.0.tar.gz", hash = "sha256:c5afc8f407c626b8313a86e10311dd3f661c6cd9c09d4bf8c15c0e11f9f2b0e6", size = 1343222 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, + { url = "https://files.pythonhosted.org/packages/69/8a/b9dc7678803429e4a3bc9ba462fa3dd9066824d3c607490235c6a796be5a/setuptools-75.8.0-py3-none-any.whl", hash = "sha256:e3982f444617239225d675215d51f6ba05f845d4eec313da4418fdbb56fb27e3", size = 1228782 }, ] [[package]] -name = "smmap" -version = "5.0.2" +name = "shellingham" +version = "1.5.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329 } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } wheels = [ - { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303 }, + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, ] [[package]] @@ -2710,55 +3261,30 @@ wheels = [ ] [[package]] -name = "starlette" -version = "0.41.3" +name = "stack-data" +version = "0.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "typing-extensions", marker = "python_full_version < '3.10'" }, + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/4c/9b5764bd22eec91c4039ef4c55334e9187085da2d8a2df7bd570869aae18/starlette-0.41.3.tar.gz", hash = "sha256:0e4ab3d16522a255be6b28260b938eae2482f98ce5cc934cb08dce8dc3ba5835", size = 2574159 } +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/00/2b325970b3060c7cecebab6d295afe763365822b1306a12eeab198f74323/starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7", size = 73225 }, + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, ] [[package]] -name = "stdlib-list" -version = "0.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5d/04/6b37a71e92ddca16b190b7df62494ac4779d58ced4787f73584eb32c8f03/stdlib_list-0.11.0.tar.gz", hash = "sha256:b74a7b643a77a12637e907f3f62f0ab9f67300bce4014f6b2d3c8b4c8fd63c66", size = 60335 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/fe/e07300c027a868d32d8ed7a425503401e91a03ff90e7ca525c115c634ffb/stdlib_list-0.11.0-py3-none-any.whl", hash = "sha256:8bf8decfffaaf273d4cfeb5bd852b910a00dec1037dcf163576803622bccf597", size = 83617 }, -] - -[[package]] -name = "tach" -version = "0.20.0" +name = "starlette" +version = "0.41.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "gitpython" }, - { name = "networkx", version = "3.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, - { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, - { name = "prompt-toolkit" }, - { name = "pydot" }, - { name = "pyyaml" }, - { name = "rich" }, - { name = "stdlib-list", marker = "python_full_version < '3.10'" }, - { name = "tomli" }, - { name = "tomli-w" }, + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/54/c8/4064f6e97abeda0dd5a68a23a9cc46f236850d8247f124847ae3f03f86ff/tach-0.20.0.tar.gz", hash = "sha256:65ec25354c36c1305a7abfae33f138e9b6026266a19507ff4724f3dda9b55c67", size = 738845 } +sdist = { url = "https://files.pythonhosted.org/packages/1a/4c/9b5764bd22eec91c4039ef4c55334e9187085da2d8a2df7bd570869aae18/starlette-0.41.3.tar.gz", hash = "sha256:0e4ab3d16522a255be6b28260b938eae2482f98ce5cc934cb08dce8dc3ba5835", size = 2574159 } wheels = [ - { url = "https://files.pythonhosted.org/packages/53/ce/39fe1253b2141f72d290d64d0b4b47ebed99b15849b0b1c42827054f3590/tach-0.20.0-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:28b2869a3ec2b9a8f558f472d35ad1d237024361bc3137fbc3e1f0e5f42b0bf5", size = 3070560 }, - { url = "https://files.pythonhosted.org/packages/05/ae/259dbb866ba38688e51a1da38d47c1da0878ea236e01486cddd7aed2b7cc/tach-0.20.0-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:c7bc8b325b41e2561cf9bace6a998fd391b45aeb37dd8011cfc311f4e6426f60", size = 2930725 }, - { url = "https://files.pythonhosted.org/packages/61/1b/c438601f76d3576200f4335c0d524377aebd20b18e09f07ef19e25fc338f/tach-0.20.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49804f15b5a03b7b39d476f1b46330442c637ab908c693fa6b26c57f707ca070", size = 3265779 }, - { url = "https://files.pythonhosted.org/packages/c0/36/56234b75760fa1ab02e83d16a7e75e0894266d8a9b4ea4e4d07a76b9be54/tach-0.20.0-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7051e2c5ccccd9d740bd7b33339117470aad7a0425fdd8c12a4f234a3f6d0896", size = 3233228 }, - { url = "https://files.pythonhosted.org/packages/92/77/01527cfa0f8c4c6cbf75f28d5a0316ceba44211ba9d949ca92068fdf77a7/tach-0.20.0-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69e4a810e0f35565e523545f191b85123c207487fe7ad6df63b2e3b514bfd0ad", size = 3523062 }, - { url = "https://files.pythonhosted.org/packages/26/8a/bd9fb362c9638811660a19eaa7283850ed675f79ee0e082e83c8563c738a/tach-0.20.0-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:511af3a651e3cf5329162b008295296d25f3ad9b0713bc4a93b78958874b2b4b", size = 3529428 }, - { url = "https://files.pythonhosted.org/packages/92/c2/7e01d870a79d65e0cceb621eac43c925f0bd96748c4da0039f5594e64f89/tach-0.20.0-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a80ba230299950493986dec04998a8ea231c9473c0d0b506cf67f139f640757", size = 3769550 }, - { url = "https://files.pythonhosted.org/packages/a1/38/1ac3e633ddf775e2c76d6daa8f345f02db2252b02b83970ca15fbe8504bd/tach-0.20.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aba656fd46e89a236d9b30610851010b200e7ae25db3053d1d852f6cc0357640", size = 3387869 }, - { url = "https://files.pythonhosted.org/packages/59/74/3ebe4994b0569a4b53b5963ad4b63ca91277a543c841cc4934132030f325/tach-0.20.0-cp37-abi3-win32.whl", hash = "sha256:653455ff1da0aebfdd7408905aae13747a7144ee98490d93778447f56330fa4b", size = 2608869 }, - { url = "https://files.pythonhosted.org/packages/7f/41/8d1d42e4de71e2894efe0e2ffd88e870252179df93335d0e7f04edd436b6/tach-0.20.0-cp37-abi3-win_amd64.whl", hash = "sha256:efdefa94bf899306fcb265ca603a419a24d2d81cc82d6547f4222077a37fa474", size = 2801132 }, + { url = "https://files.pythonhosted.org/packages/96/00/2b325970b3060c7cecebab6d295afe763365822b1306a12eeab198f74323/starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7", size = 73225 }, ] [[package]] @@ -2885,15 +3411,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, ] -[[package]] -name = "tomli-w" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675 }, -] - [[package]] name = "tqdm" version = "4.67.1" @@ -2906,6 +3423,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 }, ] +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, +] + [[package]] name = "typer" version = "0.15.1" @@ -2927,7 +3453,8 @@ version = "2.31.0.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", ] @@ -2945,7 +3472,8 @@ version = "2.32.0.20241016" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", ] dependencies = [ { name = "urllib3", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' and platform_python_implementation != 'PyPy'" }, @@ -2974,16 +3502,12 @@ wheels = [ ] [[package]] -name = "typing-inspect" -version = "0.9.0" +name = "uritemplate" +version = "4.1.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mypy-extensions" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825 } +sdist = { url = "https://files.pythonhosted.org/packages/d2/5a/4742fdba39cd02a56226815abfa72fe0aa81c33bed16ed045647d6000eba/uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0", size = 273898 } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827 }, + { url = "https://files.pythonhosted.org/packages/81/c0/7461b49cd25aeece13766f02ee576d1db528f1c37ce69aee300e075b485b/uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e", size = 10356 }, ] [[package]] @@ -2992,7 +3516,8 @@ version = "1.26.20" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation == 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation == 'PyPy'", "python_full_version < '3.10' and platform_python_implementation == 'PyPy'", "python_full_version < '3.10' and platform_python_implementation != 'PyPy'", ] @@ -3007,7 +3532,8 @@ version = "2.3.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation != 'PyPy'", - "python_full_version >= '3.10' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version >= '3.11' and python_full_version < '3.13' and platform_python_implementation != 'PyPy'", + "python_full_version == '3.10.*' and platform_python_implementation != 'PyPy'", ] sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } wheels = [