Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 28 additions & 30 deletions agentops/client/api/versions/v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from agentops.client.api.base import BaseApiClient
from agentops.client.api.types import AuthTokenResponse
from agentops.client.http.http_client import HttpClient
from agentops.exceptions import InvalidApiKeyException
from agentops.logging import logger
from termcolor import colored

Expand All @@ -32,38 +33,35 @@ async def fetch_auth_token(self, api_key: str) -> AuthTokenResponse:
api_key: The API key to authenticate with

Returns:
AuthTokenResponse containing token and project information, or None if failed
"""
try:
path = "/v3/auth/token"
data = {"api_key": api_key}
headers = self.prepare_headers()

# Build full URL
url = self._get_full_url(path)

# Make async request
response_data = await HttpClient.async_request(
method="POST", url=url, data=data, headers=headers, timeout=30
)
AuthTokenResponse containing token and project information

token = response_data.get("token")
if not token:
logger.warning("Authentication failed: Perhaps an invalid API key?")
return None

# Check project premium status
if response_data.get("project_prem_status") != "pro":
logger.info(
colored(
"\x1b[34mYou're on the agentops free plan 🤔\x1b[0m",
"blue",
)
Raises:
InvalidApiKeyException: If the API key is invalid or authentication fails
"""
path = "/v3/auth/token"
data = {"api_key": api_key}
headers = self.prepare_headers()

# Build full URL
url = self._get_full_url(path)

# Make async request
response_data = await HttpClient.async_request(
method="POST", url=url, data=data, headers=headers, timeout=30
)

if response_data is None or not response_data.get("token"):
raise InvalidApiKeyException(api_key, self.endpoint)

# Check project premium status
if response_data.get("project_prem_status") != "pro":
logger.info(
colored(
"\x1b[34mYou're on the agentops free plan 🤔\x1b[0m",
"blue",
)
)

return response_data

except Exception:
return None
return response_data

# Add V3-specific API methods here
70 changes: 38 additions & 32 deletions agentops/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,55 +94,61 @@ def _set_auth_data(self, token: str, project_id: str):
HttpClient.set_project_id(project_id)

async def _fetch_auth_async(self, api_key: str) -> Optional[dict]:
"""Asynchronously fetch authentication token."""
try:
response = await self.api.v3.fetch_auth_token(api_key)
if response:
self._set_auth_data(response["token"], response["project_id"])
"""Asynchronously fetch authentication token.

Raises:
InvalidApiKeyException: propagated from v3.fetch_auth_token on failure
"""
response = await self.api.v3.fetch_auth_token(api_key)
self._set_auth_data(response["token"], response["project_id"])

# Update V4 client with token
self.api.v4.set_auth_token(response["token"])
# Update V4 client with token
self.api.v4.set_auth_token(response["token"])

# Update tracer config with real project ID
tracing_config = {"project_id": response["project_id"]}
tracer.update_config(tracing_config)
# Update tracer config with real project ID
tracing_config = {"project_id": response["project_id"]}
tracer.update_config(tracing_config)

logger.debug("Successfully fetched authentication token asynchronously")
return response
else:
logger.debug("Authentication failed - will continue without authentication")
return None
except Exception:
return None
logger.debug("Successfully fetched authentication token asynchronously")
return response

def _start_auth_task(self, api_key: str):
"""Start the async authentication task."""
"""Start the async authentication task.

In synchronous contexts (no running event loop), blocks until auth completes
and re-raises any authentication exception so init() can surface it to the caller.
In async contexts, schedules a background task; the caller must handle the result.
"""
if self._auth_task and not self._auth_task.done():
return # Task already running

try:
loop = asyncio.get_event_loop()
if loop.is_running():
# Use existing event loop
# Async context: schedule as a background task; exceptions won't propagate here
self._auth_task = loop.create_task(self._fetch_auth_async(api_key))
else:
# Create new event loop in background thread
def run_async_auth():
asyncio.run(self._fetch_auth_async(api_key))
return
Comment on lines 129 to +130
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Propagate auth failures when an event loop is running

When init() is called from an async context such as an ASGI startup hook or notebook, this branch only schedules _fetch_auth_async() and returns, so any InvalidApiKeyException raised by fetch_auth_token() stays on the background task while init() continues and later marks the client initialized. This means the new synchronous auth failure behavior still does not apply in running-loop environments, and invalid credentials can be silently accepted until export fails.

Useful? React with 👍 / 👎.

except RuntimeError:
pass

import threading
# Synchronous context: run auth in a thread so asyncio.run() works, then join
# and re-raise so the caller (init()) surfaces the error immediately.
auth_exc: list = []

auth_thread = threading.Thread(target=run_async_auth, daemon=True)
auth_thread.start()
except RuntimeError:
# Create new event loop in background thread
def run_async_auth():
def run_async_auth():
try:
asyncio.run(self._fetch_auth_async(api_key))
except Exception as exc:
auth_exc.append(exc)

