refactor(core): dedup version, plus_api, and oauth2 stack into crewai-core

This commit is contained in:
Greyson Lalonde
2026-05-06 04:36:14 +08:00
parent 87b3df9803
commit 9fb76c3c7c
53 changed files with 1354 additions and 1917 deletions

View File

@@ -1,3 +1,7 @@
"""CLI authentication entry point."""
from __future__ import annotations
from crewai_cli.authentication.main import AuthenticationCommand

View File

@@ -1 +1,8 @@
ALGORITHMS = ["RS256"]
"""Re-export of authentication constants from ``crewai_core.auth.constants``."""
from __future__ import annotations
from crewai_core.auth.constants import ALGORITHMS as ALGORITHMS
__all__ = ["ALGORITHMS"]

View File

@@ -1,185 +1,30 @@
import time
from typing import TYPE_CHECKING, Any, TypeVar, cast
import webbrowser
"""CLI-side authentication wiring.
from crewai_core.token_manager import TokenManager
import httpx
from pydantic import BaseModel, Field
from rich.console import Console
Re-exports the OAuth2 primitives from ``crewai_core.auth`` and overrides the
``_post_login`` hook to also log into the tool repository.
"""
from crewai_cli.authentication.utils import validate_jwt_token
from crewai_cli.config import Settings
from __future__ import annotations
from crewai_core.auth.oauth2 import (
AuthenticationCommand as _BaseAuthenticationCommand,
Oauth2Settings as Oauth2Settings,
ProviderFactory as ProviderFactory,
console,
)
from crewai_core.settings import Settings
console = Console()
TOauth2Settings = TypeVar("TOauth2Settings", bound="Oauth2Settings")
__all__ = ["AuthenticationCommand", "Oauth2Settings", "ProviderFactory"]
class Oauth2Settings(BaseModel):
provider: str = Field(
description="OAuth2 provider used for authentication (e.g., workos, okta, auth0)."
)
client_id: str = Field(
description="OAuth2 client ID issued by the provider, used during authentication requests."
)
domain: str = Field(
description="OAuth2 provider's domain (e.g., your-org.auth0.com) used for issuing tokens."
)
audience: str | None = Field(
description="OAuth2 audience value, typically used to identify the target API or resource.",
default=None,
)
extra: dict[str, Any] = Field(
description="Extra configuration for the OAuth2 provider.",
default={},
)
class AuthenticationCommand(_BaseAuthenticationCommand):
"""CLI-side login that also signs the user into the tool repository."""
@classmethod
def from_settings(cls: type[TOauth2Settings]) -> TOauth2Settings:
"""Create an Oauth2Settings instance from the CLI settings."""
settings = Settings()
return cls(
provider=settings.oauth2_provider,
domain=settings.oauth2_domain,
client_id=settings.oauth2_client_id,
audience=settings.oauth2_audience,
extra=settings.oauth2_extra,
)
if TYPE_CHECKING:
from crewai_cli.authentication.providers.base_provider import BaseProvider
class ProviderFactory:
@classmethod
def from_settings(
cls: type["ProviderFactory"], # noqa: UP037
settings: Oauth2Settings | None = None,
) -> "BaseProvider": # noqa: UP037
settings = settings or Oauth2Settings.from_settings()
import importlib
module = importlib.import_module(
f"crewai_cli.authentication.providers.{settings.provider.lower()}"
)
# Converts from snake_case to CamelCase to obtain the provider class name.
provider = getattr(
module,
f"{''.join(word.capitalize() for word in settings.provider.split('_'))}Provider",
)
return cast("BaseProvider", provider(settings))
class AuthenticationCommand:
def __init__(self) -> None:
self.token_manager = TokenManager()
self.oauth2_provider = ProviderFactory.from_settings()
def login(self) -> None:
"""Sign up to CrewAI+"""
console.print("Signing in to CrewAI AMP...\n", style="bold blue")
device_code_data = self._get_device_code()
self._display_auth_instructions(device_code_data)
return self._poll_for_token(device_code_data)
def _get_device_code(self) -> dict[str, Any]:
"""Get the device code to authenticate the user."""
device_code_payload = {
"client_id": self.oauth2_provider.get_client_id(),
"scope": " ".join(self.oauth2_provider.get_oauth_scopes()),
"audience": self.oauth2_provider.get_audience(),
}
response = httpx.post(
url=self.oauth2_provider.get_authorize_url(),
data=device_code_payload,
timeout=20,
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
def _display_auth_instructions(self, device_code_data: dict[str, str]) -> None:
"""Display the authentication instructions to the user."""
verification_uri = device_code_data.get(
"verification_uri_complete", device_code_data.get("verification_uri", "")
)
console.print("1. Navigate to: ", verification_uri)
console.print("2. Enter the following code: ", device_code_data["user_code"])
webbrowser.open(verification_uri)
def _poll_for_token(self, device_code_data: dict[str, Any]) -> None:
"""Polls the server for the token until it is received, or max attempts are reached."""
token_payload = {
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_code_data["device_code"],
"client_id": self.oauth2_provider.get_client_id(),
}
console.print("\nWaiting for authentication... ", style="bold blue", end="")
attempts = 0
while True and attempts < 10:
response = httpx.post(
self.oauth2_provider.get_token_url(), data=token_payload, timeout=30
)
token_data = response.json()
if response.status_code == 200:
self._validate_and_save_token(token_data)
console.print(
"Success!",
style="bold green",
)
self._login_to_tool_repository()
console.print("\n[bold green]Welcome to CrewAI AMP![/bold green]\n")
return
if token_data["error"] not in ("authorization_pending", "slow_down"):
raise httpx.HTTPError(
token_data.get("error_description") or token_data.get("error")
)
time.sleep(device_code_data["interval"])
attempts += 1
console.print(
"Timeout: Failed to get the token. Please try again.", style="bold red"
)
def _validate_and_save_token(self, token_data: dict[str, Any]) -> None:
"""Validates the JWT token and saves the token to the token manager."""
jwt_token = token_data["access_token"]
issuer = self.oauth2_provider.get_issuer()
jwt_token_data = {
"jwt_token": jwt_token,
"jwks_url": self.oauth2_provider.get_jwks_url(),
"issuer": issuer,
"audience": self.oauth2_provider.get_audience(),
}
decoded_token = validate_jwt_token(**jwt_token_data)
expires_at = decoded_token.get("exp", 0)
self.token_manager.save_tokens(jwt_token, expires_at)
def _post_login(self) -> None:
self._login_to_tool_repository()
def _login_to_tool_repository(self) -> None:
"""Login to the tool repository."""
from crewai_cli.tools.main import ToolCommand
try:

View File

@@ -0,0 +1 @@
"""OAuth2 authentication providers — re-exported from ``crewai_core.auth.providers``."""

View File

@@ -1,34 +1,8 @@
from crewai_cli.authentication.providers.base_provider import BaseProvider
"""Re-export of ``Auth0Provider`` from ``crewai_core.auth.providers.auth0``."""
from __future__ import annotations
from crewai_core.auth.providers.auth0 import Auth0Provider as Auth0Provider
class Auth0Provider(BaseProvider):
def get_authorize_url(self) -> str:
return f"https://{self._get_domain()}/oauth/device/code"
def get_token_url(self) -> str:
return f"https://{self._get_domain()}/oauth/token"
def get_jwks_url(self) -> str:
return f"https://{self._get_domain()}/.well-known/jwks.json"
def get_issuer(self) -> str:
return f"https://{self._get_domain()}/"
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def _get_domain(self) -> str:
if self.settings.domain is None:
raise ValueError("Domain is required. Please set it in the configuration.")
return self.settings.domain
__all__ = ["Auth0Provider"]

View File

@@ -1,33 +1,8 @@
from abc import ABC, abstractmethod
"""Re-export of ``BaseProvider`` from ``crewai_core.auth.providers.base_provider``."""
from crewai_cli.authentication.main import Oauth2Settings
from __future__ import annotations
from crewai_core.auth.providers.base_provider import BaseProvider as BaseProvider
class BaseProvider(ABC):
def __init__(self, settings: Oauth2Settings):
self.settings = settings
@abstractmethod
def get_authorize_url(self) -> str: ...
@abstractmethod
def get_token_url(self) -> str: ...
@abstractmethod
def get_jwks_url(self) -> str: ...
@abstractmethod
def get_issuer(self) -> str: ...
@abstractmethod
def get_audience(self) -> str: ...
@abstractmethod
def get_client_id(self) -> str: ...
def get_required_fields(self) -> list[str]:
"""Returns which provider-specific fields inside the "extra" dict will be required"""
return []
def get_oauth_scopes(self) -> list[str]:
return ["openid", "profile", "email"]
__all__ = ["BaseProvider"]

View File

@@ -1,43 +1,8 @@
from typing import cast
"""Re-export of ``EntraIdProvider`` from ``crewai_core.auth.providers.entra_id``."""
from crewai_cli.authentication.providers.base_provider import BaseProvider
from __future__ import annotations
from crewai_core.auth.providers.entra_id import EntraIdProvider as EntraIdProvider
class EntraIdProvider(BaseProvider):
def get_authorize_url(self) -> str:
return f"{self._base_url()}/oauth2/v2.0/devicecode"
def get_token_url(self) -> str:
return f"{self._base_url()}/oauth2/v2.0/token"
def get_jwks_url(self) -> str:
return f"{self._base_url()}/discovery/v2.0/keys"
def get_issuer(self) -> str:
return f"{self._base_url()}/v2.0"
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_oauth_scopes(self) -> list[str]:
return [
*super().get_oauth_scopes(),
*cast(str, self.settings.extra.get("scope", "")).split(),
]
def get_required_fields(self) -> list[str]:
return ["scope"]
def _base_url(self) -> str:
return f"https://login.microsoftonline.com/{self.settings.domain}"
__all__ = ["EntraIdProvider"]

View File

@@ -1,32 +1,8 @@
from crewai_cli.authentication.providers.base_provider import BaseProvider
"""Re-export of ``KeycloakProvider`` from ``crewai_core.auth.providers.keycloak``."""
from __future__ import annotations
from crewai_core.auth.providers.keycloak import KeycloakProvider as KeycloakProvider
class KeycloakProvider(BaseProvider):
def get_authorize_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/auth/device"
def get_token_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/token"
def get_jwks_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/certs"
def get_issuer(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}"
def get_audience(self) -> str:
return self.settings.audience or "no-audience-provided"
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_required_fields(self) -> list[str]:
return ["realm"]
def _oauth2_base_url(self) -> str:
domain = self.settings.domain.removeprefix("https://").removeprefix("http://")
return f"https://{domain}"
__all__ = ["KeycloakProvider"]

View File

@@ -1,42 +1,8 @@
from crewai_cli.authentication.providers.base_provider import BaseProvider
"""Re-export of ``OktaProvider`` from ``crewai_core.auth.providers.okta``."""
from __future__ import annotations
from crewai_core.auth.providers.okta import OktaProvider as OktaProvider
class OktaProvider(BaseProvider):
def get_authorize_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/device/authorize"
def get_token_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/token"
def get_jwks_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/keys"
def get_issuer(self) -> str:
return self._oauth2_base_url().removesuffix("/oauth2")
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_required_fields(self) -> list[str]:
return ["authorization_server_name", "using_org_auth_server"]
def _oauth2_base_url(self) -> str:
using_org_auth_server = self.settings.extra.get("using_org_auth_server", False)
if using_org_auth_server:
base_url = f"https://{self.settings.domain}/oauth2"
else:
base_url = f"https://{self.settings.domain}/oauth2/{self.settings.extra.get('authorization_server_name', 'default')}"
return f"{base_url}"
__all__ = ["OktaProvider"]

View File

@@ -1,30 +1,8 @@
from crewai_cli.authentication.providers.base_provider import BaseProvider
"""Re-export of ``WorkosProvider`` from ``crewai_core.auth.providers.workos``."""
from __future__ import annotations
from crewai_core.auth.providers.workos import WorkosProvider as WorkosProvider
class WorkosProvider(BaseProvider):
def get_authorize_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/device_authorization"
def get_token_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/token"
def get_jwks_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/jwks"
def get_issuer(self) -> str:
return f"https://{self._get_domain()}"
def get_audience(self) -> str:
return self.settings.audience or ""
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def _get_domain(self) -> str:
if self.settings.domain is None:
raise ValueError("Domain is required. Please set it in the configuration.")
return self.settings.domain
__all__ = ["WorkosProvider"]

View File

@@ -1,13 +1,11 @@
from crewai_core.token_manager import TokenManager
"""Re-exports of authentication token helpers from ``crewai_core.auth.token``."""
from __future__ import annotations
from crewai_core.auth.token import (
AuthError as AuthError,
get_auth_token as get_auth_token,
)
class AuthError(Exception):
pass
def get_auth_token() -> str:
"""Get the authentication token."""
access_token = TokenManager().get_token()
if not access_token:
raise AuthError("No token found, make sure you are logged in")
return access_token
__all__ = ["AuthError", "get_auth_token"]

View File

@@ -1,63 +1,8 @@
from typing import Any
"""Re-export of ``validate_jwt_token`` from ``crewai_core.auth.utils``."""
import jwt
from jwt import PyJWKClient
from __future__ import annotations
from crewai_core.auth.utils import validate_jwt_token as validate_jwt_token
def validate_jwt_token(
jwt_token: str, jwks_url: str, issuer: str, audience: str
) -> Any:
"""
Verify the token's signature and claims using PyJWT.
:param jwt_token: The JWT (JWS) string to validate.
:param jwks_url: The URL of the JWKS endpoint.
:param issuer: The expected issuer of the token.
:param audience: The expected audience of the token.
:return: The decoded token.
:raises Exception: If the token is invalid for any reason (e.g., signature mismatch,
expired, incorrect issuer/audience, JWKS fetching error,
missing required claims).
"""
try:
jwk_client = PyJWKClient(jwks_url)
signing_key = jwk_client.get_signing_key_from_jwt(jwt_token)
_unverified_decoded_token = jwt.decode(
jwt_token, options={"verify_signature": False}
)
return jwt.decode(
jwt_token,
signing_key.key,
algorithms=["RS256"],
audience=audience,
issuer=issuer,
leeway=10.0,
options={
"verify_signature": True,
"verify_exp": True,
"verify_nbf": True,
"verify_iat": True,
"require": ["exp", "iat", "iss", "aud", "sub"],
},
)
except jwt.ExpiredSignatureError as e:
raise Exception("Token has expired.") from e
except jwt.InvalidAudienceError as e:
actual_audience = _unverified_decoded_token.get("aud", "[no audience found]")
raise Exception(
f"Invalid token audience. Got: '{actual_audience}'. Expected: '{audience}'"
) from e
except jwt.InvalidIssuerError as e:
actual_issuer = _unverified_decoded_token.get("iss", "[no issuer found]")
raise Exception(
f"Invalid token issuer. Got: '{actual_issuer}'. Expected: '{issuer}'"
) from e
except jwt.MissingRequiredClaimError as e:
raise Exception(f"Token is missing required claims: {e!s}") from e
except jwt.exceptions.PyJWKClientError as e:
raise Exception(f"JWKS or key processing error: {e!s}") from e
except jwt.InvalidTokenError as e:
raise Exception(f"Invalid token: {e!s}") from e
__all__ = ["validate_jwt_token"]

View File

@@ -1,231 +1,12 @@
import os
from typing import Any
from urllib.parse import urljoin
"""Re-export of ``crewai_core.plus_api.PlusAPI``.
import httpx
Kept as a stable import path for the CLI; new code should import from
``crewai_core.plus_api`` directly.
"""
from crewai_cli.config import Settings
from crewai_cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL
from crewai_cli.version import get_crewai_version
from __future__ import annotations
from crewai_core.plus_api import PlusAPI as PlusAPI
class PlusAPI:
"""
This class exposes methods for working with the CrewAI+ API.
"""
TOOLS_RESOURCE = "/crewai_plus/api/v1/tools"
ORGANIZATIONS_RESOURCE = "/crewai_plus/api/v1/me/organizations"
CREWS_RESOURCE = "/crewai_plus/api/v1/crews"
AGENTS_RESOURCE = "/crewai_plus/api/v1/agents"
TRACING_RESOURCE = "/crewai_plus/api/v1/tracing"
EPHEMERAL_TRACING_RESOURCE = "/crewai_plus/api/v1/tracing/ephemeral"
INTEGRATIONS_RESOURCE = "/crewai_plus/api/v1/integrations"
def __init__(self, api_key: str | None = None) -> None:
self.api_key = api_key
self.headers = {
"Content-Type": "application/json",
"User-Agent": f"CrewAI-CLI/{get_crewai_version()}",
"X-Crewai-Version": get_crewai_version(),
}
settings = Settings()
if settings.org_uuid:
self.headers["X-Crewai-Organization-Id"] = settings.org_uuid
if api_key:
self.headers["Authorization"] = f"Bearer {api_key}"
self.base_url = (
os.getenv("CREWAI_PLUS_URL")
or str(settings.enterprise_base_url)
or DEFAULT_CREWAI_ENTERPRISE_URL
)
def _make_request(
self, method: str, endpoint: str, **kwargs: Any
) -> httpx.Response:
url = urljoin(self.base_url, endpoint)
verify = kwargs.pop("verify", True)
with httpx.Client(trust_env=False, verify=verify) as client:
return client.request(method, url, headers=self.headers, **kwargs)
def login_to_tool_repository(
self, user_identifier: str | None = None
) -> httpx.Response:
payload = {}
if user_identifier:
payload["user_identifier"] = user_identifier
return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login", json=payload)
def get_tool(self, handle: str) -> httpx.Response:
return self._make_request("GET", f"{self.TOOLS_RESOURCE}/{handle}")
async def get_agent(self, handle: str) -> httpx.Response:
url = urljoin(self.base_url, f"{self.AGENTS_RESOURCE}/{handle}")
async with httpx.AsyncClient() as client:
return await client.get(url, headers=self.headers)
def publish_tool(
self,
handle: str,
is_public: bool,
version: str,
description: str | None,
encoded_file: str,
available_exports: list[dict[str, Any]] | None = None,
tools_metadata: list[dict[str, Any]] | None = None,
) -> httpx.Response:
params = {
"handle": handle,
"public": is_public,
"version": version,
"file": encoded_file,
"description": description,
"available_exports": available_exports,
"tools_metadata": {"package": handle, "tools": tools_metadata}
if tools_metadata is not None
else None,
}
return self._make_request("POST", f"{self.TOOLS_RESOURCE}", json=params)
def deploy_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"POST", f"{self.CREWS_RESOURCE}/by-name/{project_name}/deploy"
)
def deploy_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("POST", f"{self.CREWS_RESOURCE}/{uuid}/deploy")
def crew_status_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/by-name/{project_name}/status"
)
def crew_status_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("GET", f"{self.CREWS_RESOURCE}/{uuid}/status")
def crew_by_name(
self, project_name: str, log_type: str = "deployment"
) -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/by-name/{project_name}/logs/{log_type}"
)
def crew_by_uuid(self, uuid: str, log_type: str = "deployment") -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/{uuid}/logs/{log_type}"
)
def delete_crew_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"DELETE", f"{self.CREWS_RESOURCE}/by-name/{project_name}"
)
def delete_crew_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("DELETE", f"{self.CREWS_RESOURCE}/{uuid}")
def list_crews(self) -> httpx.Response:
return self._make_request("GET", self.CREWS_RESOURCE)
def create_crew(self, payload: dict[str, Any]) -> httpx.Response:
return self._make_request("POST", self.CREWS_RESOURCE, json=payload)
def get_organizations(self) -> httpx.Response:
return self._make_request("GET", self.ORGANIZATIONS_RESOURCE)
def initialize_trace_batch(self, payload: dict[str, Any]) -> httpx.Response:
return self._make_request(
"POST",
f"{self.TRACING_RESOURCE}/batches",
json=payload,
timeout=30,
)
def initialize_ephemeral_trace_batch(
self, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches",
json=payload,
)
def send_trace_events(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/events",
json=payload,
timeout=30,
)
def send_ephemeral_trace_events(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}/events",
json=payload,
timeout=30,
)
def finalize_trace_batch(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/finalize",
json=payload,
timeout=30,
)
def finalize_ephemeral_trace_batch(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}/finalize",
json=payload,
timeout=30,
)
def mark_trace_batch_as_failed(
self, trace_batch_id: str, error_message: str
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}",
json={"status": "failed", "failure_reason": error_message},
timeout=30,
)
def mark_ephemeral_trace_batch_as_failed(
self, trace_batch_id: str, error_message: str
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}",
json={"status": "failed", "failure_reason": error_message},
timeout=30,
)
def get_mcp_configs(self, slugs: list[str]) -> httpx.Response:
"""Get MCP server configurations for the given slugs."""
return self._make_request(
"GET",
f"{self.INTEGRATIONS_RESOURCE}/mcp_configs",
params={"slugs": ",".join(slugs)},
timeout=30,
)
def get_triggers(self) -> httpx.Response:
"""Get all available triggers from integrations."""
return self._make_request("GET", f"{self.INTEGRATIONS_RESOURCE}/apps")
def get_trigger_payload(self, app_slug: str, trigger_slug: str) -> httpx.Response:
"""Get sample payload for a specific trigger."""
return self._make_request(
"GET", f"{self.INTEGRATIONS_RESOURCE}/{app_slug}/{trigger_slug}/payload"
)
__all__ = ["PlusAPI"]

View File

@@ -1,210 +1,24 @@
"""Version utilities for CrewAI CLI."""
"""Re-exports of version utilities from ``crewai_core.version``.
from collections.abc import Mapping
from datetime import datetime, timedelta
from functools import lru_cache
import json
from pathlib import Path
from typing import Any
from urllib import request
from urllib.error import URLError
Kept as a stable import path for the CLI; new code should import from
``crewai_core.version`` directly.
"""
import appdirs
from crewai_core.version import get_crewai_version as get_crewai_version
from packaging.version import InvalidVersion, Version, parse
from __future__ import annotations
from crewai_core.version import (
check_version as check_version,
get_crewai_version as get_crewai_version,
get_latest_version_from_pypi as get_latest_version_from_pypi,
is_current_version_yanked as is_current_version_yanked,
is_newer_version_available as is_newer_version_available,
)
@lru_cache(maxsize=1)
def _get_cache_file() -> Path:
"""Get the path to the version cache file.
Cached to avoid repeated filesystem operations.
"""
cache_dir = Path(appdirs.user_cache_dir("crewai"))
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir / "version_cache.json"
def _is_cache_valid(cache_data: Mapping[str, Any]) -> bool:
"""Check if the cache is still valid, less than 24 hours old."""
if "timestamp" not in cache_data:
return False
try:
cache_time = datetime.fromisoformat(str(cache_data["timestamp"]))
return datetime.now() - cache_time < timedelta(hours=24)
except (ValueError, TypeError):
return False
def _find_latest_non_yanked_version(
releases: Mapping[str, list[dict[str, Any]]],
) -> str | None:
"""Find the latest non-yanked version from PyPI releases data.
Args:
releases: PyPI releases dict mapping version strings to file info lists.
Returns:
The latest non-yanked version string, or None if all versions are yanked.
"""
best_version: Version | None = None
best_version_str: str | None = None
for version_str, files in releases.items():
try:
v = parse(version_str)
except InvalidVersion:
continue
if v.is_prerelease or v.is_devrelease:
continue
if not files:
continue
all_yanked = all(f.get("yanked", False) for f in files)
if all_yanked:
continue
if best_version is None or v > best_version:
best_version = v
best_version_str = version_str
return best_version_str
def _is_version_yanked(
version_str: str,
releases: Mapping[str, list[dict[str, Any]]],
) -> tuple[bool, str]:
"""Check if a specific version is yanked.
Args:
version_str: The version string to check.
releases: PyPI releases dict mapping version strings to file info lists.
Returns:
Tuple of (is_yanked, yanked_reason).
"""
files = releases.get(version_str, [])
if not files:
return False, ""
all_yanked = all(f.get("yanked", False) for f in files)
if not all_yanked:
return False, ""
for f in files:
reason = f.get("yanked_reason", "")
if reason:
return True, str(reason)
return True, ""
def get_latest_version_from_pypi(timeout: int = 2) -> str | None:
"""Get the latest non-yanked version of CrewAI from PyPI.
Args:
timeout: Request timeout in seconds.
Returns:
Latest non-yanked version string or None if unable to fetch.
"""
cache_file = _get_cache_file()
if cache_file.exists():
try:
cache_data = json.loads(cache_file.read_text())
if _is_cache_valid(cache_data) and "current_version" in cache_data:
version: str | None = cache_data.get("version")
return version
except (json.JSONDecodeError, OSError):
pass
try:
with request.urlopen(
"https://pypi.org/pypi/crewai/json", timeout=timeout
) as response:
data = json.loads(response.read())
releases: dict[str, list[dict[str, Any]]] = data["releases"]
latest_version = _find_latest_non_yanked_version(releases)
current_version = get_crewai_version()
is_yanked, yanked_reason = _is_version_yanked(current_version, releases)
cache_data = {
"version": latest_version,
"timestamp": datetime.now().isoformat(),
"current_version": current_version,
"current_version_yanked": is_yanked,
"current_version_yanked_reason": yanked_reason,
}
cache_file.write_text(json.dumps(cache_data))
return latest_version
except (URLError, json.JSONDecodeError, KeyError, OSError):
return None
def is_current_version_yanked() -> tuple[bool, str]:
"""Check if the currently installed version has been yanked on PyPI.
Reads from cache if available, otherwise triggers a fetch.
Returns:
Tuple of (is_yanked, yanked_reason).
"""
cache_file = _get_cache_file()
if cache_file.exists():
try:
cache_data = json.loads(cache_file.read_text())
if _is_cache_valid(cache_data) and "current_version" in cache_data:
current = get_crewai_version()
if cache_data.get("current_version") == current:
return (
bool(cache_data.get("current_version_yanked", False)),
str(cache_data.get("current_version_yanked_reason", "")),
)
except (json.JSONDecodeError, OSError):
pass
get_latest_version_from_pypi()
try:
cache_data = json.loads(cache_file.read_text())
return (
bool(cache_data.get("current_version_yanked", False)),
str(cache_data.get("current_version_yanked_reason", "")),
)
except (json.JSONDecodeError, OSError):
return False, ""
def check_version() -> tuple[str, str | None]:
"""Check current and latest versions.
Returns:
Tuple of (current_version, latest_version).
latest_version is None if unable to fetch from PyPI.
"""
current = get_crewai_version()
latest = get_latest_version_from_pypi()
return current, latest
def is_newer_version_available() -> tuple[bool, str, str | None]:
"""Check if a newer version is available.
Returns:
Tuple of (is_newer, current_version, latest_version).
"""
current, latest = check_version()
if latest is None:
return False, current, None
try:
return parse(latest) > parse(current), current, latest
except (InvalidVersion, TypeError):
return False, current, latest
__all__ = [
"check_version",
"get_crewai_version",
"get_latest_version_from_pypi",
"is_current_version_yanked",
"is_newer_version_available",
]

View File

@@ -43,7 +43,7 @@ class TestAuthenticationCommand:
"crewai_cli.authentication.main.AuthenticationCommand._display_auth_instructions"
)
@patch("crewai_cli.authentication.main.AuthenticationCommand._poll_for_token")
@patch("crewai_cli.authentication.main.console.print")
@patch("crewai_core.auth.oauth2.console.print")
def test_login(
self,
mock_console_print,
@@ -82,8 +82,8 @@ class TestAuthenticationCommand:
self.auth_command.oauth2_provider._get_domain() == expected_urls["domain"]
)
@patch("crewai_cli.authentication.main.webbrowser")
@patch("crewai_cli.authentication.main.console.print")
@patch("crewai_core.auth.oauth2.webbrowser")
@patch("crewai_core.auth.oauth2.console.print")
def test_display_auth_instructions(self, mock_console_print, mock_webbrowser):
device_code_data = {
"verification_uri_complete": "https://example.com/auth",
@@ -113,8 +113,8 @@ class TestAuthenticationCommand:
],
)
@pytest.mark.parametrize("has_expiration", [True, False])
@patch("crewai_cli.authentication.main.validate_jwt_token")
@patch("crewai_cli.authentication.main.TokenManager.save_tokens")
@patch("crewai_core.auth.oauth2.validate_jwt_token")
@patch("crewai_core.auth.oauth2.TokenManager.save_tokens")
def test_validate_and_save_token(
self,
mock_save_tokens,
@@ -164,7 +164,7 @@ class TestAuthenticationCommand:
@patch("crewai_cli.tools.main.ToolCommand")
@patch("crewai_cli.authentication.main.Settings")
@patch("crewai_cli.authentication.main.console.print")
@patch("crewai_core.auth.oauth2.console.print")
def test_login_to_tool_repository_success(
self, mock_console_print, mock_settings, mock_tool_command
):
@@ -196,7 +196,7 @@ class TestAuthenticationCommand:
mock_console_print.assert_has_calls(expected_calls)
@patch("crewai_cli.tools.main.ToolCommand")
@patch("crewai_cli.authentication.main.console.print")
@patch("crewai_core.auth.oauth2.console.print")
def test_login_to_tool_repository_error(
self, mock_console_print, mock_tool_command
):
@@ -226,7 +226,7 @@ class TestAuthenticationCommand:
]
mock_console_print.assert_has_calls(expected_calls)
@patch("crewai_cli.authentication.main.httpx.post")
@patch("crewai_core.auth.oauth2.httpx.post")
def test_get_device_code(self, mock_post):
mock_response = MagicMock()
mock_response.json.return_value = {
@@ -262,8 +262,8 @@ class TestAuthenticationCommand:
"verification_uri_complete": "https://example.com/auth",
}
@patch("crewai_cli.authentication.main.httpx.post")
@patch("crewai_cli.authentication.main.console.print")
@patch("crewai_core.auth.oauth2.httpx.post")
@patch("crewai_core.auth.oauth2.console.print")
def test_poll_for_token_success(self, mock_console_print, mock_post):
mock_response_success = MagicMock()
mock_response_success.status_code = 200
@@ -311,8 +311,8 @@ class TestAuthenticationCommand:
]
mock_console_print.assert_has_calls(expected_calls)
@patch("crewai_cli.authentication.main.httpx.post")
@patch("crewai_cli.authentication.main.console.print")
@patch("crewai_core.auth.oauth2.httpx.post")
@patch("crewai_core.auth.oauth2.console.print")
def test_poll_for_token_timeout(self, mock_console_print, mock_post):
mock_response_pending = MagicMock()
mock_response_pending.status_code = 400
@@ -330,7 +330,7 @@ class TestAuthenticationCommand:
"Timeout: Failed to get the token. Please try again.", style="bold red"
)
@patch("crewai_cli.authentication.main.httpx.post")
@patch("crewai_core.auth.oauth2.httpx.post")
def test_poll_for_token_error(self, mock_post):
"""Test the method to poll for token (error path)."""
# Setup mock to return error