import threading
auth_thread = threading.Thread(target=run_async_auth, daemon=True)
auth_thread.start()
auth_thread.join(timeout=35) # slightly longer than the HTTP timeout (30s)

auth_thread = threading.Thread(target=run_async_auth, daemon=True)
auth_thread.start()
if auth_thread.is_alive():
raise TimeoutError("AgentOps authentication timed out after 35 seconds")
if auth_exc:
raise auth_exc[0]

def init(self, **kwargs: Any) -> None: # Return type updated to None
# Recreate the Config object to parse environment variables at the time of initialization
Expand Down
11 changes: 7 additions & 4 deletions tests/integration/test_auth_flow.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import pytest
from unittest.mock import patch, MagicMock
from unittest.mock import patch, MagicMock, AsyncMock
from agentops.client import Client
from agentops.exceptions import InvalidApiKeyException, ApiServerException

Expand All @@ -20,7 +20,9 @@ def test_auth_flow(mock_api_key):
with patch("agentops.client.client.ApiClient") as mock_api_client:
# Create mock API instance
mock_api = MagicMock()
mock_api.v3.fetch_auth_token.return_value = {"token": "mock_token", "project_id": "mock_project_id"}
mock_api.v3.fetch_auth_token = AsyncMock(
return_value={"token": "mock_token", "project_id": "mock_project_id"}
)
mock_api_client.return_value = mock_api

# Initialize the client
Expand All @@ -38,15 +40,16 @@ def test_auth_flow(mock_api_key):

@pytest.mark.vcr()
def test_auth_flow_invalid_key():
"""Test authentication flow with invalid API key."""
"""Test authentication flow with invalid API key raises an exception."""
with patch("agentops.client.client.ApiClient") as mock_api_client:
# Create mock API instance that raises an error
mock_api = MagicMock()
mock_api.v3.fetch_auth_token.side_effect = ApiServerException("Invalid API key")
mock_api.v3.fetch_auth_token = AsyncMock(side_effect=ApiServerException("Invalid API key"))
mock_api_client.return_value = mock_api

client = Client()
with pytest.raises((InvalidApiKeyException, ApiServerException)) as exc_info:
client.init(api_key="invalid-key")

assert not client.initialized
assert "Invalid API key" in str(exc_info.value)
6 changes: 4 additions & 2 deletions tests/integration/test_session_concurrency.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import pytest
import concurrent.futures
from unittest.mock import patch, MagicMock
from unittest.mock import patch, MagicMock, AsyncMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
import agentops
Expand Down Expand Up @@ -37,7 +37,9 @@ def setup_agentops(mock_api_key):
with patch("agentops.client.client.ApiClient") as mock_api_client:
# Create mock API instance
mock_api = MagicMock()
mock_api.v3.fetch_auth_token.return_value = {"token": "mock_token", "project_id": "mock_project_id"}
mock_api.v3.fetch_auth_token = AsyncMock(
return_value={"token": "mock_token", "project_id": "mock_project_id"}
)
mock_api_client.return_value = mock_api

# Mock global tracer to avoid actual initialization
Expand Down
14 changes: 9 additions & 5 deletions tests/unit/test_session.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import pytest
from unittest.mock import patch, MagicMock
from unittest.mock import patch, MagicMock, AsyncMock

# Tests for the new session management functionality
# These tests call the actual public API but mock the underlying implementation
Expand All @@ -25,10 +25,12 @@ def mock_tracing_core():
@pytest.fixture(scope="function")
def mock_api_client():
"""Mock the API client to avoid actual API calls"""
with patch("agentops.client.api.ApiClient") as mock_api:
with patch("agentops.client.client.ApiClient") as mock_api:
# Configure the v3.fetch_auth_token method to return a valid response
mock_v3 = MagicMock()
mock_v3.fetch_auth_token.return_value = {"token": "mock-jwt-token", "project_id": "mock-project-id"}
mock_v3.fetch_auth_token = AsyncMock(
return_value={"token": "mock-jwt-token", "project_id": "mock-project-id"}
)
mock_api.return_value.v3 = mock_v3

yield mock_api
Expand Down Expand Up @@ -423,9 +425,11 @@ def test_session_management_integration():
mock_tracer.initialized = True

# Mock API client
with patch("agentops.client.api.ApiClient") as mock_api:
with patch("agentops.client.client.ApiClient") as mock_api:
mock_v3 = MagicMock()
mock_v3.fetch_auth_token.return_value = {"token": "mock-jwt-token", "project_id": "mock-project-id"}
mock_v3.fetch_auth_token = AsyncMock(
return_value={"token": "mock-jwt-token", "project_id": "mock-project-id"}
)
mock_api.return_value.v3 = mock_v3

# Initialize AgentOps
Expand Down