View File

@@ -6,8 +6,8 @@ import jwt
from crewai_cli.authentication.utils import validate_jwt_token
@patch("crewai_cli.authentication.utils.PyJWKClient", return_value=MagicMock())
@patch("crewai_cli.authentication.utils.jwt")
@patch("crewai_core.auth.utils.PyJWKClient", return_value=MagicMock())
@patch("crewai_core.auth.utils.jwt")
class TestUtils(unittest.TestCase):
def test_validate_jwt_token(self, mock_jwt, mock_pyjwkclient):
mock_jwt.decode.return_value = {"exp": 1719859200}

View File

@@ -20,7 +20,7 @@ class TestPlusAPI(unittest.TestCase):
self.assertIn("CrewAI-CLI/", self.api.headers["User-Agent"])
self.assertTrue(self.api.headers["X-Crewai-Version"])
@patch("crewai_cli.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_login_to_tool_repository(self, mock_make_request):
mock_response = MagicMock()
mock_make_request.return_value = mock_response
@@ -48,8 +48,8 @@ class TestPlusAPI(unittest.TestCase):
**kwargs,
)
@patch("crewai_cli.plus_api.Settings")
@patch("crewai_cli.plus_api.httpx.Client")
@patch("crewai_core.plus_api.Settings")
@patch("crewai_core.plus_api.httpx.Client")
def test_login_to_tool_repository_with_org_uuid(
self, mock_client_class, mock_settings_class
):
@@ -71,7 +71,7 @@ class TestPlusAPI(unittest.TestCase):
)
self.assertEqual(response, mock_response)
@patch("crewai_cli.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_get_tool(self, mock_make_request):
mock_response = MagicMock()
mock_make_request.return_value = mock_response
@@ -82,8 +82,8 @@ class TestPlusAPI(unittest.TestCase):
)
self.assertEqual(response, mock_response)
@patch("crewai_cli.plus_api.Settings")
@patch("crewai_cli.plus_api.httpx.Client")
@patch("crewai_core.plus_api.Settings")
@patch("crewai_core.plus_api.httpx.Client")
def test_get_tool_with_org_uuid(self, mock_client_class, mock_settings_class):
mock_settings = MagicMock()
mock_settings.org_uuid = self.org_uuid
@@ -103,7 +103,7 @@ class TestPlusAPI(unittest.TestCase):
)
self.assertEqual(response, mock_response)
@patch("crewai_cli.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_publish_tool(self, mock_make_request):
mock_response = MagicMock()
mock_make_request.return_value = mock_response
@@ -131,8 +131,8 @@ class TestPlusAPI(unittest.TestCase):
)
self.assertEqual(response, mock_response)
@patch("crewai_cli.plus_api.Settings")
@patch("crewai_cli.plus_api.httpx.Client")
@patch("crewai_core.plus_api.Settings")
@patch("crewai_core.plus_api.httpx.Client")
def test_publish_tool_with_org_uuid(self, mock_client_class, mock_settings_class):
mock_settings = MagicMock()
mock_settings.org_uuid = self.org_uuid
@@ -170,7 +170,7 @@ class TestPlusAPI(unittest.TestCase):
)
self.assertEqual(response, mock_response)
@patch("crewai_cli.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_publish_tool_without_description(self, mock_make_request):
mock_response = MagicMock()
mock_make_request.return_value = mock_response
@@ -198,7 +198,7 @@ class TestPlusAPI(unittest.TestCase):
)
self.assertEqual(response, mock_response)
@patch("crewai_cli.plus_api.httpx.Client")
@patch("crewai_core.plus_api.httpx.Client")
def test_make_request(self, mock_client_class):
mock_client_instance = MagicMock()
mock_response = MagicMock()
@@ -213,35 +213,35 @@ class TestPlusAPI(unittest.TestCase):
)
self.assertEqual(response, mock_response)
@patch("crewai_cli.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_deploy_by_name(self, mock_make_request):
self.api.deploy_by_name("test_project")
mock_make_request.assert_called_once_with(
"POST", "/crewai_plus/api/v1/crews/by-name/test_project/deploy"
)
@patch("crewai_cli.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_deploy_by_uuid(self, mock_make_request):
self.api.deploy_by_uuid("test_uuid")
mock_make_request.assert_called_once_with(
"POST", "/crewai_plus/api/v1/crews/test_uuid/deploy"
)
@patch("crewai_cli.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_crew_status_by_name(self, mock_make_request):
self.api.crew_status_by_name("test_project")
mock_make_request.assert_called_once_with(
"GET", "/crewai_plus/api/v1/crews/by-name/test_project/status"
)
@patch("crewai_cli.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_crew_status_by_uuid(self, mock_make_request):
self.api.crew_status_by_uuid("test_uuid")
mock_make_request.assert_called_once_with(
"GET", "/crewai_plus/api/v1/crews/test_uuid/status"
)
@patch("crewai_cli.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_crew_by_name(self, mock_make_request):
self.api.crew_by_name("test_project")
mock_make_request.assert_called_once_with(
@@ -253,7 +253,7 @@ class TestPlusAPI(unittest.TestCase):
"GET", "/crewai_plus/api/v1/crews/by-name/test_project/logs/custom_log"
)
@patch("crewai_cli.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_crew_by_uuid(self, mock_make_request):
self.api.crew_by_uuid("test_uuid")
mock_make_request.assert_called_once_with(
@@ -265,26 +265,26 @@ class TestPlusAPI(unittest.TestCase):
"GET", "/crewai_plus/api/v1/crews/test_uuid/logs/custom_log"
)
@patch("crewai_cli.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_delete_crew_by_name(self, mock_make_request):
self.api.delete_crew_by_name("test_project")
mock_make_request.assert_called_once_with(
"DELETE", "/crewai_plus/api/v1/crews/by-name/test_project"
)
@patch("crewai_cli.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_delete_crew_by_uuid(self, mock_make_request):
self.api.delete_crew_by_uuid("test_uuid")
mock_make_request.assert_called_once_with(
"DELETE", "/crewai_plus/api/v1/crews/test_uuid"
)
@patch("crewai_cli.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_list_crews(self, mock_make_request):
self.api.list_crews()
mock_make_request.assert_called_once_with("GET", "/crewai_plus/api/v1/crews")
@patch("crewai_cli.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_create_crew(self, mock_make_request):
payload = {"name": "test_crew"}
self.api.create_crew(payload)
@@ -292,7 +292,7 @@ class TestPlusAPI(unittest.TestCase):
"POST", "/crewai_plus/api/v1/crews", json=payload
)
@patch("crewai_cli.plus_api.Settings")
@patch("crewai_core.plus_api.Settings")
@patch.dict(os.environ, {"CREWAI_PLUS_URL": ""})
def test_custom_base_url(self, mock_settings_class):
mock_settings = MagicMock()
@@ -333,7 +333,7 @@ async def test_get_agent(mock_async_client_class):
@pytest.mark.asyncio
@patch("httpx.AsyncClient")
@patch("crewai_cli.plus_api.Settings")
@patch("crewai_core.plus_api.Settings")
async def test_get_agent_with_org_uuid(mock_settings_class, mock_async_client_class):
org_uuid = "test-org-uuid"
mock_settings = MagicMock()

View File

@@ -7,15 +7,17 @@ from unittest.mock import MagicMock, patch
from crewai_cli.version import get_crewai_version as _get_ver
from crewai_cli.version import (
_find_latest_non_yanked_version,
_get_cache_file,
_is_cache_valid,
_is_version_yanked,
get_crewai_version,
get_latest_version_from_pypi,
is_current_version_yanked,
is_newer_version_available,
)
from crewai_core.version import (
_find_latest_non_yanked_version,
_get_cache_file,
_is_cache_valid,
_is_version_yanked,
)
def test_dynamic_versioning_consistency() -> None:
@@ -60,8 +62,8 @@ class TestVersionChecking:
cache_data = {"version": "1.0.0"}
assert _is_cache_valid(cache_data) is False
@patch("crewai_cli.version.Path.exists")
@patch("crewai_cli.version.request.urlopen")
@patch("crewai_core.version.Path.exists")
@patch("crewai_core.version.request.urlopen")
def test_get_latest_version_from_pypi_success(
self, mock_urlopen: MagicMock, mock_exists: MagicMock
) -> None:
@@ -82,8 +84,8 @@ class TestVersionChecking:
version = get_latest_version_from_pypi()
assert version == "2.0.0"
@patch("crewai_cli.version.Path.exists")
@patch("crewai_cli.version.request.urlopen")
@patch("crewai_core.version.Path.exists")
@patch("crewai_core.version.request.urlopen")
def test_get_latest_version_from_pypi_failure(
self, mock_urlopen: MagicMock, mock_exists: MagicMock
) -> None:
@@ -97,8 +99,8 @@ class TestVersionChecking:
version = get_latest_version_from_pypi()
assert version is None
@patch("crewai_cli.version.get_crewai_version")
@patch("crewai_cli.version.get_latest_version_from_pypi")
@patch("crewai_core.version.get_crewai_version")
@patch("crewai_core.version.get_latest_version_from_pypi")
def test_is_newer_version_available_true(
self, mock_latest: MagicMock, mock_current: MagicMock
) -> None:
@@ -111,8 +113,8 @@ class TestVersionChecking:
assert current == "1.0.0"
assert latest == "2.0.0"
@patch("crewai_cli.version.get_crewai_version")
@patch("crewai_cli.version.get_latest_version_from_pypi")
@patch("crewai_core.version.get_crewai_version")
@patch("crewai_core.version.get_latest_version_from_pypi")
def test_is_newer_version_available_false(
self, mock_latest: MagicMock, mock_current: MagicMock
) -> None:
@@ -125,8 +127,8 @@ class TestVersionChecking:
assert current == "2.0.0"
assert latest == "2.0.0"
@patch("crewai_cli.version.get_crewai_version")
@patch("crewai_cli.version.get_latest_version_from_pypi")
@patch("crewai_core.version.get_crewai_version")
@patch("crewai_core.version.get_latest_version_from_pypi")
def test_is_newer_version_available_with_none_latest(
self, mock_latest: MagicMock, mock_current: MagicMock
) -> None:
@@ -260,8 +262,8 @@ class TestIsVersionYanked:
class TestIsCurrentVersionYanked:
"""Test is_current_version_yanked public function."""
@patch("crewai_cli.version.get_crewai_version")
@patch("crewai_cli.version._get_cache_file")
@patch("crewai_core.version.get_crewai_version")
@patch("crewai_core.version._get_cache_file")
def test_reads_from_valid_cache(
self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path
) -> None:
@@ -282,8 +284,8 @@ class TestIsCurrentVersionYanked:
assert is_yanked is True
assert reason == "bad release"
@patch("crewai_cli.version.get_crewai_version")
@patch("crewai_cli.version._get_cache_file")
@patch("crewai_core.version.get_crewai_version")
@patch("crewai_core.version._get_cache_file")
def test_not_yanked_from_cache(
self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path
) -> None:
@@ -304,9 +306,9 @@ class TestIsCurrentVersionYanked:
assert is_yanked is False
assert reason == ""
@patch("crewai_cli.version.get_latest_version_from_pypi")
@patch("crewai_cli.version.get_crewai_version")
@patch("crewai_cli.version._get_cache_file")
@patch("crewai_core.version.get_latest_version_from_pypi")
@patch("crewai_core.version.get_crewai_version")
@patch("crewai_core.version._get_cache_file")
def test_triggers_fetch_on_stale_cache(
self,
mock_cache_file: MagicMock,
@@ -346,9 +348,9 @@ class TestIsCurrentVersionYanked:
assert is_yanked is False
mock_fetch.assert_called_once()
@patch("crewai_cli.version.get_latest_version_from_pypi")
@patch("crewai_cli.version.get_crewai_version")
@patch("crewai_cli.version._get_cache_file")
@patch("crewai_core.version.get_latest_version_from_pypi")
@patch("crewai_core.version.get_crewai_version")
@patch("crewai_core.version._get_cache_file")
def test_returns_false_on_fetch_failure(
self,
mock_cache_file: MagicMock,

View File

@@ -10,7 +10,10 @@ requires-python = ">=3.10, <3.14"
dependencies = [
"appdirs~=1.4.4",
"cryptography>=42.0",
"httpx~=0.28.1",
"packaging>=23.0",
"portalocker~=2.7.0",
"pyjwt>=2.9.0,<3",
"pydantic>=2.11.9,<2.13",
"rich>=13.7.1",
"opentelemetry-api~=1.34.0",

View File

@@ -0,0 +1,24 @@
"""OAuth2 authentication primitives — shared by crewai and crewai-cli."""
from __future__ import annotations
from crewai_core.auth.oauth2 import (
AuthenticationCommand as AuthenticationCommand,
Oauth2Settings as Oauth2Settings,
ProviderFactory as ProviderFactory,
)
from crewai_core.auth.token import (
AuthError as AuthError,
get_auth_token as get_auth_token,
)
from crewai_core.auth.utils import validate_jwt_token as validate_jwt_token
__all__ = [
"AuthError",
"AuthenticationCommand",
"Oauth2Settings",
"ProviderFactory",
"get_auth_token",
"validate_jwt_token",
]

View File

@@ -0,0 +1,8 @@
"""Authentication constants."""
from __future__ import annotations
from typing import Final
ALGORITHMS: Final[list[str]] = ["RS256"]

View File

@@ -0,0 +1,186 @@
"""OAuth2 device-flow authentication for the CrewAI platform."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING, Any, TypeVar, cast
import webbrowser
import httpx
from pydantic import BaseModel, Field
from rich.console import Console
from crewai_core.auth.utils import validate_jwt_token
from crewai_core.settings import Settings
from crewai_core.token_manager import TokenManager
console = Console()
TOauth2Settings = TypeVar("TOauth2Settings", bound="Oauth2Settings")
class Oauth2Settings(BaseModel):
"""OAuth2 provider configuration."""
provider: str = Field(
description="OAuth2 provider used for authentication (e.g., workos, okta, auth0)."
)
client_id: str = Field(
description="OAuth2 client ID issued by the provider, used during authentication requests."
)
domain: str = Field(
description="OAuth2 provider's domain (e.g., your-org.auth0.com) used for issuing tokens."
)
audience: str | None = Field(
description="OAuth2 audience value, typically used to identify the target API or resource.",
default=None,
)
extra: dict[str, Any] = Field(
description="Extra configuration for the OAuth2 provider.",
default={},
)
@classmethod
def from_settings(cls: type[TOauth2Settings]) -> TOauth2Settings:
"""Build an ``Oauth2Settings`` instance from the persisted CrewAI settings."""
settings = Settings()
return cls(
provider=settings.oauth2_provider,
domain=settings.oauth2_domain,
client_id=settings.oauth2_client_id,
audience=settings.oauth2_audience,
extra=settings.oauth2_extra,
)
if TYPE_CHECKING:
from crewai_core.auth.providers.base_provider import BaseProvider
class ProviderFactory:
"""Factory for resolving the configured OAuth2 provider."""
@classmethod
def from_settings(
cls: type["ProviderFactory"], # noqa: UP037
settings: Oauth2Settings | None = None,
) -> "BaseProvider": # noqa: UP037
"""Create a provider instance from settings, importing the module dynamically."""
settings = settings or Oauth2Settings.from_settings()
import importlib
module = importlib.import_module(
f"crewai_core.auth.providers.{settings.provider.lower()}"
)
provider = getattr(
module,
f"{''.join(word.capitalize() for word in settings.provider.split('_'))}Provider",
)
return cast("BaseProvider", provider(settings))
class AuthenticationCommand:
"""Drives the OAuth2 device-flow login against the configured provider."""
def __init__(self) -> None:
self.token_manager = TokenManager()
self.oauth2_provider = ProviderFactory.from_settings()
def login(self) -> None:
"""Sign in to the CrewAI platform via the OAuth2 device flow."""
console.print("Signing in to CrewAI AMP...\n", style="bold blue")
device_code_data = self._get_device_code()
self._display_auth_instructions(device_code_data)
return self._poll_for_token(device_code_data)
def _get_device_code(self) -> dict[str, Any]:
"""Request a device code from the provider."""
device_code_payload = {
"client_id": self.oauth2_provider.get_client_id(),
"scope": " ".join(self.oauth2_provider.get_oauth_scopes()),
"audience": self.oauth2_provider.get_audience(),
}
response = httpx.post(
url=self.oauth2_provider.get_authorize_url(),
data=device_code_payload,
timeout=20,
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
def _display_auth_instructions(self, device_code_data: dict[str, str]) -> None:
"""Print and open the verification URL the user must visit."""
verification_uri = device_code_data.get(
"verification_uri_complete", device_code_data.get("verification_uri", "")
)
console.print("1. Navigate to: ", verification_uri)
console.print("2. Enter the following code: ", device_code_data["user_code"])
webbrowser.open(verification_uri)
def _poll_for_token(self, device_code_data: dict[str, Any]) -> None:
"""Poll the token endpoint until authentication completes or times out."""
token_payload = {
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_code_data["device_code"],
"client_id": self.oauth2_provider.get_client_id(),
}
console.print("\nWaiting for authentication... ", style="bold blue", end="")
attempts = 0
while True and attempts < 10:
response = httpx.post(
self.oauth2_provider.get_token_url(), data=token_payload, timeout=30
)
token_data = response.json()
if response.status_code == 200:
self._validate_and_save_token(token_data)
console.print(
"Success!",
style="bold green",
)
self._post_login()
console.print("\n[bold green]Welcome to CrewAI AMP![/bold green]\n")
return
if token_data["error"] not in ("authorization_pending", "slow_down"):
raise httpx.HTTPError(
token_data.get("error_description") or token_data.get("error")
)
time.sleep(device_code_data["interval"])
attempts += 1
console.print(
"Timeout: Failed to get the token. Please try again.", style="bold red"
)
def _validate_and_save_token(self, token_data: dict[str, Any]) -> None:
"""Validate the JWT and persist it via the token manager."""
jwt_token = token_data["access_token"]
issuer = self.oauth2_provider.get_issuer()
jwt_token_data = {
"jwt_token": jwt_token,
"jwks_url": self.oauth2_provider.get_jwks_url(),
"issuer": issuer,
"audience": self.oauth2_provider.get_audience(),
}
decoded_token = validate_jwt_token(**jwt_token_data)
expires_at = decoded_token.get("exp", 0)
self.token_manager.save_tokens(jwt_token, expires_at)
def _post_login(self) -> None:
"""Hook called after a successful login. Override to extend behavior."""

View File

@@ -0,0 +1 @@
"""OAuth2 authentication providers."""

View File

@@ -0,0 +1,40 @@
"""Auth0 OAuth2 provider."""
from __future__ import annotations
from crewai_core.auth.providers.base_provider import BaseProvider
class Auth0Provider(BaseProvider):
"""Auth0 OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"https://{self._get_domain()}/oauth/device/code"
def get_token_url(self) -> str:
return f"https://{self._get_domain()}/oauth/token"
def get_jwks_url(self) -> str:
return f"https://{self._get_domain()}/.well-known/jwks.json"
def get_issuer(self) -> str:
return f"https://{self._get_domain()}/"
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def _get_domain(self) -> str:
if self.settings.domain is None:
raise ValueError("Domain is required. Please set it in the configuration.")
return self.settings.domain

View File

@@ -0,0 +1,46 @@
"""Base OAuth2 provider interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from crewai_core.auth.oauth2 import Oauth2Settings
class BaseProvider(ABC):
"""Abstract base class for OAuth2 providers."""
def __init__(self, settings: Oauth2Settings):
self.settings = settings
@abstractmethod
def get_authorize_url(self) -> str:
"""Return the authorization endpoint URL."""
@abstractmethod
def get_token_url(self) -> str:
"""Return the token endpoint URL."""
@abstractmethod
def get_jwks_url(self) -> str:
"""Return the JWKS endpoint URL."""
@abstractmethod
def get_issuer(self) -> str:
"""Return the OAuth issuer identifier."""
@abstractmethod
def get_audience(self) -> str:
"""Return the OAuth audience identifier."""
@abstractmethod
def get_client_id(self) -> str:
"""Return the OAuth client identifier."""
def get_required_fields(self) -> list[str]:
"""Return provider-specific keys required inside ``Oauth2Settings.extra``."""
return []
def get_oauth_scopes(self) -> list[str]:
"""Return the OAuth scopes to request."""
return ["openid", "profile", "email"]

View File

@@ -0,0 +1,49 @@
"""Entra ID (Azure AD) OAuth2 provider."""
from __future__ import annotations
from typing import cast
from crewai_core.auth.providers.base_provider import BaseProvider
class EntraIdProvider(BaseProvider):
"""Entra ID (Azure AD) OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"{self._base_url()}/oauth2/v2.0/devicecode"
def get_token_url(self) -> str:
return f"{self._base_url()}/oauth2/v2.0/token"
def get_jwks_url(self) -> str:
return f"{self._base_url()}/discovery/v2.0/keys"
def get_issuer(self) -> str:
return f"{self._base_url()}/v2.0"
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_oauth_scopes(self) -> list[str]:
return [
*super().get_oauth_scopes(),
*cast(str, self.settings.extra.get("scope", "")).split(),
]
def get_required_fields(self) -> list[str]:
return ["scope"]
def _base_url(self) -> str:
return f"https://login.microsoftonline.com/{self.settings.domain}"

View File

@@ -0,0 +1,38 @@
"""Keycloak OAuth2 provider."""
from __future__ import annotations
from crewai_core.auth.providers.base_provider import BaseProvider
class KeycloakProvider(BaseProvider):
"""Keycloak OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/auth/device"
def get_token_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/token"
def get_jwks_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/certs"
def get_issuer(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}"
def get_audience(self) -> str:
return self.settings.audience or "no-audience-provided"
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_required_fields(self) -> list[str]:
return ["realm"]
def _oauth2_base_url(self) -> str:
domain = self.settings.domain.removeprefix("https://").removeprefix("http://")
return f"https://{domain}"

View File

@@ -0,0 +1,48 @@
"""Okta OAuth2 provider."""
from __future__ import annotations
from crewai_core.auth.providers.base_provider import BaseProvider
class OktaProvider(BaseProvider):
"""Okta OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/device/authorize"
def get_token_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/token"
def get_jwks_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/keys"
def get_issuer(self) -> str:
return self._oauth2_base_url().removesuffix("/oauth2")
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_required_fields(self) -> list[str]:
return ["authorization_server_name", "using_org_auth_server"]
def _oauth2_base_url(self) -> str:
using_org_auth_server = self.settings.extra.get("using_org_auth_server", False)
if using_org_auth_server:
base_url = f"https://{self.settings.domain}/oauth2"
else:
base_url = f"https://{self.settings.domain}/oauth2/{self.settings.extra.get('authorization_server_name', 'default')}"
return f"{base_url}"

View File

@@ -0,0 +1,36 @@
"""WorkOS OAuth2 provider."""
from __future__ import annotations
from crewai_core.auth.providers.base_provider import BaseProvider
class WorkosProvider(BaseProvider):
"""WorkOS OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/device_authorization"
def get_token_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/token"
def get_jwks_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/jwks"
def get_issuer(self) -> str:
return f"https://{self._get_domain()}"
def get_audience(self) -> str:
return self.settings.audience or ""
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def _get_domain(self) -> str:
if self.settings.domain is None:
raise ValueError("Domain is required. Please set it in the configuration.")
return self.settings.domain

View File

@@ -0,0 +1,17 @@
"""Authentication token retrieval."""
from __future__ import annotations
from crewai_core.token_manager import TokenManager
class AuthError(Exception):
"""Raised when authentication fails."""
def get_auth_token() -> str:
"""Return the saved authentication token; raise ``AuthError`` if missing."""
access_token = TokenManager().get_token()
if not access_token:
raise AuthError("No token found, make sure you are logged in")
return access_token

View File

@@ -0,0 +1,71 @@
"""JWT token validation utilities."""
from __future__ import annotations
from typing import Any
import jwt
from jwt import PyJWKClient
from crewai_core.auth.constants import ALGORITHMS
def validate_jwt_token(
jwt_token: str, jwks_url: str, issuer: str, audience: str
) -> Any:
"""Verify a JWT's signature and claims using PyJWT.
Args:
jwt_token: The JWT (JWS) string to validate.
jwks_url: The URL of the JWKS endpoint.
issuer: The expected issuer of the token.
audience: The expected audience of the token.
Returns:
The decoded token.
Raises:
Exception: If the token is invalid for any reason.
"""
try:
jwk_client = PyJWKClient(jwks_url)
signing_key = jwk_client.get_signing_key_from_jwt(jwt_token)
_unverified_decoded_token = jwt.decode(
jwt_token, options={"verify_signature": False}
)
return jwt.decode(
jwt_token,
signing_key.key,
algorithms=ALGORITHMS,
audience=audience,
issuer=issuer,
leeway=10.0,
options={
"verify_signature": True,
"verify_exp": True,
"verify_nbf": True,
"verify_iat": True,
"require": ["exp", "iat", "iss", "aud", "sub"],
},
)
except jwt.ExpiredSignatureError as e:
raise Exception("Token has expired.") from e
except jwt.InvalidAudienceError as e:
actual_audience = _unverified_decoded_token.get("aud", "[no audience found]")
raise Exception(
f"Invalid token audience. Got: '{actual_audience}'. Expected: '{audience}'"
) from e
except jwt.InvalidIssuerError as e:
actual_issuer = _unverified_decoded_token.get("iss", "[no issuer found]")
raise Exception(
f"Invalid token issuer. Got: '{actual_issuer}'. Expected: '{issuer}'"
) from e
except jwt.MissingRequiredClaimError as e:
raise Exception(f"Token is missing required claims: {e!s}") from e
except jwt.exceptions.PyJWKClientError as e:
raise Exception(f"JWKS or key processing error: {e!s}") from e
except jwt.InvalidTokenError as e:
raise Exception(f"Invalid token: {e!s}") from e

View File

@@ -44,7 +44,7 @@ def _redis_available() -> bool:
@lru_cache(maxsize=1)
def _redis_connection() -> redis.Redis:
def _redis_connection() -> redis.Redis[bytes]:
"""Return a cached Redis connection, creating one on first call."""
from redis import Redis

View File

@@ -0,0 +1,232 @@
"""CrewAI+ API client — shared by both crewai and crewai-cli."""
from __future__ import annotations
import os
from typing import Any
from urllib.parse import urljoin
import httpx
from crewai_core.constants import DEFAULT_CREWAI_ENTERPRISE_URL
from crewai_core.settings import Settings
from crewai_core.version import get_crewai_version
class PlusAPI:
"""Client for working with the CrewAI+ API."""
TOOLS_RESOURCE = "/crewai_plus/api/v1/tools"
ORGANIZATIONS_RESOURCE = "/crewai_plus/api/v1/me/organizations"
CREWS_RESOURCE = "/crewai_plus/api/v1/crews"
AGENTS_RESOURCE = "/crewai_plus/api/v1/agents"
TRACING_RESOURCE = "/crewai_plus/api/v1/tracing"
EPHEMERAL_TRACING_RESOURCE = "/crewai_plus/api/v1/tracing/ephemeral"
INTEGRATIONS_RESOURCE = "/crewai_plus/api/v1/integrations"
def __init__(self, api_key: str | None = None) -> None:
self.api_key = api_key
self.headers = {
"Content-Type": "application/json",
"User-Agent": f"CrewAI-CLI/{get_crewai_version()}",
"X-Crewai-Version": get_crewai_version(),
}
if api_key:
self.headers["Authorization"] = f"Bearer {api_key}"
settings = Settings()
if settings.org_uuid:
self.headers["X-Crewai-Organization-Id"] = settings.org_uuid
self.base_url = (
os.getenv("CREWAI_PLUS_URL")
or str(settings.enterprise_base_url)
or DEFAULT_CREWAI_ENTERPRISE_URL
)
def _make_request(
self, method: str, endpoint: str, **kwargs: Any
) -> httpx.Response:
url = urljoin(self.base_url, endpoint)
verify = kwargs.pop("verify", True)
with httpx.Client(trust_env=False, verify=verify) as client:
return client.request(method, url, headers=self.headers, **kwargs)
def login_to_tool_repository(
self, user_identifier: str | None = None
) -> httpx.Response:
payload = {}
if user_identifier:
payload["user_identifier"] = user_identifier
return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login", json=payload)
def get_tool(self, handle: str) -> httpx.Response:
return self._make_request("GET", f"{self.TOOLS_RESOURCE}/{handle}")
async def get_agent(self, handle: str) -> httpx.Response:
url = urljoin(self.base_url, f"{self.AGENTS_RESOURCE}/{handle}")
async with httpx.AsyncClient() as client:
return await client.get(url, headers=self.headers)
def publish_tool(
self,
handle: str,
is_public: bool,
version: str,
description: str | None,
encoded_file: str,
available_exports: list[dict[str, Any]] | None = None,
tools_metadata: list[dict[str, Any]] | None = None,
) -> httpx.Response:
params = {
"handle": handle,
"public": is_public,
"version": version,
"file": encoded_file,
"description": description,
"available_exports": available_exports,
"tools_metadata": {"package": handle, "tools": tools_metadata}
if tools_metadata is not None
else None,
}
return self._make_request("POST", f"{self.TOOLS_RESOURCE}", json=params)
def deploy_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"POST", f"{self.CREWS_RESOURCE}/by-name/{project_name}/deploy"
)
def deploy_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("POST", f"{self.CREWS_RESOURCE}/{uuid}/deploy")
def crew_status_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/by-name/{project_name}/status"
)
def crew_status_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("GET", f"{self.CREWS_RESOURCE}/{uuid}/status")
def crew_by_name(
self, project_name: str, log_type: str = "deployment"
) -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/by-name/{project_name}/logs/{log_type}"
)
def crew_by_uuid(self, uuid: str, log_type: str = "deployment") -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/{uuid}/logs/{log_type}"
)
def delete_crew_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"DELETE", f"{self.CREWS_RESOURCE}/by-name/{project_name}"
)
def delete_crew_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("DELETE", f"{self.CREWS_RESOURCE}/{uuid}")
def list_crews(self) -> httpx.Response:
return self._make_request("GET", self.CREWS_RESOURCE)
def create_crew(self, payload: dict[str, Any]) -> httpx.Response:
return self._make_request("POST", self.CREWS_RESOURCE, json=payload)
def get_organizations(self) -> httpx.Response:
return self._make_request("GET", self.ORGANIZATIONS_RESOURCE)
def initialize_trace_batch(self, payload: dict[str, Any]) -> httpx.Response:
return self._make_request(
"POST",
f"{self.TRACING_RESOURCE}/batches",
json=payload,
timeout=30,
)
def initialize_ephemeral_trace_batch(
self, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches",
json=payload,
)
def send_trace_events(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/events",
json=payload,
timeout=30,
)
def send_ephemeral_trace_events(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}/events",
json=payload,
timeout=30,
)
def finalize_trace_batch(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/finalize",
json=payload,
timeout=30,
)
def finalize_ephemeral_trace_batch(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}/finalize",
json=payload,
timeout=30,
)
def mark_trace_batch_as_failed(
self, trace_batch_id: str, error_message: str
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}",
json={"status": "failed", "failure_reason": error_message},
timeout=30,
)
def mark_ephemeral_trace_batch_as_failed(
self, trace_batch_id: str, error_message: str
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}",
json={"status": "failed", "failure_reason": error_message},
timeout=30,
)
def get_mcp_configs(self, slugs: list[str]) -> httpx.Response:
"""Get MCP server configurations for the given slugs."""
return self._make_request(
"GET",
f"{self.INTEGRATIONS_RESOURCE}/mcp_configs",
params={"slugs": ",".join(slugs)},
timeout=30,
)
def get_triggers(self) -> httpx.Response:
"""Get all available triggers from integrations."""
return self._make_request("GET", f"{self.INTEGRATIONS_RESOURCE}/apps")
def get_trigger_payload(self, app_slug: str, trigger_slug: str) -> httpx.Response:
"""Get sample payload for a specific trigger."""
return self._make_request(
"GET", f"{self.INTEGRATIONS_RESOURCE}/{app_slug}/{trigger_slug}/payload"
)

View File

@@ -1,9 +1,24 @@
"""Version utilities for CrewAI."""
"""Version utilities — installed version + PyPI freshness/yank checks.
Shared by both ``crewai`` and ``crewai-cli`` so the PyPI-checking logic lives
in one place. Frontends (``crewai version`` CLI, banner printer) consume the
helpers here without re-implementing them.
"""
from __future__ import annotations
from functools import cache
from collections.abc import Mapping
from datetime import datetime, timedelta
from functools import cache, lru_cache
import importlib.metadata
import json
from pathlib import Path
from typing import Any
from urllib import request
from urllib.error import URLError
import appdirs
from packaging.version import InvalidVersion, Version, parse
@cache
@@ -21,3 +36,159 @@ def get_crewai_version() -> str:
return importlib.metadata.version("crewai-core")
except importlib.metadata.PackageNotFoundError:
return "unknown"
@lru_cache(maxsize=1)
def _get_cache_file() -> Path:
"""Return the path to the version cache file, creating the dir if needed."""
cache_dir = Path(appdirs.user_cache_dir("crewai"))
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir / "version_cache.json"
def _is_cache_valid(cache_data: Mapping[str, Any]) -> bool:
"""Return True if the cache is less than 24 hours old."""
if "timestamp" not in cache_data:
return False
try:
cache_time = datetime.fromisoformat(str(cache_data["timestamp"]))
return datetime.now() - cache_time < timedelta(hours=24)
except (ValueError, TypeError):
return False
def _find_latest_non_yanked_version(
releases: Mapping[str, list[dict[str, Any]]],
) -> str | None:
"""Return the latest non-prerelease, non-yanked version from PyPI releases."""
best_version: Version | None = None
best_version_str: str | None = None
for version_str, files in releases.items():
try:
v = parse(version_str)
except InvalidVersion:
continue
if v.is_prerelease or v.is_devrelease:
continue
if not files:
continue
all_yanked = all(f.get("yanked", False) for f in files)
if all_yanked:
continue
if best_version is None or v > best_version:
best_version = v
best_version_str = version_str
return best_version_str
def _is_version_yanked(
version_str: str,
releases: Mapping[str, list[dict[str, Any]]],
) -> tuple[bool, str]:
"""Return ``(yanked, reason)`` for ``version_str`` against PyPI releases."""
files = releases.get(version_str, [])
if not files:
return False, ""
all_yanked = all(f.get("yanked", False) for f in files)
if not all_yanked:
return False, ""
for f in files:
reason = f.get("yanked_reason", "")
if reason:
return True, str(reason)
return True, ""
def get_latest_version_from_pypi(timeout: int = 2) -> str | None:
"""Return the latest non-yanked PyPI version of CrewAI, or ``None`` on failure."""
cache_file = _get_cache_file()
if cache_file.exists():
try:
cache_data = json.loads(cache_file.read_text())
if _is_cache_valid(cache_data) and "current_version" in cache_data:
version: str | None = cache_data.get("version")
return version
except (json.JSONDecodeError, OSError):
pass
try:
with request.urlopen(
"https://pypi.org/pypi/crewai/json", timeout=timeout
) as response:
data = json.loads(response.read())
releases: dict[str, list[dict[str, Any]]] = data["releases"]
latest_version = _find_latest_non_yanked_version(releases)
current_version = get_crewai_version()
is_yanked, yanked_reason = _is_version_yanked(current_version, releases)
cache_data = {
"version": latest_version,
"timestamp": datetime.now().isoformat(),
"current_version": current_version,
"current_version_yanked": is_yanked,
"current_version_yanked_reason": yanked_reason,
}
cache_file.write_text(json.dumps(cache_data))
return latest_version
except (URLError, json.JSONDecodeError, KeyError, OSError):
return None
def is_current_version_yanked() -> tuple[bool, str]:
"""Return ``(yanked, reason)`` for the currently installed version."""
cache_file = _get_cache_file()
if cache_file.exists():
try:
cache_data = json.loads(cache_file.read_text())
if _is_cache_valid(cache_data) and "current_version" in cache_data:
current = get_crewai_version()
if cache_data.get("current_version") == current:
return (
bool(cache_data.get("current_version_yanked", False)),
str(cache_data.get("current_version_yanked_reason", "")),
)
except (json.JSONDecodeError, OSError):
pass
get_latest_version_from_pypi()
try:
cache_data = json.loads(cache_file.read_text())
return (
bool(cache_data.get("current_version_yanked", False)),
str(cache_data.get("current_version_yanked_reason", "")),
)
except (json.JSONDecodeError, OSError):
return False, ""
def check_version() -> tuple[str, str | None]:
"""Return ``(current_version, latest_version)``; latest is ``None`` on fetch failure."""
current = get_crewai_version()
latest = get_latest_version_from_pypi()
return current, latest
def is_newer_version_available() -> tuple[bool, str, str | None]:
"""Return ``(is_newer, current_version, latest_version)``."""
current, latest = check_version()
if latest is None:
return False, current, None
try:
return parse(latest) > parse(current), current, latest
except (InvalidVersion, TypeError):
return False, current, latest

View File

@@ -1,7 +1,22 @@
"""Authentication utilities for the CrewAI platform."""
"""Authentication utilities — re-exported from ``crewai_core.auth``."""
from crewai.auth.oauth2 import AuthenticationCommand
from crewai.auth.token import AuthError, get_auth_token
from __future__ import annotations
from crewai_core.auth import (
AuthError as AuthError,
AuthenticationCommand as AuthenticationCommand,
Oauth2Settings as Oauth2Settings,
ProviderFactory as ProviderFactory,
get_auth_token as get_auth_token,
validate_jwt_token as validate_jwt_token,
)
__all__ = ["AuthError", "AuthenticationCommand", "get_auth_token"]
__all__ = [
"AuthError",
"AuthenticationCommand",
"Oauth2Settings",
"ProviderFactory",
"get_auth_token",
"validate_jwt_token",
]

View File

@@ -1,3 +1,8 @@
"""Authentication constants."""
"""Re-export of authentication constants from ``crewai_core.auth.constants``."""
ALGORITHMS = ["RS256"]
from __future__ import annotations
from crewai_core.auth.constants import ALGORITHMS as ALGORITHMS
__all__ = ["ALGORITHMS"]

View File

@@ -1,184 +1,12 @@
"""OAuth2 authentication for the CrewAI platform."""
"""Re-exports of OAuth2 primitives from ``crewai_core.auth.oauth2``."""
import time
from typing import TYPE_CHECKING, Any, TypeVar, cast
import webbrowser
from __future__ import annotations
from crewai_core.settings import Settings
from crewai_core.token_manager import TokenManager
import httpx
from pydantic import BaseModel, Field
from rich.console import Console
from crewai.auth.utils import validate_jwt_token
from crewai_core.auth.oauth2 import (
AuthenticationCommand as AuthenticationCommand,
Oauth2Settings as Oauth2Settings,
ProviderFactory as ProviderFactory,
)
console = Console()
TOauth2Settings = TypeVar("TOauth2Settings", bound="Oauth2Settings")
class Oauth2Settings(BaseModel):
"""OAuth2 provider configuration."""
provider: str = Field(
description="OAuth2 provider used for authentication (e.g., workos, okta, auth0)."
)
client_id: str = Field(
description="OAuth2 client ID issued by the provider, used during authentication requests."
)
domain: str = Field(
description="OAuth2 provider's domain (e.g., your-org.auth0.com) used for issuing tokens."
)
audience: str | None = Field(
description="OAuth2 audience value, typically used to identify the target API or resource.",
default=None,
)
extra: dict[str, Any] = Field(
description="Extra configuration for the OAuth2 provider.",
default={},
)
@classmethod
def from_settings(cls: type[TOauth2Settings]) -> TOauth2Settings:
"""Create an Oauth2Settings instance from the CLI settings."""
settings = Settings()
return cls(
provider=settings.oauth2_provider,
domain=settings.oauth2_domain,
client_id=settings.oauth2_client_id,
audience=settings.oauth2_audience,
extra=settings.oauth2_extra,
)
if TYPE_CHECKING:
from crewai.auth.providers.base_provider import BaseProvider
class ProviderFactory:
"""Factory for creating OAuth2 providers from settings."""
@classmethod
def from_settings(
cls: type["ProviderFactory"], # noqa: UP037
settings: Oauth2Settings | None = None,
) -> "BaseProvider": # noqa: UP037
"""Create a provider instance from settings."""
settings = settings or Oauth2Settings.from_settings()
import importlib
module = importlib.import_module(
f"crewai.auth.providers.{settings.provider.lower()}"
)
provider = getattr(
module,
f"{''.join(word.capitalize() for word in settings.provider.split('_'))}Provider",
)
return cast("BaseProvider", provider(settings))
class AuthenticationCommand:
"""Handles authentication with the CrewAI platform."""
def __init__(self) -> None:
self.token_manager = TokenManager()
self.oauth2_provider = ProviderFactory.from_settings()
def login(self) -> None:
"""Sign up to CrewAI+"""
console.print("Signing in to CrewAI AMP...\n", style="bold blue")
device_code_data = self._get_device_code()
self._display_auth_instructions(device_code_data)
return self._poll_for_token(device_code_data)
def _get_device_code(self) -> dict[str, Any]:
"""Get the device code to authenticate the user."""
device_code_payload = {
"client_id": self.oauth2_provider.get_client_id(),
"scope": " ".join(self.oauth2_provider.get_oauth_scopes()),
"audience": self.oauth2_provider.get_audience(),
}
response = httpx.post(
url=self.oauth2_provider.get_authorize_url(),
data=device_code_payload,
timeout=20,
)
response.raise_for_status()
return cast(dict[str, Any], response.json())
def _display_auth_instructions(self, device_code_data: dict[str, str]) -> None:
"""Display the authentication instructions to the user."""
verification_uri = device_code_data.get(
"verification_uri_complete", device_code_data.get("verification_uri", "")
)
console.print("1. Navigate to: ", verification_uri)
console.print("2. Enter the following code: ", device_code_data["user_code"])
webbrowser.open(verification_uri)
def _poll_for_token(self, device_code_data: dict[str, Any]) -> None:
"""Polls the server for the token until it is received, or max attempts are reached."""
token_payload = {
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
"device_code": device_code_data["device_code"],
"client_id": self.oauth2_provider.get_client_id(),
}
console.print("\nWaiting for authentication... ", style="bold blue", end="")
attempts = 0
while True and attempts < 10:
response = httpx.post(
self.oauth2_provider.get_token_url(), data=token_payload, timeout=30
)
token_data = response.json()
if response.status_code == 200:
self._validate_and_save_token(token_data)
console.print(
"Success!",
style="bold green",
)
self._post_login()
console.print("\n[bold green]Welcome to CrewAI AMP![/bold green]\n")
return
if token_data["error"] not in ("authorization_pending", "slow_down"):
raise httpx.HTTPError(
token_data.get("error_description") or token_data.get("error")
)
time.sleep(device_code_data["interval"])
attempts += 1
console.print(
"Timeout: Failed to get the token. Please try again.", style="bold red"
)
def _validate_and_save_token(self, token_data: dict[str, Any]) -> None:
"""Validates the JWT token and saves the token to the token manager."""
jwt_token = token_data["access_token"]
issuer = self.oauth2_provider.get_issuer()
jwt_token_data = {
"jwt_token": jwt_token,
"jwks_url": self.oauth2_provider.get_jwks_url(),
"issuer": issuer,
"audience": self.oauth2_provider.get_audience(),
}
decoded_token = validate_jwt_token(**jwt_token_data)
expires_at = decoded_token.get("exp", 0)
self.token_manager.save_tokens(jwt_token, expires_at)
def _post_login(self) -> None:
"""Hook called after successful login. Override in subclasses for additional behavior."""
__all__ = ["AuthenticationCommand", "Oauth2Settings", "ProviderFactory"]

View File

@@ -1 +1 @@
"""OAuth2 authentication providers."""
"""OAuth2 authentication providers — re-exported from ``crewai_core.auth.providers``."""

View File

@@ -1,38 +1,8 @@
"""Auth0 OAuth2 provider."""
"""Re-export of ``Auth0Provider`` from ``crewai_core.auth.providers.auth0``."""
from crewai.auth.providers.base_provider import BaseProvider
from __future__ import annotations
from crewai_core.auth.providers.auth0 import Auth0Provider as Auth0Provider
class Auth0Provider(BaseProvider):
"""Auth0 OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"https://{self._get_domain()}/oauth/device/code"
def get_token_url(self) -> str:
return f"https://{self._get_domain()}/oauth/token"
def get_jwks_url(self) -> str:
return f"https://{self._get_domain()}/.well-known/jwks.json"
def get_issuer(self) -> str:
return f"https://{self._get_domain()}/"
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def _get_domain(self) -> str:
if self.settings.domain is None:
raise ValueError("Domain is required. Please set it in the configuration.")
return self.settings.domain
__all__ = ["Auth0Provider"]

View File

@@ -1,44 +1,8 @@
"""Base OAuth2 provider interface."""
"""Re-export of ``BaseProvider`` from ``crewai_core.auth.providers.base_provider``."""
from abc import ABC, abstractmethod
from __future__ import annotations
from crewai.auth.oauth2 import Oauth2Settings
from crewai_core.auth.providers.base_provider import BaseProvider as BaseProvider
class BaseProvider(ABC):
"""Abstract base class for OAuth2 providers."""
def __init__(self, settings: Oauth2Settings):
self.settings = settings
@abstractmethod
def get_authorize_url(self) -> str:
"""Return the authorization endpoint URL."""
@abstractmethod
def get_token_url(self) -> str:
"""Return the token endpoint URL."""
@abstractmethod
def get_jwks_url(self) -> str:
"""Return the JWKS endpoint URL."""
@abstractmethod
def get_issuer(self) -> str:
"""Return the OAuth issuer identifier."""
@abstractmethod
def get_audience(self) -> str:
"""Return the OAuth audience identifier."""
@abstractmethod
def get_client_id(self) -> str:
"""Return the OAuth client identifier."""
def get_required_fields(self) -> list[str]:
"""Returns which provider-specific fields inside the "extra" dict will be required."""
return []
def get_oauth_scopes(self) -> list[str]:
"""Returns the OAuth scopes to request."""
return ["openid", "profile", "email"]
__all__ = ["BaseProvider"]

View File

@@ -1,47 +1,8 @@
"""Entra ID (Azure AD) OAuth2 provider."""
"""Re-export of ``EntraIdProvider`` from ``crewai_core.auth.providers.entra_id``."""
from typing import cast
from __future__ import annotations
from crewai.auth.providers.base_provider import BaseProvider
from crewai_core.auth.providers.entra_id import EntraIdProvider as EntraIdProvider
class EntraIdProvider(BaseProvider):
"""Entra ID (Azure AD) OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"{self._base_url()}/oauth2/v2.0/devicecode"
def get_token_url(self) -> str:
return f"{self._base_url()}/oauth2/v2.0/token"
def get_jwks_url(self) -> str:
return f"{self._base_url()}/discovery/v2.0/keys"
def get_issuer(self) -> str:
return f"{self._base_url()}/v2.0"
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_oauth_scopes(self) -> list[str]:
return [
*super().get_oauth_scopes(),
*cast(str, self.settings.extra.get("scope", "")).split(),
]
def get_required_fields(self) -> list[str]:
return ["scope"]
def _base_url(self) -> str:
return f"https://login.microsoftonline.com/{self.settings.domain}"
__all__ = ["EntraIdProvider"]

View File

@@ -1,36 +1,8 @@
"""Keycloak OAuth2 provider."""
"""Re-export of ``KeycloakProvider`` from ``crewai_core.auth.providers.keycloak``."""
from crewai.auth.providers.base_provider import BaseProvider
from __future__ import annotations
from crewai_core.auth.providers.keycloak import KeycloakProvider as KeycloakProvider
class KeycloakProvider(BaseProvider):
"""Keycloak OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/auth/device"
def get_token_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/token"
def get_jwks_url(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}/protocol/openid-connect/certs"
def get_issuer(self) -> str:
return f"{self._oauth2_base_url()}/realms/{self.settings.extra.get('realm')}"
def get_audience(self) -> str:
return self.settings.audience or "no-audience-provided"
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_required_fields(self) -> list[str]:
return ["realm"]
def _oauth2_base_url(self) -> str:
domain = self.settings.domain.removeprefix("https://").removeprefix("http://")
return f"https://{domain}"
__all__ = ["KeycloakProvider"]

View File

@@ -1,46 +1,8 @@
"""Okta OAuth2 provider."""
"""Re-export of ``OktaProvider`` from ``crewai_core.auth.providers.okta``."""
from crewai.auth.providers.base_provider import BaseProvider
from __future__ import annotations
from crewai_core.auth.providers.okta import OktaProvider as OktaProvider
class OktaProvider(BaseProvider):
"""Okta OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/device/authorize"
def get_token_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/token"
def get_jwks_url(self) -> str:
return f"{self._oauth2_base_url()}/v1/keys"
def get_issuer(self) -> str:
return self._oauth2_base_url().removesuffix("/oauth2")
def get_audience(self) -> str:
if self.settings.audience is None:
raise ValueError(
"Audience is required. Please set it in the configuration."
)
return self.settings.audience
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def get_required_fields(self) -> list[str]:
return ["authorization_server_name", "using_org_auth_server"]
def _oauth2_base_url(self) -> str:
using_org_auth_server = self.settings.extra.get("using_org_auth_server", False)
if using_org_auth_server:
base_url = f"https://{self.settings.domain}/oauth2"
else:
base_url = f"https://{self.settings.domain}/oauth2/{self.settings.extra.get('authorization_server_name', 'default')}"
return f"{base_url}"
__all__ = ["OktaProvider"]

View File

@@ -1,34 +1,8 @@
"""WorkOS OAuth2 provider."""
"""Re-export of ``WorkosProvider`` from ``crewai_core.auth.providers.workos``."""
from crewai.auth.providers.base_provider import BaseProvider
from __future__ import annotations
from crewai_core.auth.providers.workos import WorkosProvider as WorkosProvider
class WorkosProvider(BaseProvider):
"""WorkOS OAuth2 provider implementation."""
def get_authorize_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/device_authorization"
def get_token_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/token"
def get_jwks_url(self) -> str:
return f"https://{self._get_domain()}/oauth2/jwks"
def get_issuer(self) -> str:
return f"https://{self._get_domain()}"
def get_audience(self) -> str:
return self.settings.audience or ""
def get_client_id(self) -> str:
if self.settings.client_id is None:
raise ValueError(
"Client ID is required. Please set it in the configuration."
)
return self.settings.client_id
def _get_domain(self) -> str:
if self.settings.domain is None:
raise ValueError("Domain is required. Please set it in the configuration.")
return self.settings.domain
__all__ = ["WorkosProvider"]

View File

@@ -1,15 +1,11 @@
"""Authentication token retrieval."""
"""Re-exports of authentication token helpers from ``crewai_core.auth.token``."""
from crewai_core.token_manager import TokenManager
from __future__ import annotations
from crewai_core.auth.token import (
AuthError as AuthError,
get_auth_token as get_auth_token,
)
class AuthError(Exception):
"""Raised when authentication fails."""
def get_auth_token() -> str:
"""Get the authentication token."""
access_token = TokenManager().get_token()
if not access_token:
raise AuthError("No token found, make sure you are logged in")
return access_token
__all__ = ["AuthError", "get_auth_token"]

View File

@@ -1,67 +1,8 @@
"""JWT token validation utilities."""
"""Re-export of ``validate_jwt_token`` from ``crewai_core.auth.utils``."""
from typing import Any
from __future__ import annotations
import jwt
from jwt import PyJWKClient
from crewai_core.auth.utils import validate_jwt_token as validate_jwt_token
def validate_jwt_token(
jwt_token: str, jwks_url: str, issuer: str, audience: str
) -> Any:
"""Verify the token's signature and claims using PyJWT.
Args:
jwt_token: The JWT (JWS) string to validate.
jwks_url: The URL of the JWKS endpoint.
issuer: The expected issuer of the token.
audience: The expected audience of the token.
Returns:
The decoded token.
Raises:
Exception: If the token is invalid for any reason.
"""
try:
jwk_client = PyJWKClient(jwks_url)
signing_key = jwk_client.get_signing_key_from_jwt(jwt_token)
_unverified_decoded_token = jwt.decode(
jwt_token, options={"verify_signature": False}
)
return jwt.decode(
jwt_token,
signing_key.key,
algorithms=["RS256"],
audience=audience,
issuer=issuer,
leeway=10.0,
options={
"verify_signature": True,
"verify_exp": True,
"verify_nbf": True,
"verify_iat": True,
"require": ["exp", "iat", "iss", "aud", "sub"],
},
)
except jwt.ExpiredSignatureError as e:
raise Exception("Token has expired.") from e
except jwt.InvalidAudienceError as e:
actual_audience = _unverified_decoded_token.get("aud", "[no audience found]")
raise Exception(
f"Invalid token audience. Got: '{actual_audience}'. Expected: '{audience}'"
) from e
except jwt.InvalidIssuerError as e:
actual_issuer = _unverified_decoded_token.get("iss", "[no issuer found]")
raise Exception(
f"Invalid token issuer. Got: '{actual_issuer}'. Expected: '{issuer}'"
) from e
except jwt.MissingRequiredClaimError as e:
raise Exception(f"Token is missing required claims: {e!s}") from e
except jwt.exceptions.PyJWKClientError as e:
raise Exception(f"JWKS or key processing error: {e!s}") from e
except jwt.InvalidTokenError as e:
raise Exception(f"Invalid token: {e!s}") from e
__all__ = ["validate_jwt_token"]

View File

@@ -1,230 +1,12 @@
"""CrewAI+ API client."""
"""Re-export of ``crewai_core.plus_api.PlusAPI``.
import os
from typing import Any
from urllib.parse import urljoin
Kept as a stable import path for the framework; new code should import from
``crewai_core.plus_api`` directly.
"""
from crewai_core.settings import Settings
import httpx
from __future__ import annotations
from crewai.constants import DEFAULT_CREWAI_ENTERPRISE_URL
from crewai.version import get_crewai_version
from crewai_core.plus_api import PlusAPI as PlusAPI
class PlusAPI:
"""Client for working with the CrewAI+ API."""
TOOLS_RESOURCE = "/crewai_plus/api/v1/tools"
ORGANIZATIONS_RESOURCE = "/crewai_plus/api/v1/me/organizations"
CREWS_RESOURCE = "/crewai_plus/api/v1/crews"
AGENTS_RESOURCE = "/crewai_plus/api/v1/agents"
TRACING_RESOURCE = "/crewai_plus/api/v1/tracing"
EPHEMERAL_TRACING_RESOURCE = "/crewai_plus/api/v1/tracing/ephemeral"
INTEGRATIONS_RESOURCE = "/crewai_plus/api/v1/integrations"
def __init__(self, api_key: str | None = None) -> None:
self.api_key = api_key
self.headers = {
"Content-Type": "application/json",
"User-Agent": f"CrewAI-CLI/{get_crewai_version()}",
"X-Crewai-Version": get_crewai_version(),
}
if api_key:
self.headers["Authorization"] = f"Bearer {api_key}"
settings = Settings()
if settings.org_uuid:
self.headers["X-Crewai-Organization-Id"] = settings.org_uuid
self.base_url = (
os.getenv("CREWAI_PLUS_URL")
or str(settings.enterprise_base_url)
or DEFAULT_CREWAI_ENTERPRISE_URL
)
def _make_request(
self, method: str, endpoint: str, **kwargs: Any
) -> httpx.Response:
url = urljoin(self.base_url, endpoint)
verify = kwargs.pop("verify", True)
with httpx.Client(trust_env=False, verify=verify) as client:
return client.request(method, url, headers=self.headers, **kwargs)
def login_to_tool_repository(
self, user_identifier: str | None = None
) -> httpx.Response:
payload = {}
if user_identifier:
payload["user_identifier"] = user_identifier
return self._make_request("POST", f"{self.TOOLS_RESOURCE}/login", json=payload)
def get_tool(self, handle: str) -> httpx.Response:
return self._make_request("GET", f"{self.TOOLS_RESOURCE}/{handle}")
async def get_agent(self, handle: str) -> httpx.Response:
url = urljoin(self.base_url, f"{self.AGENTS_RESOURCE}/{handle}")
async with httpx.AsyncClient() as client:
return await client.get(url, headers=self.headers)
def publish_tool(
self,
handle: str,
is_public: bool,
version: str,
description: str | None,
encoded_file: str,
available_exports: list[dict[str, Any]] | None = None,
tools_metadata: list[dict[str, Any]] | None = None,
) -> httpx.Response:
params = {
"handle": handle,
"public": is_public,
"version": version,
"file": encoded_file,
"description": description,
"available_exports": available_exports,
"tools_metadata": {"package": handle, "tools": tools_metadata}
if tools_metadata is not None
else None,
}
return self._make_request("POST", f"{self.TOOLS_RESOURCE}", json=params)
def deploy_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"POST", f"{self.CREWS_RESOURCE}/by-name/{project_name}/deploy"
)
def deploy_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("POST", f"{self.CREWS_RESOURCE}/{uuid}/deploy")
def crew_status_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/by-name/{project_name}/status"
)
def crew_status_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("GET", f"{self.CREWS_RESOURCE}/{uuid}/status")
def crew_by_name(
self, project_name: str, log_type: str = "deployment"
) -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/by-name/{project_name}/logs/{log_type}"
)
def crew_by_uuid(self, uuid: str, log_type: str = "deployment") -> httpx.Response:
return self._make_request(
"GET", f"{self.CREWS_RESOURCE}/{uuid}/logs/{log_type}"
)
def delete_crew_by_name(self, project_name: str) -> httpx.Response:
return self._make_request(
"DELETE", f"{self.CREWS_RESOURCE}/by-name/{project_name}"
)
def delete_crew_by_uuid(self, uuid: str) -> httpx.Response:
return self._make_request("DELETE", f"{self.CREWS_RESOURCE}/{uuid}")
def list_crews(self) -> httpx.Response:
return self._make_request("GET", self.CREWS_RESOURCE)
def create_crew(self, payload: dict[str, Any]) -> httpx.Response:
return self._make_request("POST", self.CREWS_RESOURCE, json=payload)
def get_organizations(self) -> httpx.Response:
return self._make_request("GET", self.ORGANIZATIONS_RESOURCE)
def initialize_trace_batch(self, payload: dict[str, Any]) -> httpx.Response:
return self._make_request(
"POST",
f"{self.TRACING_RESOURCE}/batches",
json=payload,
timeout=30,
)
def initialize_ephemeral_trace_batch(
self, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches",
json=payload,
)
def send_trace_events(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/events",
json=payload,
timeout=30,
)
def send_ephemeral_trace_events(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"POST",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}/events",
json=payload,
timeout=30,
)
def finalize_trace_batch(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}/finalize",
json=payload,
timeout=30,
)
def finalize_ephemeral_trace_batch(
self, trace_batch_id: str, payload: dict[str, Any]
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}/finalize",
json=payload,
timeout=30,
)
def mark_trace_batch_as_failed(
self, trace_batch_id: str, error_message: str
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.TRACING_RESOURCE}/batches/{trace_batch_id}",
json={"status": "failed", "failure_reason": error_message},
timeout=30,
)
def mark_ephemeral_trace_batch_as_failed(
self, trace_batch_id: str, error_message: str
) -> httpx.Response:
return self._make_request(
"PATCH",
f"{self.EPHEMERAL_TRACING_RESOURCE}/batches/{trace_batch_id}",
json={"status": "failed", "failure_reason": error_message},
timeout=30,
)
def get_mcp_configs(self, slugs: list[str]) -> httpx.Response:
"""Get MCP server configurations for the given slugs."""
return self._make_request(
"GET",
f"{self.INTEGRATIONS_RESOURCE}/mcp_configs",
params={"slugs": ",".join(slugs)},
timeout=30,
)
def get_triggers(self) -> httpx.Response:
"""Get all available triggers from integrations."""
return self._make_request("GET", f"{self.INTEGRATIONS_RESOURCE}/apps")
def get_trigger_payload(self, app_slug: str, trigger_slug: str) -> httpx.Response:
"""Get sample payload for a specific trigger."""
return self._make_request(
"GET", f"{self.INTEGRATIONS_RESOURCE}/{app_slug}/{trigger_slug}/payload"
)
__all__ = ["PlusAPI"]

View File

@@ -1,215 +1,24 @@
"""Version utilities for CrewAI."""
"""Re-exports of version utilities from ``crewai_core.version``.
from collections.abc import Mapping
from datetime import datetime, timedelta
from functools import lru_cache
import importlib.metadata
import json
from pathlib import Path
from typing import Any
from urllib import request
from urllib.error import URLError
Kept as a stable import path for the framework; new code should import from
``crewai_core.version`` directly.
"""
import appdirs
from packaging.version import InvalidVersion, Version, parse
from __future__ import annotations
from crewai_core.version import (
check_version as check_version,
get_crewai_version as get_crewai_version,
get_latest_version_from_pypi as get_latest_version_from_pypi,
is_current_version_yanked as is_current_version_yanked,
is_newer_version_available as is_newer_version_available,
)
@lru_cache(maxsize=1)
def _get_cache_file() -> Path:
"""Get the path to the version cache file.
Cached to avoid repeated filesystem operations.
"""
cache_dir = Path(appdirs.user_cache_dir("crewai"))
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir / "version_cache.json"
def get_crewai_version() -> str:
"""Get the version number of the installed CrewAI package."""
return importlib.metadata.version("crewai")
def _is_cache_valid(cache_data: Mapping[str, Any]) -> bool:
"""Check if the cache is still valid, less than 24 hours old."""
if "timestamp" not in cache_data:
return False
try:
cache_time = datetime.fromisoformat(str(cache_data["timestamp"]))
return datetime.now() - cache_time < timedelta(hours=24)
except (ValueError, TypeError):
return False
def _find_latest_non_yanked_version(
releases: Mapping[str, list[dict[str, Any]]],
) -> str | None:
"""Find the latest non-yanked version from PyPI releases data.
Args:
releases: PyPI releases dict mapping version strings to file info lists.
Returns:
The latest non-yanked version string, or None if all versions are yanked.
"""
best_version: Version | None = None
best_version_str: str | None = None
for version_str, files in releases.items():
try:
v = parse(version_str)
except InvalidVersion:
continue
if v.is_prerelease or v.is_devrelease:
continue
if not files:
continue
all_yanked = all(f.get("yanked", False) for f in files)
if all_yanked:
continue
if best_version is None or v > best_version:
best_version = v
best_version_str = version_str
return best_version_str
def _is_version_yanked(
version_str: str,
releases: Mapping[str, list[dict[str, Any]]],
) -> tuple[bool, str]:
"""Check if a specific version is yanked.
Args:
version_str: The version string to check.
releases: PyPI releases dict mapping version strings to file info lists.
Returns:
Tuple of (is_yanked, yanked_reason).
"""
files = releases.get(version_str, [])
if not files:
return False, ""
all_yanked = all(f.get("yanked", False) for f in files)
if not all_yanked:
return False, ""
for f in files:
reason = f.get("yanked_reason", "")
if reason:
return True, str(reason)
return True, ""
def get_latest_version_from_pypi(timeout: int = 2) -> str | None:
"""Get the latest non-yanked version of CrewAI from PyPI.
Args:
timeout: Request timeout in seconds.
Returns:
Latest non-yanked version string or None if unable to fetch.
"""
cache_file = _get_cache_file()
if cache_file.exists():
try:
cache_data = json.loads(cache_file.read_text())
if _is_cache_valid(cache_data) and "current_version" in cache_data:
version: str | None = cache_data.get("version")
return version
except (json.JSONDecodeError, OSError):
pass
try:
with request.urlopen(
"https://pypi.org/pypi/crewai/json", timeout=timeout
) as response:
data = json.loads(response.read())
releases: dict[str, list[dict[str, Any]]] = data["releases"]
latest_version = _find_latest_non_yanked_version(releases)
current_version = get_crewai_version()
is_yanked, yanked_reason = _is_version_yanked(current_version, releases)
cache_data = {
"version": latest_version,
"timestamp": datetime.now().isoformat(),
"current_version": current_version,
"current_version_yanked": is_yanked,
"current_version_yanked_reason": yanked_reason,
}
cache_file.write_text(json.dumps(cache_data))
return latest_version
except (URLError, json.JSONDecodeError, KeyError, OSError):
return None
def is_current_version_yanked() -> tuple[bool, str]:
"""Check if the currently installed version has been yanked on PyPI.
Reads from cache if available, otherwise triggers a fetch.
Returns:
Tuple of (is_yanked, yanked_reason).
"""
cache_file = _get_cache_file()
if cache_file.exists():
try:
cache_data = json.loads(cache_file.read_text())
if _is_cache_valid(cache_data) and "current_version" in cache_data:
current = get_crewai_version()
if cache_data.get("current_version") == current:
return (
bool(cache_data.get("current_version_yanked", False)),
str(cache_data.get("current_version_yanked_reason", "")),
)
except (json.JSONDecodeError, OSError):
pass
get_latest_version_from_pypi()
try:
cache_data = json.loads(cache_file.read_text())
return (
bool(cache_data.get("current_version_yanked", False)),
str(cache_data.get("current_version_yanked_reason", "")),
)
except (json.JSONDecodeError, OSError):
return False, ""
def check_version() -> tuple[str, str | None]:
"""Check current and latest versions.
Returns:
Tuple of (current_version, latest_version).
latest_version is None if unable to fetch from PyPI.
"""
current = get_crewai_version()
latest = get_latest_version_from_pypi()
return current, latest
def is_newer_version_available() -> tuple[bool, str, str | None]:
"""Check if a newer version is available.
Returns:
Tuple of (is_newer, current_version, latest_version).
"""
current, latest = check_version()
if latest is None:
return False, current, None
try:
return parse(latest) > parse(current), current, latest
except (InvalidVersion, TypeError):
return False, current, latest
__all__ = [
"check_version",
"get_crewai_version",
"get_latest_version_from_pypi",
"is_current_version_yanked",
"is_newer_version_available",
]

View File

@@ -6,8 +6,8 @@ import jwt
from crewai.auth.utils import validate_jwt_token
@patch("crewai.auth.utils.PyJWKClient", return_value=MagicMock())
@patch("crewai.auth.utils.jwt")
@patch("crewai_core.auth.utils.PyJWKClient", return_value=MagicMock())
@patch("crewai_core.auth.utils.jwt")
class TestUtils(unittest.TestCase):
def test_validate_jwt_token(self, mock_jwt, mock_pyjwkclient):
mock_jwt.decode.return_value = {"exp": 1719859200}

View File

@@ -20,7 +20,7 @@ class TestPlusAPI(unittest.TestCase):
self.assertTrue("CrewAI-CLI/" in self.api.headers["User-Agent"])
self.assertTrue(self.api.headers["X-Crewai-Version"])
@patch("crewai.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_login_to_tool_repository(self, mock_make_request):
mock_response = MagicMock()
mock_make_request.return_value = mock_response
@@ -32,7 +32,7 @@ class TestPlusAPI(unittest.TestCase):
)
self.assertEqual(response, mock_response)
@patch("crewai.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_login_to_tool_repository_with_user_identifier(self, mock_make_request):
mock_response = MagicMock()
mock_make_request.return_value = mock_response
@@ -60,8 +60,8 @@ class TestPlusAPI(unittest.TestCase):
**kwargs,
)
@patch("crewai.plus_api.Settings")
@patch("crewai.plus_api.httpx.Client")
@patch("crewai_core.plus_api.Settings")
@patch("crewai_core.plus_api.httpx.Client")
def test_login_to_tool_repository_with_org_uuid(
self, mock_client_class, mock_settings_class
):
@@ -83,7 +83,7 @@ class TestPlusAPI(unittest.TestCase):
)
self.assertEqual(response, mock_response)
@patch("crewai.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_get_tool(self, mock_make_request):
mock_response = MagicMock()
mock_make_request.return_value = mock_response
@@ -94,8 +94,8 @@ class TestPlusAPI(unittest.TestCase):
)
self.assertEqual(response, mock_response)
@patch("crewai.plus_api.Settings")
@patch("crewai.plus_api.httpx.Client")
@patch("crewai_core.plus_api.Settings")
@patch("crewai_core.plus_api.httpx.Client")
def test_get_tool_with_org_uuid(self, mock_client_class, mock_settings_class):
mock_settings = MagicMock()
mock_settings.org_uuid = self.org_uuid
@@ -115,7 +115,7 @@ class TestPlusAPI(unittest.TestCase):
)
self.assertEqual(response, mock_response)
@patch("crewai.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_publish_tool(self, mock_make_request):
mock_response = MagicMock()
mock_make_request.return_value = mock_response
@@ -143,8 +143,8 @@ class TestPlusAPI(unittest.TestCase):
)
self.assertEqual(response, mock_response)
@patch("crewai.plus_api.Settings")
@patch("crewai.plus_api.httpx.Client")
@patch("crewai_core.plus_api.Settings")
@patch("crewai_core.plus_api.httpx.Client")
def test_publish_tool_with_org_uuid(self, mock_client_class, mock_settings_class):
mock_settings = MagicMock()
mock_settings.org_uuid = self.org_uuid
@@ -182,7 +182,7 @@ class TestPlusAPI(unittest.TestCase):
)
self.assertEqual(response, mock_response)
@patch("crewai.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_publish_tool_without_description(self, mock_make_request):
mock_response = MagicMock()
mock_make_request.return_value = mock_response
@@ -210,7 +210,7 @@ class TestPlusAPI(unittest.TestCase):
)
self.assertEqual(response, mock_response)
@patch("crewai.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_publish_tool_with_tools_metadata(self, mock_make_request):
mock_response = MagicMock()
mock_make_request.return_value = mock_response
@@ -251,7 +251,7 @@ class TestPlusAPI(unittest.TestCase):
)
self.assertEqual(response, mock_response)
@patch("crewai.plus_api.httpx.Client")
@patch("crewai_core.plus_api.httpx.Client")
def test_make_request(self, mock_client_class):
mock_client_instance = MagicMock()
mock_response = MagicMock()
@@ -266,35 +266,35 @@ class TestPlusAPI(unittest.TestCase):
)
self.assertEqual(response, mock_response)
@patch("crewai.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_deploy_by_name(self, mock_make_request):
self.api.deploy_by_name("test_project")
mock_make_request.assert_called_once_with(
"POST", "/crewai_plus/api/v1/crews/by-name/test_project/deploy"
)
@patch("crewai.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_deploy_by_uuid(self, mock_make_request):
self.api.deploy_by_uuid("test_uuid")
mock_make_request.assert_called_once_with(
"POST", "/crewai_plus/api/v1/crews/test_uuid/deploy"
)
@patch("crewai.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_crew_status_by_name(self, mock_make_request):
self.api.crew_status_by_name("test_project")
mock_make_request.assert_called_once_with(
"GET", "/crewai_plus/api/v1/crews/by-name/test_project/status"
)
@patch("crewai.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_crew_status_by_uuid(self, mock_make_request):
self.api.crew_status_by_uuid("test_uuid")
mock_make_request.assert_called_once_with(
"GET", "/crewai_plus/api/v1/crews/test_uuid/status"
)
@patch("crewai.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_crew_by_name(self, mock_make_request):
self.api.crew_by_name("test_project")
mock_make_request.assert_called_once_with(
@@ -306,7 +306,7 @@ class TestPlusAPI(unittest.TestCase):
"GET", "/crewai_plus/api/v1/crews/by-name/test_project/logs/custom_log"
)
@patch("crewai.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_crew_by_uuid(self, mock_make_request):
self.api.crew_by_uuid("test_uuid")
mock_make_request.assert_called_once_with(
@@ -318,26 +318,26 @@ class TestPlusAPI(unittest.TestCase):
"GET", "/crewai_plus/api/v1/crews/test_uuid/logs/custom_log"
)
@patch("crewai.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_delete_crew_by_name(self, mock_make_request):
self.api.delete_crew_by_name("test_project")
mock_make_request.assert_called_once_with(
"DELETE", "/crewai_plus/api/v1/crews/by-name/test_project"
)
@patch("crewai.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_delete_crew_by_uuid(self, mock_make_request):
self.api.delete_crew_by_uuid("test_uuid")
mock_make_request.assert_called_once_with(
"DELETE", "/crewai_plus/api/v1/crews/test_uuid"
)
@patch("crewai.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_list_crews(self, mock_make_request):
self.api.list_crews()
mock_make_request.assert_called_once_with("GET", "/crewai_plus/api/v1/crews")
@patch("crewai.plus_api.PlusAPI._make_request")
@patch("crewai_core.plus_api.PlusAPI._make_request")
def test_create_crew(self, mock_make_request):
payload = {"name": "test_crew"}
self.api.create_crew(payload)
@@ -345,7 +345,7 @@ class TestPlusAPI(unittest.TestCase):
"POST", "/crewai_plus/api/v1/crews", json=payload
)
@patch("crewai.plus_api.Settings")
@patch("crewai_core.plus_api.Settings")
@patch.dict(os.environ, {"CREWAI_PLUS_URL": ""})
def test_custom_base_url(self, mock_settings_class):
mock_settings = MagicMock()
@@ -386,7 +386,7 @@ async def test_get_agent(mock_async_client_class):
@pytest.mark.asyncio
@patch("httpx.AsyncClient")
@patch("crewai.plus_api.Settings")
@patch("crewai_core.plus_api.Settings")
async def test_get_agent_with_org_uuid(mock_settings_class, mock_async_client_class):
org_uuid = "test-org-uuid"
mock_settings = MagicMock()

View File

@@ -7,15 +7,17 @@ from unittest.mock import MagicMock, patch
from crewai import __version__
from crewai.version import (
_find_latest_non_yanked_version,
_get_cache_file,
_is_cache_valid,
_is_version_yanked,
get_crewai_version,
get_latest_version_from_pypi,
is_current_version_yanked,
is_newer_version_available,
)
from crewai_core.version import (
_find_latest_non_yanked_version,
_get_cache_file,
_is_cache_valid,
_is_version_yanked,
)
def test_dynamic_versioning_consistency() -> None:
@@ -60,8 +62,8 @@ class TestVersionChecking:
cache_data = {"version": "1.0.0"}
assert _is_cache_valid(cache_data) is False
@patch("crewai.version.Path.exists")
@patch("crewai.version.request.urlopen")
@patch("crewai_core.version.Path.exists")
@patch("crewai_core.version.request.urlopen")
def test_get_latest_version_from_pypi_success(
self, mock_urlopen: MagicMock, mock_exists: MagicMock
) -> None:
@@ -82,8 +84,8 @@ class TestVersionChecking:
version = get_latest_version_from_pypi()
assert version == "2.0.0"
@patch("crewai.version.Path.exists")
@patch("crewai.version.request.urlopen")
@patch("crewai_core.version.Path.exists")
@patch("crewai_core.version.request.urlopen")
def test_get_latest_version_from_pypi_failure(
self, mock_urlopen: MagicMock, mock_exists: MagicMock
) -> None:
@@ -97,8 +99,8 @@ class TestVersionChecking:
version = get_latest_version_from_pypi()
assert version is None
@patch("crewai.version.get_crewai_version")
@patch("crewai.version.get_latest_version_from_pypi")
@patch("crewai_core.version.get_crewai_version")
@patch("crewai_core.version.get_latest_version_from_pypi")
def test_is_newer_version_available_true(
self, mock_latest: MagicMock, mock_current: MagicMock
) -> None:
@@ -111,8 +113,8 @@ class TestVersionChecking:
assert current == "1.0.0"
assert latest == "2.0.0"
@patch("crewai.version.get_crewai_version")
@patch("crewai.version.get_latest_version_from_pypi")
@patch("crewai_core.version.get_crewai_version")
@patch("crewai_core.version.get_latest_version_from_pypi")
def test_is_newer_version_available_false(
self, mock_latest: MagicMock, mock_current: MagicMock
) -> None:
@@ -125,8 +127,8 @@ class TestVersionChecking:
assert current == "2.0.0"
assert latest == "2.0.0"
@patch("crewai.version.get_crewai_version")
@patch("crewai.version.get_latest_version_from_pypi")
@patch("crewai_core.version.get_crewai_version")
@patch("crewai_core.version.get_latest_version_from_pypi")
def test_is_newer_version_available_with_none_latest(
self, mock_latest: MagicMock, mock_current: MagicMock
) -> None:
@@ -260,8 +262,8 @@ class TestIsVersionYanked:
class TestIsCurrentVersionYanked:
"""Test is_current_version_yanked public function."""
@patch("crewai.version.get_crewai_version")
@patch("crewai.version._get_cache_file")
@patch("crewai_core.version.get_crewai_version")
@patch("crewai_core.version._get_cache_file")
def test_reads_from_valid_cache(
self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path
) -> None:
@@ -282,8 +284,8 @@ class TestIsCurrentVersionYanked:
assert is_yanked is True
assert reason == "bad release"
@patch("crewai.version.get_crewai_version")
@patch("crewai.version._get_cache_file")
@patch("crewai_core.version.get_crewai_version")
@patch("crewai_core.version._get_cache_file")
def test_not_yanked_from_cache(
self, mock_cache_file: MagicMock, mock_version: MagicMock, tmp_path: Path
) -> None:
@@ -304,9 +306,9 @@ class TestIsCurrentVersionYanked:
assert is_yanked is False
assert reason == ""
@patch("crewai.version.get_latest_version_from_pypi")
@patch("crewai.version.get_crewai_version")
@patch("crewai.version._get_cache_file")
@patch("crewai_core.version.get_latest_version_from_pypi")
@patch("crewai_core.version.get_crewai_version")
@patch("crewai_core.version._get_cache_file")
def test_triggers_fetch_on_stale_cache(
self,
mock_cache_file: MagicMock,
@@ -346,9 +348,9 @@ class TestIsCurrentVersionYanked:
assert is_yanked is False
mock_fetch.assert_called_once()
@patch("crewai.version.get_latest_version_from_pypi")
@patch("crewai.version.get_crewai_version")
@patch("crewai.version._get_cache_file")
@patch("crewai_core.version.get_latest_version_from_pypi")
@patch("crewai_core.version.get_crewai_version")
@patch("crewai_core.version._get_cache_file")
def test_returns_false_on_fetch_failure(
self,
mock_cache_file: MagicMock,

View File

@@ -29,6 +29,7 @@ dev = [
"types-psycopg2==2.9.21.20251012",
"types-pymysql==1.1.0.20250916",
"types-aiofiles~=25.1.0",
"types-redis~=4.6",
"commitizen>=4.13.9",
"pip-audit==2.9.0",
]

54
uv.lock generated
View File

@@ -63,6 +63,7 @@ dev = [
{ name = "types-psycopg2", specifier = "==2.9.21.20251012" },
{ name = "types-pymysql", specifier = "==1.1.0.20250916" },
{ name = "types-pyyaml", specifier = "==6.0.*" },
{ name = "types-redis", specifier = "~=4.6" },
{ name = "types-regex", specifier = "==2026.1.15.*" },
{ name = "types-requests", specifier = "~=2.31.0.6" },
{ name = "vcrpy", specifier = "==7.0.0" },
@@ -1473,11 +1474,14 @@ source = { editable = "lib/crewai-core" }
dependencies = [
{ name = "appdirs" },
{ name = "cryptography" },
{ name = "httpx" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
{ name = "opentelemetry-sdk" },
{ name = "packaging" },
{ name = "portalocker" },
{ name = "pydantic" },
{ name = "pyjwt" },
{ name = "rich" },
{ name = "tomli" },
]
@@ -1486,11 +1490,14 @@ dependencies = [
requires-dist = [
{ name = "appdirs", specifier = "~=1.4.4" },
{ name = "cryptography", specifier = ">=42.0" },
{ name = "httpx", specifier = "~=0.28.1" },
{ name = "opentelemetry-api", specifier = "~=1.34.0" },
{ name = "opentelemetry-exporter-otlp-proto-http", specifier = "~=1.34.0" },
{ name = "opentelemetry-sdk", specifier = "~=1.34.0" },
{ name = "packaging", specifier = ">=23.0" },
{ name = "portalocker", specifier = "~=2.7.0" },
{ name = "pydantic", specifier = ">=2.11.9,<2.13" },
{ name = "pyjwt", specifier = ">=2.9.0,<3" },
{ name = "rich", specifier = ">=13.7.1" },
{ name = "tomli", specifier = "~=2.0.2" },
]
@@ -9042,6 +9049,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/e5/47a573bbbd0a790f8f9fe452f7188ea72b212d21c9be57d5fc0cbc442075/types_awscrt-0.31.3-py3-none-any.whl", hash = "sha256:e5ce65a00a2ab4f35eacc1e3d700d792338d56e4823ee7b4dbe017f94cfc4458", size = 43340, upload-time = "2026-03-08T02:31:13.38Z" },
]
[[package]]
name = "types-cffi"
version = "2.0.0.20260408"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-setuptools" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/67/eb4ef3408fdc0b4e5af38b30c0e6ad4663b41bdae9fb85a9f09a8db61a99/types_cffi-2.0.0.20260408.tar.gz", hash = "sha256:aa8b9c456ab715c079fc655929811f21f331bfb940f4a821987c581bf4e36230", size = 17541, upload-time = "2026-04-08T04:36:03.918Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/a3/7fbd93ededcc7c77e9e5948b9794161733ebdbf618a27965b1bea0e728a4/types_cffi-2.0.0.20260408-py3-none-any.whl", hash = "sha256:68bd296742b4ff7c0afe3547f50bd0acc55416ecf322ffefd2b7344ef6388a42", size = 20101, upload-time = "2026-04-08T04:36:02.995Z" },
]
[[package]]
name = "types-psycopg2"
version = "2.9.21.20251012"
@@ -9060,6 +9079,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/21/eb/a225e32a6e7b196af67ab2f1b07363595f63255374cc3b88bfdab53b4ee8/types_pymysql-1.1.0.20250916-py3-none-any.whl", hash = "sha256:873eb9836bb5e3de4368cc7010ca72775f86e9692a5c7810f8c7f48da082e55b", size = 23063, upload-time = "2025-09-16T02:49:20.933Z" },
]
[[package]]
name = "types-pyopenssl"
version = "24.1.0.20240722"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "types-cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/93/29/47a346550fd2020dac9a7a6d033ea03fccb92fa47c726056618cc889745e/types-pyOpenSSL-24.1.0.20240722.tar.gz", hash = "sha256:47913b4678a01d879f503a12044468221ed8576263c1540dcb0484ca21b08c39", size = 8458, upload-time = "2024-07-22T02:32:22.558Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/05/c868a850b6fbb79c26f5f299b768ee0adc1f9816d3461dcf4287916f655b/types_pyOpenSSL-24.1.0.20240722-py3-none-any.whl", hash = "sha256:6a7a5d2ec042537934cfb4c9d4deb0e16c4c6250b09358df1f083682fe6fda54", size = 7499, upload-time = "2024-07-22T02:32:21.232Z" },
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20260408"
@@ -9069,6 +9101,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/f0/c391068b86abb708882c6d75a08cd7d25b2c7227dab527b3a3685a3c635b/types_pyyaml-6.0.12.20260408-py3-none-any.whl", hash = "sha256:fbc42037d12159d9c801ebfcc79ebd28335a7c13b08a4cfbc6916df78fee9384", size = 20339, upload-time = "2026-04-08T04:30:50.113Z" },
]
[[package]]
name = "types-redis"
version = "4.6.0.20241004"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "types-pyopenssl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/95/c054d3ac940e8bac4ca216470c80c26688a0e79e09f520a942bb27da3386/types-redis-4.6.0.20241004.tar.gz", hash = "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e", size = 49679, upload-time = "2024-10-04T02:43:59.224Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/82/7d25dce10aad92d2226b269bce2f85cfd843b4477cd50245d7d40ecf8f89/types_redis-4.6.0.20241004-py3-none-any.whl", hash = "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed", size = 58737, upload-time = "2024-10-04T02:43:57.968Z" },
]
[[package]]
name = "types-regex"
version = "2026.1.15.20260116"
@@ -9099,6 +9144,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/27/e88220fe6274eccd3bdf95d9382918716d312f6f6cef6a46332d1ee2feff/types_s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:1c0cd111ecf6e21437cb410f5cddb631bfb2263b77ad973e79b9c6d0cb24e0ef", size = 19247, upload-time = "2025-12-08T08:13:08.426Z" },
]
[[package]]
name = "types-setuptools"
version = "82.0.0.20260408"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/12/3464b410c50420dd4674fa5fe9d3880711c1dbe1a06f5fe4960ee9067b9e/types_setuptools-82.0.0.20260408.tar.gz", hash = "sha256:036c68caf7e672a699f5ebbf914708d40644c14e05298bc49f7272be91cf43d3", size = 44861, upload-time = "2026-04-08T04:29:33.292Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/e1/46a4fc3ef03aabf5d18bac9df5cf37c6b02c3bddf3e05c3533f4b4588331/types_setuptools-82.0.0.20260408-py3-none-any.whl", hash = "sha256:ece0a215cdfa6463a65fd6f68bd940f39e455729300ddfe61cab1147ed1d2462", size = 68428, upload-time = "2026-04-08T04:29:32.175Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"