mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-09-14 23:29:49 +00:00
Compare commits
16 Commits
clear-cont
...
iris/fix-g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cc6e5d225 | ||
|
|
92eb5f9183 | ||
|
|
c90337ba5a | ||
|
|
3d72c707d5 | ||
|
|
98799a3b09 | ||
|
|
1cef70de52 | ||
|
|
b608a3595c | ||
|
|
f5db5a1788 | ||
|
|
968c3065d3 | ||
|
|
818f2624e8 | ||
|
|
8e46205619 | ||
|
|
1bc2e0722d | ||
|
|
48cc5d4e5e | ||
|
|
917b9df6d7 | ||
|
|
ec53d6f534 | ||
|
|
381fef73be |
7
.github/workflows/vulnerability-scan.yml
vendored
7
.github/workflows/vulnerability-scan.yml
vendored
@@ -100,6 +100,13 @@ jobs:
|
||||
# GHSA-xph7-9rjv-w5fr (CVE-2026-45831): SimpleRBACAuthorizationProvider
|
||||
# ignores tenant/database/collection scope.
|
||||
--ignore-vuln GHSA-xph7-9rjv-w5fr
|
||||
# nltk <=3.10.3: GHSA-8mgp-746c-j5xp (CVE-2026-81726): model-artifact
|
||||
# APIs bypass pathsec and read/write outside allowed roots. No patched
|
||||
# PyPI release yet (fixes are on nltk develop only). Transitive via
|
||||
# crewai-tools[xml] -> unstructured; CrewAI does not call those APIs.
|
||||
# TODO: drop this ignore when bumping nltk past 3.10.3 to a patched
|
||||
# release; keep the ignore list in sync with .pre-commit-config.yaml.
|
||||
--ignore-vuln GHSA-8mgp-746c-j5xp
|
||||
)
|
||||
uv run pip-audit "${pip_audit_args[@]}"
|
||||
continue-on-error: true
|
||||
|
||||
@@ -29,6 +29,7 @@ repos:
|
||||
- id: pip-audit
|
||||
name: pip-audit
|
||||
# Keep this ignore list in sync with .github/workflows/vulnerability-scan.yml.
|
||||
# TODO: drop --ignore-vuln GHSA-8mgp-746c-j5xp when bumping nltk past 3.10.3.
|
||||
entry: >-
|
||||
bash -c 'source .venv/bin/activate && uv run pip-audit --skip-editable
|
||||
--ignore-vuln PYSEC-2024-277
|
||||
@@ -59,7 +60,8 @@ repos:
|
||||
--ignore-vuln GHSA-f4j7-r4q5-qw2c
|
||||
--ignore-vuln GHSA-2wm9-hf6c-p5cr
|
||||
--ignore-vuln GHSA-36p7-vc44-83pf
|
||||
--ignore-vuln GHSA-xph7-9rjv-w5fr' --
|
||||
--ignore-vuln GHSA-xph7-9rjv-w5fr
|
||||
--ignore-vuln GHSA-8mgp-746c-j5xp' --
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-push, manual]
|
||||
|
||||
@@ -26,7 +26,7 @@ mode: "wide"
|
||||
- **معالجة الأخطاء** – توجيه كيفية استجابة الـ Agents للإخفاقات والاستثناءات وحالات انتهاء المهلة.
|
||||
- **مطالبات خاصة بالأدوات** – تعريف تعليمات مفصلة لكيفية استدعاء الأدوات أو استخدامها.
|
||||
|
||||
اطلع على [قوالب المطالبات الأصلية في مستودع CrewAI](https://github.com/crewAIInc/crewAI/blob/main/src/crewai/translations/en.json) لمعرفة كيفية تنظيم هذه العناصر. من هناك، يمكنك تجاوزها أو تكييفها حسب الحاجة لفتح سلوكيات متقدمة.
|
||||
اطلع على [قوالب المطالبات الأصلية في مستودع CrewAI](https://github.com/crewAIInc/crewAI/blob/main/lib/crewai/src/crewai/translations/en.json) لمعرفة كيفية تنظيم هذه العناصر. من هناك، يمكنك تجاوزها أو تكييفها حسب الحاجة لفتح سلوكيات متقدمة.
|
||||
|
||||
## فهم تعليمات النظام الافتراضية
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ mode: "wide"
|
||||
عند تفعيل ميزة `share_crew`، يتم جمع بيانات تفصيلية تشمل أوصاف المهام وخلفيات وأهداف الوكلاء وسمات محددة أخرى
|
||||
لتوفير رؤى أعمق. قد يتضمن جمع البيانات الموسع هذا معلومات شخصية إذا دمجها المستخدمون في طواقمهم أو مهامهم.
|
||||
يجب على المستخدمين النظر بعناية في محتوى طواقمهم ومهامهم قبل تفعيل `share_crew`.
|
||||
يمكن للمستخدمين تعطيل القياس عن بُعد عبر تعيين متغير البيئة `CREWAI_DISABLE_TELEMETRY` إلى `true` أو تعيين `OTEL_SDK_DISABLED` إلى `true` (لاحظ أن الأخير يعطل جميع أدوات OpenTelemetry عالمياً).
|
||||
يمكن للمستخدمين تعطيل القياس عن بُعد في CrewAI عبر تعيين `CREWAI_DISABLE_TELEMETRY` إلى `true` أو `1` أو `yes` أو `on` (بغض النظر عن حالة الأحرف). `OTEL_SDK_DISABLED` بنفس القيم يعطّل أيضاً مُصدِّر CrewAI. مجموعة أدوات OpenTelemetry نفسها ما تزال تقبل `true` فقط لتعطيل بقية أدوات القياس في العملية.
|
||||
|
||||
### أمثلة:
|
||||
```python
|
||||
@@ -34,6 +34,8 @@ os.environ['CREWAI_DISABLE_TELEMETRY'] = 'true'
|
||||
os.environ['OTEL_SDK_DISABLED'] = 'true'
|
||||
```
|
||||
|
||||
`CREWAI_DISABLE_TELEMETRY=1` (أو `yes` / `on`) يعمل بنفس طريقة `true`. تُتجاهل القيم غير المعروفة ويبقى القياس عن بُعد مفعّلاً.
|
||||
|
||||
### العزل عن إعداد OpenTelemetry الخاص بك
|
||||
|
||||
يعمل القياس عن بُعد الخاص بـ CrewAI على `TracerProvider` خاص به ولا يسجل نفسه
|
||||
|
||||
@@ -50,16 +50,15 @@ mode: "wide"
|
||||
- **سلامة الذكاء الاصطناعي**: تنفيذ فحوصات الإشراف على المحتوى والسلامة
|
||||
|
||||
```python
|
||||
from crewai_tools import DallETool, VisionTool, CodeInterpreterTool
|
||||
from crewai_tools import DallETool, VisionTool
|
||||
|
||||
# Create AI tools
|
||||
image_generator = DallETool()
|
||||
vision_processor = VisionTool()
|
||||
code_executor = CodeInterpreterTool()
|
||||
|
||||
# Add to your agent
|
||||
agent = Agent(
|
||||
role="AI Specialist",
|
||||
tools=[image_generator, vision_processor, code_executor],
|
||||
tools=[image_generator, vision_processor],
|
||||
goal="Create and analyze content using AI capabilities"
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ Under the hood, CrewAI employs a modular prompt system that you can customize ex
|
||||
- **Error handling** – Direct how agents respond to failures, exceptions, or timeouts.
|
||||
- **Tool-specific prompts** – Define detailed instructions for how tools are invoked or utilized.
|
||||
|
||||
Check out the [original prompt templates in CrewAI's repository](https://github.com/crewAIInc/crewAI/blob/main/src/crewai/translations/en.json) to see how these elements are organized. From there, you can override or adapt them as needed to unlock advanced behaviors.
|
||||
Check out the [original prompt templates in CrewAI's repository](https://github.com/crewAIInc/crewAI/blob/main/lib/crewai/src/crewai/translations/en.json) to see how these elements are organized. From there, you can override or adapt them as needed to unlock advanced behaviors.
|
||||
|
||||
## Understanding Default System Instructions
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ usage of tools, API calls, responses, any data processed by the agents, or secre
|
||||
When the `share_crew` feature is enabled, detailed data including task descriptions, agents' backstories or goals, and other specific attributes are collected
|
||||
to provide deeper insights. This expanded data collection may include personal information if users have incorporated it into their crews or tasks.
|
||||
Users should carefully consider the content of their crews and tasks before enabling `share_crew`.
|
||||
Users can disable telemetry by setting the environment variable `CREWAI_DISABLE_TELEMETRY` to `true` or by setting `OTEL_SDK_DISABLED` to `true` (note that the latter disables all OpenTelemetry instrumentation globally).
|
||||
Users can disable CrewAI telemetry by setting `CREWAI_DISABLE_TELEMETRY` to `true`, `1`, `yes`, or `on` (any case). `OTEL_SDK_DISABLED` with the same values also disables CrewAI's exporter. The OpenTelemetry SDK itself still only honors `true` for disabling other instrumentation in the process.
|
||||
|
||||
### Examples:
|
||||
```python
|
||||
@@ -34,6 +34,8 @@ os.environ['CREWAI_DISABLE_TELEMETRY'] = 'true'
|
||||
os.environ['OTEL_SDK_DISABLED'] = 'true'
|
||||
```
|
||||
|
||||
`CREWAI_DISABLE_TELEMETRY=1` (or `yes` / `on`) works the same as `true`. Unrecognized values are ignored and leave telemetry on.
|
||||
|
||||
### Isolation from your own OpenTelemetry setup
|
||||
|
||||
CrewAI's telemetry runs on its own private `TracerProvider` and never registers
|
||||
|
||||
@@ -50,16 +50,15 @@ These tools integrate with AI and machine learning services to enhance your agen
|
||||
- **AI Safety**: Implement content moderation and safety checks
|
||||
|
||||
```python
|
||||
from crewai_tools import DallETool, VisionTool, CodeInterpreterTool
|
||||
from crewai_tools import DallETool, VisionTool
|
||||
|
||||
# Create AI tools
|
||||
image_generator = DallETool()
|
||||
vision_processor = VisionTool()
|
||||
code_executor = CodeInterpreterTool()
|
||||
|
||||
# Add to your agent
|
||||
agent = Agent(
|
||||
role="AI Specialist",
|
||||
tools=[image_generator, vision_processor, code_executor],
|
||||
tools=[image_generator, vision_processor],
|
||||
goal="Create and analyze content using AI capabilities"
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ CrewAI의 기본 프롬프트는 많은 시나리오에서 잘 작동하지만,
|
||||
- **오류 처리** – agent가 실패, 예외, 또는 타임아웃에 어떻게 반응할지 지정합니다.
|
||||
- **도구별 prompt** – 도구가 호출되거나 사용되는 방법에 대한 상세 지침을 정의합니다.
|
||||
|
||||
이 요소들이 어떻게 구성되어 있는지 보려면 [CrewAI 저장소의 원본 prompt 템플릿](https://github.com/crewAIInc/crewAI/blob/main/src/crewai/translations/en.json)을 확인하세요. 여기서 필요에 따라 오버라이드하거나 수정하여 고급 동작을 구현할 수 있습니다.
|
||||
이 요소들이 어떻게 구성되어 있는지 보려면 [CrewAI 저장소의 원본 prompt 템플릿](https://github.com/crewAIInc/crewAI/blob/main/lib/crewai/src/crewai/translations/en.json)을 확인하세요. 여기서 필요에 따라 오버라이드하거나 수정하여 고급 동작을 구현할 수 있습니다.
|
||||
|
||||
## 기본 시스템 지침 이해하기
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ CrewAI는 익명 텔레메트리를 활용하여 사용 통계를 수집하며,
|
||||
`share_crew` 기능이 활성화되면, 보다 심층적인 통찰을 제공하기 위해 작업 설명, 에이전트의 배경 이야기나 목표, 기타 특정 속성 등 상세한 데이터가 수집됩니다.
|
||||
이 확대된 데이터 수집에는 사용자가 crew나 작업에 개인정보를 포함한 경우, 개인정보가 포함될 수 있습니다.
|
||||
사용자는 `share_crew`를 활성화하기 전에 crew와 작업의 내용을 신중하게 검토해야 합니다.
|
||||
사용자는 환경 변수 `CREWAI_DISABLE_TELEMETRY`를 `true`로 설정하거나, `OTEL_SDK_DISABLED`를 `true`로 설정하여 텔레메트리를 비활성화할 수 있습니다(후자의 경우 전체 OpenTelemetry 계측이 전역에서 비활성화된다는 점에 유의하십시오).
|
||||
사용자는 `CREWAI_DISABLE_TELEMETRY`를 `true`, `1`, `yes`, `on` 중 하나로 설정하여 CrewAI 텔레메트리를 비활성화할 수 있습니다(대소문자 무관). 같은 값의 `OTEL_SDK_DISABLED`도 CrewAI exporter를 끕니다. 프로세스 내 다른 OpenTelemetry 계측을 끄려면 OpenTelemetry SDK는 여전히 `true`만 인식합니다.
|
||||
|
||||
### 예시:
|
||||
```python
|
||||
@@ -33,6 +33,8 @@ os.environ['CREWAI_DISABLE_TELEMETRY'] = 'true'
|
||||
os.environ['OTEL_SDK_DISABLED'] = 'true'
|
||||
```
|
||||
|
||||
`CREWAI_DISABLE_TELEMETRY=1`(`yes` / `on`도 동일)은 `true`와 같습니다. 인식되지 않는 값은 무시되며 텔레메트리는 켜진 채로 남습니다.
|
||||
|
||||
### 사용자 OpenTelemetry 설정과의 격리
|
||||
|
||||
CrewAI의 telemetry는 자체 전용 `TracerProvider`에서 실행되며 자신을 전역
|
||||
|
||||
@@ -48,17 +48,16 @@ mode: "wide"
|
||||
- **AI 안전성**: 콘텐츠 모더레이션 및 안전성 점검 구현
|
||||
|
||||
```python
|
||||
from crewai_tools import DallETool, VisionTool, CodeInterpreterTool
|
||||
from crewai_tools import DallETool, VisionTool
|
||||
|
||||
# Create AI tools
|
||||
image_generator = DallETool()
|
||||
vision_processor = VisionTool()
|
||||
code_executor = CodeInterpreterTool()
|
||||
|
||||
# Add to your agent
|
||||
agent = Agent(
|
||||
role="AI Specialist",
|
||||
tools=[image_generator, vision_processor, code_executor],
|
||||
tools=[image_generator, vision_processor],
|
||||
goal="Create and analyze content using AI capabilities"
|
||||
)
|
||||
```
|
||||
@@ -26,7 +26,7 @@ Nos bastidores, o CrewAI adota um sistema de prompt modular que pode ser amplame
|
||||
- **Tratamento de erros** – Definem como os agentes respondem a falhas, exceções ou timeouts.
|
||||
- **Prompts específicos de ferramentas** – Definem instruções detalhadas para como as ferramentas são invocadas ou utilizadas.
|
||||
|
||||
Confira os [templates de prompt originais no repositório do CrewAI](https://github.com/crewAIInc/crewAI/blob/main/src/crewai/translations/en.json) para ver como esses elementos são organizados. A partir daí, você pode sobrescrever ou adaptar conforme necessário para desbloquear comportamentos avançados.
|
||||
Confira os [templates de prompt originais no repositório do CrewAI](https://github.com/crewAIInc/crewAI/blob/main/lib/crewai/src/crewai/translations/en.json) para ver como esses elementos são organizados. A partir daí, você pode sobrescrever ou adaptar conforme necessário para desbloquear comportamentos avançados.
|
||||
|
||||
## Entendendo as Instruções de Sistema Padrão
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ uso de ferramentas, chamadas de API, respostas, quaisquer dados processados pelo
|
||||
Quando o recurso `share_crew` está ativado, dados detalhados, incluindo descrições das tarefas, histórias ou objetivos dos agentes e outros atributos específicos são coletados
|
||||
para fornecer insights mais detalhados. Essa coleta expandida pode incluir informações pessoais caso o usuário as tenha inserido em seus crews ou tarefas.
|
||||
Usuários devem considerar cuidadosamente o conteúdo de seus crews e tarefas antes de habilitar o `share_crew`.
|
||||
A telemetria pode ser desabilitada ao definir a variável de ambiente `CREWAI_DISABLE_TELEMETRY` como `true` ou ao definir `OTEL_SDK_DISABLED` como `true` (observe que esta última desabilita toda instrumentação OpenTelemetry globalmente).
|
||||
A telemetria do CrewAI pode ser desabilitada ao definir `CREWAI_DISABLE_TELEMETRY` como `true`, `1`, `yes` ou `on` (qualquer capitalização). `OTEL_SDK_DISABLED` com os mesmos valores também desabilita o exportador do CrewAI. O SDK do OpenTelemetry em si ainda só reconhece `true` para desabilitar as demais instrumentações do processo.
|
||||
|
||||
### Exemplos:
|
||||
```python
|
||||
@@ -34,6 +34,8 @@ os.environ['CREWAI_DISABLE_TELEMETRY'] = 'true'
|
||||
os.environ['OTEL_SDK_DISABLED'] = 'true'
|
||||
```
|
||||
|
||||
`CREWAI_DISABLE_TELEMETRY=1` (ou `yes` / `on`) funciona como `true`. Valores não reconhecidos são ignorados e a telemetria permanece ligada.
|
||||
|
||||
### Isolamento da sua própria configuração do OpenTelemetry
|
||||
|
||||
A telemetria do CrewAI roda em seu próprio `TracerProvider` privado e nunca se
|
||||
|
||||
@@ -50,17 +50,16 @@ Essas ferramentas se integram com serviços de IA e machine learning para aprimo
|
||||
- **Segurança em IA**: Implemente moderação de conteúdo e checagens de segurança
|
||||
|
||||
```python
|
||||
from crewai_tools import DallETool, VisionTool, CodeInterpreterTool
|
||||
from crewai_tools import DallETool, VisionTool
|
||||
|
||||
# Create AI tools
|
||||
image_generator = DallETool()
|
||||
vision_processor = VisionTool()
|
||||
code_executor = CodeInterpreterTool()
|
||||
|
||||
# Add to your agent
|
||||
agent = Agent(
|
||||
role="AI Specialist",
|
||||
tools=[image_generator, vision_processor, code_executor],
|
||||
tools=[image_generator, vision_processor],
|
||||
goal="Create and analyze content using AI capabilities"
|
||||
)
|
||||
```
|
||||
|
||||
@@ -38,11 +38,6 @@ import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from crewai.project.json_loader import (
|
||||
JSONProjectValidationError,
|
||||
find_json_project_file,
|
||||
validate_crew_project,
|
||||
)
|
||||
from crewai_core.project import (
|
||||
ProjectDefinitionError,
|
||||
configured_project_definition,
|
||||
@@ -52,6 +47,8 @@ from crewai_core.project import (
|
||||
)
|
||||
from rich.console import Console
|
||||
|
||||
from crewai_cli.utils import normalize_package_name
|
||||
|
||||
|
||||
console = Console()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -108,15 +105,143 @@ _KNOWN_API_KEY_HINTS: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
def normalize_package_name(project_name: str) -> str:
|
||||
"""Normalize a pyproject project.name into a Python package directory name.
|
||||
_JSON_VALIDATION_MARKER = "CREWAI_JSON_VALIDATION_RESULT="
|
||||
_JSON_VALIDATOR_SCRIPT = f"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
Mirrors the rules in ``crewai.cli.create_crew.create_crew`` so the
|
||||
validator agrees with the scaffolder about where ``src/<pkg>/`` should
|
||||
live.
|
||||
"""
|
||||
folder = project_name.replace(" ", "_").replace("-", "_").lower()
|
||||
return re.sub(r"[^a-zA-Z0-9_]", "", folder)
|
||||
try:
|
||||
from crewai.project.json_loader import validate_crew_project
|
||||
project = validate_crew_project(sys.argv[1], agents_dir=sys.argv[2])
|
||||
payload = {{"ok": True, "agent_names": project.agent_names}}
|
||||
except BaseException as exc:
|
||||
errors = getattr(exc, "errors", None)
|
||||
payload = {{
|
||||
"ok": False,
|
||||
"error_type": type(exc).__name__,
|
||||
"error": str(exc),
|
||||
"errors": errors if isinstance(errors, list) else None,
|
||||
}}
|
||||
|
||||
print({_JSON_VALIDATION_MARKER!r} + json.dumps(payload))
|
||||
""".strip()
|
||||
|
||||
|
||||
class _JSONProjectValidationError(ValueError):
|
||||
def __init__(self, errors: list[str]) -> None:
|
||||
self.errors = errors
|
||||
super().__init__("\n".join(errors))
|
||||
|
||||
|
||||
class _JSONProjectEnvironmentError(RuntimeError):
|
||||
"""JSON validation could not run in the project's environment."""
|
||||
|
||||
hint = (
|
||||
"Install `uv` if needed, run `uv sync` in the project directory, "
|
||||
"then retry with `uv run crewai deploy validate`."
|
||||
)
|
||||
|
||||
|
||||
def _find_json_project_file(directory: Path, stem: str) -> Path | None:
|
||||
for extension in (".jsonc", ".json"):
|
||||
candidate = directory / f"{stem}{extension}"
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _validate_json_project_in_project_env(
|
||||
crew_path: Path, agents_dir: Path, project_root: Path
|
||||
) -> list[str]:
|
||||
"""Validate a JSON crew with the full CrewAI package from its project env."""
|
||||
uv_path = shutil.which("uv")
|
||||
if uv_path is None:
|
||||
raise _JSONProjectEnvironmentError(
|
||||
"The `uv` executable is required to validate JSON crews from a "
|
||||
"standalone CLI installation."
|
||||
)
|
||||
|
||||
try:
|
||||
proc = subprocess.run( # noqa: S603 - fixed command plus trusted paths
|
||||
[
|
||||
uv_path,
|
||||
"run",
|
||||
"python",
|
||||
"-c",
|
||||
_JSON_VALIDATOR_SCRIPT,
|
||||
str(crew_path),
|
||||
str(agents_dir),
|
||||
],
|
||||
cwd=project_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise _JSONProjectEnvironmentError(
|
||||
"JSON crew validation timed out after 120s."
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise _JSONProjectEnvironmentError(
|
||||
f"Could not start JSON crew validation: {exc}"
|
||||
) from exc
|
||||
|
||||
payload: dict[str, Any] | None = None
|
||||
for line in reversed(proc.stdout.splitlines()):
|
||||
if not line.startswith(_JSON_VALIDATION_MARKER):
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(line.removeprefix(_JSON_VALIDATION_MARKER))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
break
|
||||
|
||||
if payload is None:
|
||||
detail = (proc.stderr or proc.stdout or "").strip()
|
||||
raise _JSONProjectEnvironmentError(
|
||||
detail or "JSON crew validation produced no result."
|
||||
)
|
||||
|
||||
if not payload.get("ok"):
|
||||
errors = payload.get("errors")
|
||||
if isinstance(errors, list) and all(isinstance(error, str) for error in errors):
|
||||
raise _JSONProjectValidationError(errors)
|
||||
error_type = payload.get("error_type", "Error")
|
||||
error = payload.get("error", "JSON crew validation failed")
|
||||
raise _JSONProjectEnvironmentError(f"{error_type}: {error}")
|
||||
|
||||
agent_names = payload.get("agent_names")
|
||||
if not isinstance(agent_names, list) or not all(
|
||||
isinstance(name, str) for name in agent_names
|
||||
):
|
||||
raise _JSONProjectEnvironmentError(
|
||||
"JSON crew validation returned invalid agent names."
|
||||
)
|
||||
return agent_names
|
||||
|
||||
|
||||
def _validate_json_project(
|
||||
crew_path: Path, agents_dir: Path, project_root: Path
|
||||
) -> list[str]:
|
||||
"""Validate locally when possible, otherwise use the project's environment."""
|
||||
try:
|
||||
from crewai.project.json_loader import (
|
||||
JSONProjectValidationError,
|
||||
validate_crew_project,
|
||||
)
|
||||
except ModuleNotFoundError as exc:
|
||||
if exc.name and (exc.name == "crewai" or exc.name.startswith("crewai.")):
|
||||
return _validate_json_project_in_project_env(
|
||||
crew_path, agents_dir, project_root
|
||||
)
|
||||
raise
|
||||
|
||||
try:
|
||||
project = validate_crew_project(crew_path, agents_dir)
|
||||
except JSONProjectValidationError as exc:
|
||||
raise _JSONProjectValidationError(exc.errors) from exc
|
||||
return project.agent_names
|
||||
|
||||
|
||||
class DeployValidator:
|
||||
@@ -232,11 +357,13 @@ class DeployValidator:
|
||||
agents_dir = crew_path.parent / "agents"
|
||||
agents_dir_ok = self._check_json_agents_dir(agents_dir)
|
||||
|
||||
project = None
|
||||
agent_names: list[str] | None = None
|
||||
try:
|
||||
if agents_dir_ok:
|
||||
project = validate_crew_project(crew_path, agents_dir)
|
||||
except JSONProjectValidationError as e:
|
||||
agent_names = _validate_json_project(
|
||||
crew_path, agents_dir, self.project_root
|
||||
)
|
||||
except _JSONProjectValidationError as e:
|
||||
self._add(
|
||||
Severity.ERROR,
|
||||
"invalid_crew_json",
|
||||
@@ -245,6 +372,15 @@ class DeployValidator:
|
||||
hint="Fix the JSON crew, agent, and task references before deploying.",
|
||||
)
|
||||
return self.results
|
||||
except _JSONProjectEnvironmentError as e:
|
||||
self._add(
|
||||
Severity.ERROR,
|
||||
"json_validation_environment_failed",
|
||||
"Could not validate the JSON crew in the project environment",
|
||||
detail=str(e),
|
||||
hint=e.hint,
|
||||
)
|
||||
return self.results
|
||||
except Exception as e:
|
||||
self._add(
|
||||
Severity.ERROR,
|
||||
@@ -254,8 +390,8 @@ class DeployValidator:
|
||||
)
|
||||
return self.results
|
||||
|
||||
if project is not None:
|
||||
self._check_env_vars_json(crew_path, agents_dir, project.agent_names)
|
||||
if agent_names is not None:
|
||||
self._check_env_vars_json(crew_path, agents_dir, agent_names)
|
||||
self._check_version_vs_lockfile()
|
||||
|
||||
return self.results
|
||||
@@ -288,7 +424,7 @@ class DeployValidator:
|
||||
logger.debug("Skipping unreadable crew file %s: %s", crew_path, exc)
|
||||
|
||||
for name in agent_names:
|
||||
agent_path = find_json_project_file(agents_dir, name)
|
||||
agent_path = _find_json_project_file(agents_dir, name)
|
||||
if agent_path is None:
|
||||
continue
|
||||
try:
|
||||
|
||||
@@ -4,8 +4,7 @@ import subprocess
|
||||
import click
|
||||
from crewai_core.project import configured_project_definition, read_toml
|
||||
|
||||
from crewai_cli.deploy.validate import normalize_package_name
|
||||
from crewai_cli.utils import build_env_with_all_tool_credentials
|
||||
from crewai_cli.utils import build_env_with_all_tool_credentials, normalize_package_name
|
||||
|
||||
|
||||
def _is_json_crew_project(project_root: Path | None = None) -> bool:
|
||||
|
||||
@@ -39,6 +39,7 @@ __all__ = [
|
||||
"get_project_version",
|
||||
"is_dmn_mode_enabled",
|
||||
"load_env_vars",
|
||||
"normalize_package_name",
|
||||
"parse_toml",
|
||||
"read_toml",
|
||||
"render_template",
|
||||
@@ -67,6 +68,12 @@ console = Console()
|
||||
_TEMPLATE_TOKEN_RE = re.compile(r"{{([a-zA-Z_][a-zA-Z0-9_]*)}}")
|
||||
|
||||
|
||||
def normalize_package_name(project_name: str) -> str:
|
||||
"""Normalize a project name into its scaffolded Python package name."""
|
||||
folder = project_name.replace(" ", "_").replace("-", "_").lower()
|
||||
return re.sub(r"[^a-zA-Z0-9_]", "", folder)
|
||||
|
||||
|
||||
def is_dmn_mode_enabled() -> bool:
|
||||
"""Return True when the enterprise non-interactive mode is enabled."""
|
||||
value = os.environ.get("CREWAI_DMN")
|
||||
|
||||
148
lib/cli/tests/deploy/test_cli_only_install.py
Normal file
148
lib/cli/tests/deploy/test_cli_only_install.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""Regression coverage for crewai-cli installed without the full crewai package."""
|
||||
|
||||
import builtins
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import crewai_cli.deploy.validate as validate_module
|
||||
|
||||
|
||||
def test_reported_commands_run_without_crewai() -> None:
|
||||
script = r"""
|
||||
import builtins
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
os.environ["CREWAI_DISABLE_TELEMETRY"] = "true"
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def import_without_crewai(name, *args, **kwargs):
|
||||
if name == "crewai" or name.startswith("crewai."):
|
||||
raise ModuleNotFoundError("No module named 'crewai'", name="crewai")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
builtins.__import__ = import_without_crewai
|
||||
|
||||
from crewai_cli.cli import crewai
|
||||
import crewai_cli.command as command_module
|
||||
import crewai_cli.install_crew as install_module
|
||||
|
||||
def not_logged_in():
|
||||
raise RuntimeError("not logged in")
|
||||
|
||||
command_module.get_auth_token = not_logged_in
|
||||
install_module.build_env_with_all_tool_credentials = lambda: {}
|
||||
install_module.subprocess.run = lambda *args, **kwargs: subprocess.CompletedProcess(
|
||||
args[0], 0
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
with runner.isolated_filesystem():
|
||||
Path("pyproject.toml").write_text('[project]\nname = "demo"\n')
|
||||
|
||||
deploy_result = runner.invoke(crewai, ["deploy", "list"])
|
||||
assert deploy_result.exit_code == 0, deploy_result.output
|
||||
assert "Please sign up/login" in deploy_result.output
|
||||
assert not isinstance(deploy_result.exception, ModuleNotFoundError)
|
||||
|
||||
install_result = runner.invoke(crewai, ["install"])
|
||||
assert install_result.exit_code == 0, install_result.output
|
||||
assert not isinstance(install_result.exception, ModuleNotFoundError)
|
||||
"""
|
||||
|
||||
proc = subprocess.run( # noqa: S603
|
||||
[sys.executable, "-c", script],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
|
||||
|
||||
def test_json_validation_uses_project_environment_without_crewai(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
real_import = builtins.__import__
|
||||
|
||||
def import_without_crewai(name: str, *args: Any, **kwargs: Any) -> Any:
|
||||
if name == "crewai" or name.startswith("crewai."):
|
||||
raise ModuleNotFoundError("No module named 'crewai'", name="crewai")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_run(
|
||||
command: list[str], **kwargs: Any
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
captured["command"] = command
|
||||
captured["kwargs"] = kwargs
|
||||
payload = {"ok": True, "agent_names": ["researcher"]}
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
stdout=(
|
||||
"uv output\n"
|
||||
f"{validate_module._JSON_VALIDATION_MARKER}{json.dumps(payload)}\n"
|
||||
),
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", import_without_crewai)
|
||||
monkeypatch.setattr(shutil, "which", lambda command: "/usr/bin/uv")
|
||||
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||
|
||||
crew_path = tmp_path / "crew.jsonc"
|
||||
agents_dir = tmp_path / "agents"
|
||||
assert validate_module._validate_json_project(
|
||||
crew_path, agents_dir, tmp_path
|
||||
) == ["researcher"]
|
||||
|
||||
assert captured["command"][:4] == ["/usr/bin/uv", "run", "python", "-c"]
|
||||
assert captured["kwargs"]["cwd"] == tmp_path
|
||||
|
||||
|
||||
def test_project_environment_preserves_json_validation_errors(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
payload = {"ok": False, "errors": ["tasks[0] references missing_agent"]}
|
||||
proc = subprocess.CompletedProcess(
|
||||
[],
|
||||
0,
|
||||
stdout=f"{validate_module._JSON_VALIDATION_MARKER}{json.dumps(payload)}\n",
|
||||
stderr="",
|
||||
)
|
||||
monkeypatch.setattr(shutil, "which", lambda command: "/usr/bin/uv")
|
||||
monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: proc)
|
||||
|
||||
with pytest.raises(validate_module._JSONProjectValidationError) as exc_info:
|
||||
validate_module._validate_json_project_in_project_env(
|
||||
tmp_path / "crew.jsonc", tmp_path / "agents", tmp_path
|
||||
)
|
||||
|
||||
assert exc_info.value.errors == ["tasks[0] references missing_agent"]
|
||||
|
||||
|
||||
def test_missing_uv_has_an_actionable_project_environment_error(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(shutil, "which", lambda command: None)
|
||||
|
||||
with pytest.raises(validate_module._JSONProjectEnvironmentError) as exc_info:
|
||||
validate_module._validate_json_project_in_project_env(
|
||||
tmp_path / "crew.jsonc", tmp_path / "agents", tmp_path
|
||||
)
|
||||
|
||||
assert "required" in str(exc_info.value)
|
||||
assert "Install `uv`" in exc_info.value.hint
|
||||
assert "uv sync" in exc_info.value.hint
|
||||
@@ -16,6 +16,7 @@ import pytest
|
||||
from crewai_cli.deploy.validate import (
|
||||
DeployValidator,
|
||||
Severity,
|
||||
_JSONProjectEnvironmentError,
|
||||
normalize_package_name,
|
||||
)
|
||||
|
||||
@@ -205,6 +206,33 @@ def test_json_runtime_fields_are_deploy_errors(tmp_path: Path) -> None:
|
||||
assert "runtime-only" in finding.detail
|
||||
|
||||
|
||||
def test_json_project_environment_failure_has_actionable_hint(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
_scaffold_json_crew(tmp_path)
|
||||
|
||||
def fail_in_project_environment(*args: object) -> list[str]:
|
||||
raise _JSONProjectEnvironmentError("uv run failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"crewai_cli.deploy.validate._validate_json_project",
|
||||
fail_in_project_environment,
|
||||
)
|
||||
|
||||
validator = DeployValidator(project_root=tmp_path)
|
||||
validator.run()
|
||||
|
||||
finding = next(
|
||||
result
|
||||
for result in validator.results
|
||||
if result.code == "json_validation_environment_failed"
|
||||
)
|
||||
assert finding.title == "Could not validate the JSON crew in the project environment"
|
||||
assert finding.detail == "uv run failed"
|
||||
assert "Install `uv`" in finding.hint
|
||||
assert "uv sync" in finding.hint
|
||||
|
||||
|
||||
def test_json_crew_requires_agents_dir_without_classic_errors(tmp_path: Path) -> None:
|
||||
_scaffold_json_crew(tmp_path)
|
||||
for path in (tmp_path / "agents").iterdir():
|
||||
|
||||
@@ -181,6 +181,9 @@ class Telemetry:
|
||||
|
||||
_instance: ClassVar[Self | None] = None
|
||||
_lock: ClassVar[threading.Lock] = threading.Lock()
|
||||
_TRUTHY_ENV: ClassVar[frozenset[str]] = frozenset({"1", "on", "true", "yes"})
|
||||
_FALSY_ENV: ClassVar[frozenset[str]] = frozenset({"", "0", "false", "no", "off"})
|
||||
_warned_env_flags: ClassVar[set[tuple[str, str]]] = set()
|
||||
|
||||
def __new__(cls) -> Self:
|
||||
if cls._instance is None:
|
||||
@@ -233,12 +236,39 @@ class Telemetry:
|
||||
raise
|
||||
self.ready = False
|
||||
|
||||
@classmethod
|
||||
def _env_flag_enabled(cls, name: str, *, default: bool = False) -> bool:
|
||||
"""Return whether ``name`` is a conventional yes-value.
|
||||
|
||||
Yes: ``true``, ``1``, ``yes``, ``on``. No: unset, ``false``, ``0``,
|
||||
``no``, ``off``, empty. Anything else is treated as unset and logged
|
||||
once per ``(name, raw)`` pair for the process.
|
||||
"""
|
||||
raw = os.getenv(name)
|
||||
if raw is None:
|
||||
return default
|
||||
value = raw.strip().lower()
|
||||
if value in cls._TRUTHY_ENV:
|
||||
return True
|
||||
if value in cls._FALSY_ENV:
|
||||
return False
|
||||
warning_key = (name, raw)
|
||||
if warning_key not in cls._warned_env_flags:
|
||||
cls._warned_env_flags.add(warning_key)
|
||||
logger.warning(
|
||||
"Unrecognized value %r for %s; expected true/1/yes/on or "
|
||||
"false/0/no/off. Treating as unset.",
|
||||
raw,
|
||||
name,
|
||||
)
|
||||
return default
|
||||
|
||||
@classmethod
|
||||
def _is_telemetry_disabled(cls) -> bool:
|
||||
return (
|
||||
os.getenv("OTEL_SDK_DISABLED", "false").lower() == "true"
|
||||
or os.getenv("CREWAI_DISABLE_TELEMETRY", "false").lower() == "true"
|
||||
or os.getenv("CREWAI_DISABLE_TRACKING", "false").lower() == "true"
|
||||
cls._env_flag_enabled("OTEL_SDK_DISABLED")
|
||||
or cls._env_flag_enabled("CREWAI_DISABLE_TELEMETRY")
|
||||
or cls._env_flag_enabled("CREWAI_DISABLE_TRACKING")
|
||||
)
|
||||
|
||||
def _should_execute_telemetry(self) -> bool:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
@@ -175,6 +176,48 @@ def test_configured_project_definition_rejects_empty_definition(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["true", "TRUE", "1", "yes", "on", " yes "])
|
||||
def test_core_telemetry_disabled_by_conventional_yes_values(
|
||||
monkeypatch: pytest.MonkeyPatch, value: str
|
||||
) -> None:
|
||||
from crewai_core.telemetry import Telemetry
|
||||
|
||||
monkeypatch.setenv("CREWAI_DISABLE_TELEMETRY", value)
|
||||
monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False)
|
||||
monkeypatch.delenv("CREWAI_DISABLE_TRACKING", raising=False)
|
||||
assert Telemetry._is_telemetry_disabled() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["false", "0", "no", "off", ""])
|
||||
def test_core_telemetry_stays_enabled_for_conventional_no_values(
|
||||
monkeypatch: pytest.MonkeyPatch, value: str
|
||||
) -> None:
|
||||
from crewai_core.telemetry import Telemetry
|
||||
|
||||
monkeypatch.setenv("CREWAI_DISABLE_TELEMETRY", value)
|
||||
monkeypatch.delenv("OTEL_SDK_DISABLED", raising=False)
|
||||
monkeypatch.delenv("CREWAI_DISABLE_TRACKING", raising=False)
|
||||
assert Telemetry._is_telemetry_disabled() is False
|
||||
|
||||
|
||||
def test_core_telemetry_unrecognized_disable_value_warns_once(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
from crewai_core.telemetry import Telemetry
|
||||
|
||||
monkeypatch.setenv("CREWAI_DISABLE_TELEMETRY", "maybe")
|
||||
Telemetry._warned_env_flags.clear()
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="crewai_core.telemetry"):
|
||||
assert Telemetry._env_flag_enabled("CREWAI_DISABLE_TELEMETRY") is False
|
||||
assert Telemetry._env_flag_enabled("CREWAI_DISABLE_TELEMETRY") is False
|
||||
|
||||
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert len(warnings) == 1
|
||||
assert "CREWAI_DISABLE_TELEMETRY" in warnings[0].getMessage()
|
||||
assert "maybe" in warnings[0].getMessage()
|
||||
|
||||
|
||||
def test_core_telemetry_never_installs_a_global_provider(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -9,7 +9,7 @@ authors = [
|
||||
requires-python = ">=3.10, <3.14"
|
||||
dependencies = [
|
||||
"Pillow~=12.3.0",
|
||||
"pypdf~=6.14.2",
|
||||
"pypdf~=6.16.1",
|
||||
"python-magic>=0.4.27",
|
||||
"aiocache~=0.12.3",
|
||||
"aiofiles~=24.1.0",
|
||||
@@ -19,8 +19,6 @@ dependencies = [
|
||||
|
||||
[tool.uv]
|
||||
exclude-newer = "3 days"
|
||||
# pypdf 6.14.2 is a security fix newer than the global supply-chain cutoff.
|
||||
exclude-newer-package = { pypdf = "2026-06-24T00:00:00Z" }
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -107,12 +107,11 @@ stagehand = [
|
||||
"stagehand>=0.4.1",
|
||||
]
|
||||
github = [
|
||||
# <3.1.58 has GHSA-p538-c434-8v24 (arbitrary file truncation),
|
||||
# GHSA-3f7w-8rr8-f37f (unguarded git option forwarding),
|
||||
# GHSA-9rj7-rf2p-w77r, GHSA-4gmw-gg2m-w46p, GHSA-hh9p-6wh2-4mfc,
|
||||
# GHSA-wvpp-8hx9-p66j and GHSA-jm78-9fvv-mhgr (further unguarded git
|
||||
# option forwarding / arbitrary file read); force 3.1.58+.
|
||||
"gitpython>=3.1.58,<4",
|
||||
# <3.1.59 has PYSEC-2026-3785/GHSA-7833-fr7j-v32q,
|
||||
# PYSEC-2026-3786/GHSA-284h-m62q-gf8w, PYSEC-2026-3787/GHSA-8mcc-hrx5-hvxc,
|
||||
# and PYSEC-2026-3788/GHSA-5xxx-qhh7-9287. 3.1.60 hardens config escapes,
|
||||
# diff/actor parsing, and filesystem diffs; force 3.1.60+.
|
||||
"gitpython>=3.1.60,<4",
|
||||
"PyGithub==1.59.1",
|
||||
]
|
||||
rag = [
|
||||
@@ -123,8 +122,10 @@ xml = [
|
||||
"unstructured[local-inference, all-docs]>=0.17.2",
|
||||
# unstructured allows nltk>=3.9.2, but <3.10.3 still has PYSEC-2026-3726
|
||||
# (symlink file read in IPIPANCorpusReader; 3.10.0-3.10.1) plus later
|
||||
# 3.10.2 findings. Declared here, not only as a uv override, so consumers
|
||||
# installing crewai-tools[xml] get the fixed version.
|
||||
# 3.10.2 findings. 3.10.3 still has unpatched GHSA-8mgp-746c-j5xp
|
||||
# (ignored in pip-audit until a release ships). TODO: drop that ignore
|
||||
# when bumping nltk past 3.10.3. Declared here, not only as a uv
|
||||
# override, so consumers installing crewai-tools[xml] get this floor.
|
||||
"nltk>=3.10.3",
|
||||
]
|
||||
oxylabs = [
|
||||
|
||||
@@ -7,9 +7,6 @@ through the CrewAI platform API.
|
||||
from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import (
|
||||
CrewAIPlatformActionTool,
|
||||
)
|
||||
from crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder import (
|
||||
CrewaiPlatformToolBuilder,
|
||||
)
|
||||
from crewai_tools.tools.crewai_platform_tools.crewai_platform_tools import (
|
||||
CrewaiPlatformTools,
|
||||
)
|
||||
@@ -17,6 +14,5 @@ from crewai_tools.tools.crewai_platform_tools.crewai_platform_tools import (
|
||||
|
||||
__all__ = [
|
||||
"CrewAIPlatformActionTool",
|
||||
"CrewaiPlatformToolBuilder",
|
||||
"CrewaiPlatformTools",
|
||||
]
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class ApplicationSelector:
|
||||
"""Parse an application selector.
|
||||
|
||||
Selectors use the ``application[/action][@connection_uuid]`` syntax.
|
||||
|
||||
Raises:
|
||||
ValueError: If the selector does not follow the supported syntax.
|
||||
"""
|
||||
|
||||
name: str
|
||||
action: str | None
|
||||
connection_id: UUID | None
|
||||
|
||||
def __init__(self, value: str) -> None:
|
||||
if not value:
|
||||
raise ValueError(f"Invalid application selector {value!r}: cannot be empty")
|
||||
if "@" in value and "/" in value and value.index("@") < value.index("/"):
|
||||
raise ValueError(
|
||||
f"Invalid application selector {value!r}: "
|
||||
"connection ID must be the last segment"
|
||||
)
|
||||
|
||||
app, connection_separator, connection_id = value.partition("@")
|
||||
name, action_separator, action = app.partition("/")
|
||||
|
||||
if not name:
|
||||
raise ValueError(
|
||||
f"Invalid application selector {value!r}: application cannot be empty"
|
||||
)
|
||||
if action_separator and not action:
|
||||
raise ValueError(
|
||||
f"Invalid application selector {value!r}: action cannot be empty"
|
||||
)
|
||||
if connection_separator and not connection_id:
|
||||
raise ValueError(
|
||||
f"Invalid application selector {value!r}: connection ID cannot be empty"
|
||||
)
|
||||
|
||||
parsed_connection_id = None
|
||||
if connection_id:
|
||||
try:
|
||||
parsed_connection_id = UUID(connection_id)
|
||||
except ValueError as error:
|
||||
raise ValueError(
|
||||
f"Invalid application selector {value!r}: "
|
||||
"connection ID must be a valid UUID"
|
||||
) from error
|
||||
|
||||
self.name = name
|
||||
self.action = action if action_separator else None
|
||||
self.connection_id = parsed_connection_id
|
||||
@@ -1,108 +1,76 @@
|
||||
"""Crewai Enterprise Tools."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
from crewai.tools.tool_failure import ToolFailure
|
||||
from crewai.utilities.pydantic_schema_utils import create_model_from_schema
|
||||
from pydantic import Field, create_model
|
||||
import requests
|
||||
from pydantic import Field, PrivateAttr, create_model
|
||||
|
||||
from crewai_tools.tools.crewai_platform_tools.misc import (
|
||||
get_platform_api_base_url,
|
||||
get_platform_integration_token,
|
||||
from crewai_tools.tools.crewai_platform_tools.integrations_client import (
|
||||
IntegrationsClient,
|
||||
LegacyClient,
|
||||
ToolExecutionFailure,
|
||||
ToolInfo,
|
||||
)
|
||||
|
||||
|
||||
class CrewAIPlatformActionTool(BaseTool):
|
||||
_client: IntegrationsClient = PrivateAttr()
|
||||
_tool_info: ToolInfo = PrivateAttr()
|
||||
app: str = Field(description="The integration slug for this action")
|
||||
action_name: str = Field(default="", description="The name of the action")
|
||||
action_schema: dict[str, Any] = Field(
|
||||
default_factory=dict, description="The schema of the action"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
description: str,
|
||||
app: str,
|
||||
action_name: str,
|
||||
action_schema: dict[str, Any],
|
||||
):
|
||||
parameters = action_schema.get("function", {}).get("parameters", {})
|
||||
tool_info: ToolInfo,
|
||||
client: IntegrationsClient | None = None,
|
||||
) -> None:
|
||||
schema_name = f"{tool_info.qualified_name}Schema"
|
||||
parameters = tool_info.parameters
|
||||
|
||||
if parameters and parameters.get("properties"):
|
||||
try:
|
||||
if "title" not in parameters:
|
||||
parameters = {**parameters, "title": f"{action_name}Schema"}
|
||||
parameters = {**parameters, "title": schema_name}
|
||||
if "type" not in parameters:
|
||||
parameters = {**parameters, "type": "object"}
|
||||
args_schema = create_model_from_schema(parameters)
|
||||
except Exception:
|
||||
args_schema = create_model(f"{action_name}Schema")
|
||||
args_schema = create_model(schema_name)
|
||||
else:
|
||||
args_schema = create_model(f"{action_name}Schema")
|
||||
args_schema = create_model(schema_name)
|
||||
|
||||
super().__init__(
|
||||
name=action_name.lower().replace(" ", "_"),
|
||||
description=description,
|
||||
name=tool_info.qualified_name,
|
||||
description=tool_info.description,
|
||||
args_schema=args_schema,
|
||||
app=app,
|
||||
app=tool_info.app,
|
||||
)
|
||||
self.action_name = action_name
|
||||
self.action_schema = action_schema
|
||||
self._client = client if client is not None else LegacyClient()
|
||||
self._tool_info = tool_info
|
||||
|
||||
def _run(self, **kwargs: Any) -> Any:
|
||||
def _run(self, **kwargs: Any) -> str | ToolFailure:
|
||||
try:
|
||||
cleaned_kwargs = {
|
||||
key: value for key, value in kwargs.items() if value is not None
|
||||
}
|
||||
|
||||
api_url = (
|
||||
f"{get_platform_api_base_url()}/actions/{self.action_name}/execute"
|
||||
)
|
||||
token = get_platform_integration_token()
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"integration": cleaned_kwargs if cleaned_kwargs else {"_noop": True}
|
||||
}
|
||||
result = self._client.execute_action(self._tool_info, cleaned_kwargs)
|
||||
|
||||
response = requests.post(
|
||||
url=api_url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=60,
|
||||
verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true",
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
if not response.ok:
|
||||
if isinstance(data, dict):
|
||||
error_info = data.get("error", {})
|
||||
if isinstance(error_info, dict):
|
||||
error_message = error_info.get("message", json.dumps(data))
|
||||
else:
|
||||
error_message = str(error_info)
|
||||
else:
|
||||
error_message = str(data)
|
||||
# A non-2xx here means the upstream app rejected the action
|
||||
# (e.g. Slack's channel_not_found) -- report it, not prose.
|
||||
if isinstance(result, ToolExecutionFailure):
|
||||
return ToolFailure(
|
||||
message=f"API request failed: {error_message}",
|
||||
code=str(response.status_code),
|
||||
retryable=response.status_code >= 500,
|
||||
details={"action": self.action_name},
|
||||
message=f"API request failed: {result.message}",
|
||||
code=result.code,
|
||||
retryable=result.retryable,
|
||||
details={"action": self._tool_info.action},
|
||||
)
|
||||
|
||||
return json.dumps(data, indent=2)
|
||||
return json.dumps(result.output, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return ToolFailure(
|
||||
message=f"Error executing action {self.action_name}: {e!s}",
|
||||
message=f"Error executing action {self._tool_info.action}: {e!s}",
|
||||
code=e.__class__.__name__,
|
||||
details={"action": self.action_name},
|
||||
details={"action": self._tool_info.action},
|
||||
)
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
"""CrewAI platform tool builder for fetching and creating action tools."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from types import TracebackType
|
||||
from typing import Any
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
import requests
|
||||
|
||||
from crewai_tools.tools.crewai_platform_tools.application_selector import (
|
||||
ApplicationSelector,
|
||||
)
|
||||
from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import (
|
||||
CrewAIPlatformActionTool,
|
||||
)
|
||||
from crewai_tools.tools.crewai_platform_tools.misc import (
|
||||
get_platform_api_base_url,
|
||||
get_platform_integration_token,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CrewaiPlatformToolBuilder:
|
||||
"""Builds platform tools from remote action schemas."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
apps: list[str],
|
||||
) -> None:
|
||||
self._apps = [ApplicationSelector(app) for app in apps]
|
||||
self._actions_schema: dict[str, dict[str, Any]] = {}
|
||||
self._tools: list[BaseTool] | None = None
|
||||
|
||||
def tools(self) -> list[BaseTool]:
|
||||
"""Fetch actions and return built tools."""
|
||||
if self._tools is None:
|
||||
self._fetch_actions()
|
||||
self._create_tools()
|
||||
return self._tools if self._tools is not None else []
|
||||
|
||||
def _fetch_actions(self) -> None:
|
||||
"""Fetch action schemas from the platform API."""
|
||||
actions_url = f"{get_platform_api_base_url()}/actions"
|
||||
headers = {"Authorization": f"Bearer {get_platform_integration_token()}"}
|
||||
apps = [
|
||||
f"{app.name}/{app.action}" if app.action is not None else app.name
|
||||
for app in self._apps
|
||||
]
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
actions_url,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
params={"apps": ",".join(apps)},
|
||||
verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true",
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch platform tools for apps {apps}: {e}")
|
||||
return
|
||||
|
||||
raw_data = response.json()
|
||||
|
||||
self._actions_schema = {}
|
||||
action_categories = raw_data.get("actions", {})
|
||||
|
||||
for app, action_list in action_categories.items():
|
||||
if isinstance(action_list, list):
|
||||
for action in action_list:
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
if action_name := action.get("name"):
|
||||
action_schema = {
|
||||
"function": {
|
||||
"name": action_name,
|
||||
"description": action.get(
|
||||
"description", f"Execute {action_name}"
|
||||
),
|
||||
"parameters": action.get("parameters", {}),
|
||||
"app": app,
|
||||
}
|
||||
}
|
||||
self._actions_schema[action_name] = action_schema
|
||||
|
||||
def _create_tools(self) -> None:
|
||||
"""Create tool instances from fetched action schemas."""
|
||||
tools: list[BaseTool] = []
|
||||
|
||||
for action_name, action_schema in self._actions_schema.items():
|
||||
function_details = action_schema.get("function", {})
|
||||
description = function_details.get("description", f"Execute {action_name}")
|
||||
|
||||
tool = CrewAIPlatformActionTool(
|
||||
description=description,
|
||||
app=function_details["app"],
|
||||
action_name=action_name,
|
||||
action_schema=action_schema,
|
||||
)
|
||||
|
||||
tools.append(tool)
|
||||
|
||||
self._tools = tools
|
||||
|
||||
def __enter__(self) -> list[BaseTool]:
|
||||
"""Enter context manager and return tools."""
|
||||
return self.tools()
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: TracebackType | None,
|
||||
) -> None:
|
||||
"""Exit context manager."""
|
||||
@@ -2,9 +2,12 @@ import logging
|
||||
|
||||
from crewai.tools import BaseTool
|
||||
|
||||
from crewai_tools.adapters.tool_collection import ToolCollection
|
||||
from crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder import (
|
||||
CrewaiPlatformToolBuilder,
|
||||
from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import (
|
||||
CrewAIPlatformActionTool,
|
||||
)
|
||||
from crewai_tools.tools.crewai_platform_tools.integrations_client import (
|
||||
ApplicationSelector,
|
||||
client_for_selector,
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def CrewaiPlatformTools( # noqa: N802
|
||||
apps: list[str],
|
||||
) -> ToolCollection[BaseTool]:
|
||||
) -> list[BaseTool]:
|
||||
"""Factory function that returns crewai platform tools.
|
||||
|
||||
Args:
|
||||
@@ -22,6 +25,20 @@ def CrewaiPlatformTools( # noqa: N802
|
||||
Returns:
|
||||
A list of BaseTool instances for platform actions
|
||||
"""
|
||||
builder = CrewaiPlatformToolBuilder(apps=apps)
|
||||
selectors = [ApplicationSelector.from_string(app) for app in apps]
|
||||
tools: list[BaseTool] = []
|
||||
|
||||
return builder.tools() # type: ignore
|
||||
try:
|
||||
for selector in selectors:
|
||||
client = client_for_selector(selector)
|
||||
tools.extend(
|
||||
CrewAIPlatformActionTool(tool_info, client=client)
|
||||
for tool_info in client.get_actions([selector])
|
||||
)
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.error(f"Failed to fetch platform tools for apps {apps}: {error}")
|
||||
return []
|
||||
|
||||
return tools
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Contract and default client for platform integrations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from typing import Any, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
from crewai.utilities.string_utils import sanitize_tool_name
|
||||
from crewai_core.plus_api import PlusAPI
|
||||
import requests
|
||||
|
||||
from crewai_tools.tools.crewai_platform_tools.misc import (
|
||||
get_platform_integration_token,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplicationSelector:
|
||||
"""Represent an application selector."""
|
||||
|
||||
app: str
|
||||
action: str | None
|
||||
connection_id: UUID | None
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, value: str) -> ApplicationSelector:
|
||||
"""Parse the ``application[/action][@connection_uuid]`` syntax.
|
||||
|
||||
Raises:
|
||||
ValueError: If the selector does not follow the supported syntax.
|
||||
"""
|
||||
if not value:
|
||||
raise ValueError(f"Invalid application selector {value!r}: cannot be empty")
|
||||
if "@" in value and "/" in value and value.index("@") < value.index("/"):
|
||||
raise ValueError(
|
||||
f"Invalid application selector {value!r}: "
|
||||
"connection ID must be the last segment"
|
||||
)
|
||||
|
||||
app_and_action, connection_separator, connection_id = value.partition("@")
|
||||
app, action_separator, action = app_and_action.partition("/")
|
||||
|
||||
if not app:
|
||||
raise ValueError(
|
||||
f"Invalid application selector {value!r}: application cannot be empty"
|
||||
)
|
||||
if action_separator and not action:
|
||||
raise ValueError(
|
||||
f"Invalid application selector {value!r}: action cannot be empty"
|
||||
)
|
||||
if connection_separator and not connection_id:
|
||||
raise ValueError(
|
||||
f"Invalid application selector {value!r}: connection ID cannot be empty"
|
||||
)
|
||||
|
||||
parsed_connection_id = None
|
||||
if connection_id:
|
||||
try:
|
||||
parsed_connection_id = UUID(connection_id)
|
||||
except ValueError as error:
|
||||
raise ValueError(
|
||||
f"Invalid application selector {value!r}: "
|
||||
"connection ID must be a valid UUID"
|
||||
) from error
|
||||
|
||||
return cls(
|
||||
app=app,
|
||||
action=action if action_separator else None,
|
||||
connection_id=parsed_connection_id,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolInfo:
|
||||
"""Describe a normalized platform action."""
|
||||
|
||||
app: str
|
||||
action: str
|
||||
connection_id: UUID | None
|
||||
description: str
|
||||
parameters: dict[str, Any]
|
||||
|
||||
@property
|
||||
def qualified_name(self) -> str:
|
||||
"""Return the qualified tool name."""
|
||||
parts = [self.app, self.action]
|
||||
if self.connection_id is not None:
|
||||
parts.append(str(self.connection_id))
|
||||
return sanitize_tool_name("_".join(parts))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolExecutionSuccess:
|
||||
"""Represent a successful platform action execution."""
|
||||
|
||||
output: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolExecutionFailure:
|
||||
"""Represent an expected platform action failure."""
|
||||
|
||||
message: str
|
||||
code: str
|
||||
retryable: bool
|
||||
|
||||
|
||||
ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure
|
||||
|
||||
|
||||
class IntegrationsClient(Protocol):
|
||||
"""Define the contract for platform integrations clients."""
|
||||
|
||||
def get_actions(self, selectors: list[ApplicationSelector]) -> list[ToolInfo]:
|
||||
"""Get the actions available for the selected applications."""
|
||||
|
||||
def execute_action(
|
||||
self, tool: ToolInfo, arguments: dict[str, Any]
|
||||
) -> ToolExecutionResult:
|
||||
"""Execute an action with the given arguments."""
|
||||
|
||||
|
||||
class ClipperClient:
|
||||
"""Use the Clipper platform integrations API."""
|
||||
|
||||
_RESOURCE = "/clipper/v1"
|
||||
|
||||
def get_actions(self, selectors: list[ApplicationSelector]) -> list[ToolInfo]:
|
||||
"""Get the actions available for the selected applications."""
|
||||
plus_api = PlusAPI()
|
||||
base_url = f"{plus_api.base_url.rstrip('/')}{self._RESOURCE}"
|
||||
headers = self._headers()
|
||||
tool_infos: list[ToolInfo] = []
|
||||
|
||||
for selector in selectors:
|
||||
url = f"{base_url}/applications/{selector.app}/tools"
|
||||
if selector.action is not None:
|
||||
url = f"{url}/{selector.action}"
|
||||
|
||||
params = (
|
||||
{"connection_id": str(selector.connection_id)}
|
||||
if selector.connection_id is not None
|
||||
else {}
|
||||
)
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=headers,
|
||||
params=params,
|
||||
timeout=30,
|
||||
verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true",
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()["data"]
|
||||
actions = data if selector.action is None else [data]
|
||||
|
||||
tool_infos.extend(
|
||||
ToolInfo(
|
||||
app=selector.app,
|
||||
action=action["slug"],
|
||||
connection_id=selector.connection_id,
|
||||
description=action["description"],
|
||||
parameters=action["input_schema"],
|
||||
)
|
||||
for action in actions
|
||||
)
|
||||
|
||||
return tool_infos
|
||||
|
||||
def execute_action(
|
||||
self, tool: ToolInfo, arguments: dict[str, Any]
|
||||
) -> ToolExecutionResult:
|
||||
"""Execute an action with the given arguments."""
|
||||
plus_api = PlusAPI()
|
||||
payload: dict[str, Any] = {"arguments": arguments}
|
||||
if tool.connection_id is not None:
|
||||
payload["connection_id"] = str(tool.connection_id)
|
||||
|
||||
response = requests.post(
|
||||
(
|
||||
f"{plus_api.base_url.rstrip('/')}{self._RESOURCE}"
|
||||
f"/applications/{tool.app}/tools/{tool.action}/execute"
|
||||
),
|
||||
headers=self._headers(),
|
||||
json=payload,
|
||||
timeout=60,
|
||||
verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true",
|
||||
)
|
||||
if 200 <= response.status_code < 300:
|
||||
data = response.json()
|
||||
return ToolExecutionSuccess(output=data["data"]["output"])
|
||||
|
||||
try:
|
||||
error = response.json()["errors"][0]
|
||||
message = error["detail"]
|
||||
code = error["code"]
|
||||
except (
|
||||
requests.exceptions.JSONDecodeError,
|
||||
KeyError,
|
||||
IndexError,
|
||||
TypeError,
|
||||
):
|
||||
message = f"Upstream API request failed with status {response.status_code}."
|
||||
code = str(response.status_code)
|
||||
|
||||
return ToolExecutionFailure(
|
||||
message=message,
|
||||
code=code,
|
||||
retryable=500 <= response.status_code < 600,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _headers() -> dict[str, str]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {get_platform_integration_token()}",
|
||||
}
|
||||
deployment_instance_uuid = os.getenv("CREWAI_DEPLOYMENT_INSTANCE_UUID")
|
||||
if deployment_instance_uuid:
|
||||
headers["X-Crewai-Deployment-Instance-Id"] = deployment_instance_uuid
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
class LegacyClient:
|
||||
"""Use the existing CrewAI platform integrations API."""
|
||||
|
||||
def get_actions(self, selectors: list[ApplicationSelector]) -> list[ToolInfo]:
|
||||
"""Get the actions available for the selected applications."""
|
||||
plus_api = PlusAPI()
|
||||
apps = [
|
||||
f"{selector.app}/{selector.action}"
|
||||
if selector.action is not None
|
||||
else selector.app
|
||||
for selector in selectors
|
||||
]
|
||||
response = requests.get(
|
||||
f"{plus_api.base_url.rstrip('/')}{plus_api.INTEGRATIONS_RESOURCE}/actions",
|
||||
headers={"Authorization": f"Bearer {get_platform_integration_token()}"},
|
||||
timeout=30,
|
||||
params={"apps": ",".join(apps)},
|
||||
verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true",
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
tool_infos: list[ToolInfo] = []
|
||||
action_categories = response.json().get("actions", {})
|
||||
for app, actions in action_categories.items():
|
||||
if not isinstance(actions, list):
|
||||
continue
|
||||
for action_data in actions:
|
||||
if not isinstance(action_data, dict):
|
||||
continue
|
||||
if action := action_data.get("name"):
|
||||
parameters = action_data.get("parameters", {})
|
||||
if not isinstance(parameters, dict):
|
||||
parameters = {}
|
||||
|
||||
tool_infos.extend(
|
||||
ToolInfo(
|
||||
app=app,
|
||||
action=action,
|
||||
connection_id=selector.connection_id,
|
||||
description=action_data.get(
|
||||
"description", f"Execute {action}"
|
||||
),
|
||||
parameters=parameters,
|
||||
)
|
||||
for selector in selectors
|
||||
if selector.app == app and selector.action in (None, action)
|
||||
)
|
||||
|
||||
return tool_infos
|
||||
|
||||
def execute_action(
|
||||
self, tool: ToolInfo, arguments: dict[str, Any]
|
||||
) -> ToolExecutionResult:
|
||||
"""Execute an action with the given arguments."""
|
||||
plus_api = PlusAPI()
|
||||
response = requests.post(
|
||||
url=(
|
||||
f"{plus_api.base_url.rstrip('/')}{plus_api.INTEGRATIONS_RESOURCE}"
|
||||
f"/actions/{tool.action}/execute"
|
||||
),
|
||||
headers={
|
||||
"Authorization": f"Bearer {get_platform_integration_token()}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={"integration": arguments if arguments else {"_noop": True}},
|
||||
timeout=60,
|
||||
allow_redirects=False,
|
||||
verify=os.environ.get("CREWAI_FACTORY", "false").lower() != "true",
|
||||
)
|
||||
data = response.json()
|
||||
if not 200 <= response.status_code < 300:
|
||||
if isinstance(data, dict):
|
||||
error_info = data.get("error", {})
|
||||
if isinstance(error_info, dict):
|
||||
error_message = error_info.get("message", json.dumps(data))
|
||||
else:
|
||||
error_message = str(error_info)
|
||||
else:
|
||||
error_message = str(data)
|
||||
|
||||
return ToolExecutionFailure(
|
||||
message=str(error_message),
|
||||
code=str(response.status_code),
|
||||
retryable=response.status_code >= 500,
|
||||
)
|
||||
|
||||
return ToolExecutionSuccess(output=data)
|
||||
|
||||
|
||||
def client_for_selector(selector: ApplicationSelector) -> IntegrationsClient:
|
||||
"""Select the integrations client for an application selector."""
|
||||
if selector.connection_id is not None:
|
||||
return ClipperClient()
|
||||
return LegacyClient()
|
||||
@@ -1,14 +1,8 @@
|
||||
import os
|
||||
|
||||
|
||||
def get_platform_api_base_url() -> str:
|
||||
"""Get the platform API base URL from environment or use default."""
|
||||
base_url = os.getenv("CREWAI_PLUS_URL", "https://app.crewai.com")
|
||||
return f"{base_url}/crewai_plus/api/v1/integrations"
|
||||
|
||||
|
||||
def get_platform_integration_token() -> str:
|
||||
"""Get the platform API base URL from environment or use default."""
|
||||
"""Get the platform integration token from the environment."""
|
||||
token = os.getenv("CREWAI_PLATFORM_INTEGRATION_TOKEN") or ""
|
||||
if not token:
|
||||
raise ValueError(
|
||||
|
||||
@@ -1,43 +1,96 @@
|
||||
from unittest.mock import patch, Mock
|
||||
import os
|
||||
from typing import cast
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from crewai.tools.tool_failure import ToolFailure
|
||||
|
||||
from crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool import (
|
||||
CrewAIPlatformActionTool,
|
||||
)
|
||||
from crewai_tools.tools.crewai_platform_tools.integrations_client import (
|
||||
IntegrationsClient,
|
||||
ToolExecutionFailure,
|
||||
ToolExecutionSuccess,
|
||||
ToolInfo,
|
||||
)
|
||||
|
||||
|
||||
class TestCrewAIPlatformActionToolVerify:
|
||||
"""Test suite for SSL verification behavior based on CREWAI_FACTORY environment variable"""
|
||||
|
||||
def setup_method(self):
|
||||
self.action_schema = {
|
||||
"function": {
|
||||
"name": "test_action",
|
||||
"parameters": {
|
||||
"properties": {
|
||||
"test_param": {
|
||||
"type": "string",
|
||||
"description": "Test parameter"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def create_test_tool(self):
|
||||
return CrewAIPlatformActionTool(
|
||||
description="Test action tool",
|
||||
self.tool_info = ToolInfo(
|
||||
app="test_app",
|
||||
action_name="test_action",
|
||||
action_schema=self.action_schema
|
||||
action="test_action",
|
||||
connection_id=None,
|
||||
description="Test action tool",
|
||||
parameters={
|
||||
"properties": {
|
||||
"test_param": {
|
||||
"type": "string",
|
||||
"description": "Test parameter",
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
)
|
||||
|
||||
def create_test_tool(
|
||||
self, client: IntegrationsClient | None = None
|
||||
) -> CrewAIPlatformActionTool:
|
||||
return CrewAIPlatformActionTool(self.tool_info, client=client)
|
||||
|
||||
def test_run_serializes_success_output(self):
|
||||
client = Mock(spec=IntegrationsClient)
|
||||
client.execute_action.return_value = ToolExecutionSuccess(
|
||||
output={"result": {"id": 42}}
|
||||
)
|
||||
|
||||
result = self.create_test_tool(cast(IntegrationsClient, client))._run(
|
||||
test_param="test_value", optional_param=None
|
||||
)
|
||||
|
||||
assert result == '{\n "result": {\n "id": 42\n }\n}'
|
||||
assert client.execute_action.call_args.args[1] == {"test_param": "test_value"}
|
||||
|
||||
def test_run_converts_expected_failure(self):
|
||||
client = Mock(spec=IntegrationsClient)
|
||||
client.execute_action.return_value = ToolExecutionFailure(
|
||||
message="Channel not found",
|
||||
code="404",
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
result = self.create_test_tool(cast(IntegrationsClient, client))._run(
|
||||
test_param="test_value"
|
||||
)
|
||||
|
||||
assert result == ToolFailure(
|
||||
message="API request failed: Channel not found",
|
||||
code="404",
|
||||
retryable=False,
|
||||
details={"action": "test_action"},
|
||||
)
|
||||
|
||||
def test_run_preserves_unexpected_exception_fallback(self):
|
||||
client = Mock(spec=IntegrationsClient)
|
||||
client.execute_action.side_effect = ValueError("Invalid response JSON")
|
||||
|
||||
result = self.create_test_tool(cast(IntegrationsClient, client))._run(
|
||||
test_param="test_value"
|
||||
)
|
||||
|
||||
assert result == ToolFailure(
|
||||
message="Error executing action test_action: Invalid response JSON",
|
||||
code="ValueError",
|
||||
details={"action": "test_action"},
|
||||
)
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}, clear=True)
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post")
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post")
|
||||
def test_run_with_ssl_verification_default(self, mock_post):
|
||||
"""Test that _run uses SSL verification by default when CREWAI_FACTORY is not set"""
|
||||
mock_response = Mock()
|
||||
mock_response = Mock(status_code=200)
|
||||
mock_response.ok = True
|
||||
mock_response.json.return_value = {"result": "success"}
|
||||
mock_post.return_value = mock_response
|
||||
@@ -50,10 +103,10 @@ class TestCrewAIPlatformActionToolVerify:
|
||||
assert call_args.kwargs["verify"] is True
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "false"}, clear=True)
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post")
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post")
|
||||
def test_run_with_ssl_verification_factory_false(self, mock_post):
|
||||
"""Test that _run uses SSL verification when CREWAI_FACTORY is 'false'"""
|
||||
mock_response = Mock()
|
||||
mock_response = Mock(status_code=200)
|
||||
mock_response.ok = True
|
||||
mock_response.json.return_value = {"result": "success"}
|
||||
mock_post.return_value = mock_response
|
||||
@@ -66,10 +119,10 @@ class TestCrewAIPlatformActionToolVerify:
|
||||
assert call_args.kwargs["verify"] is True
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "FALSE"}, clear=True)
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post")
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post")
|
||||
def test_run_with_ssl_verification_factory_false_uppercase(self, mock_post):
|
||||
"""Test that _run uses SSL verification when CREWAI_FACTORY is 'FALSE' (case-insensitive)"""
|
||||
mock_response = Mock()
|
||||
mock_response = Mock(status_code=200)
|
||||
mock_response.ok = True
|
||||
mock_response.json.return_value = {"result": "success"}
|
||||
mock_post.return_value = mock_response
|
||||
@@ -82,10 +135,10 @@ class TestCrewAIPlatformActionToolVerify:
|
||||
assert call_args.kwargs["verify"] is True
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "true"}, clear=True)
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post")
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post")
|
||||
def test_run_without_ssl_verification_factory_true(self, mock_post):
|
||||
"""Test that _run disables SSL verification when CREWAI_FACTORY is 'true'"""
|
||||
mock_response = Mock()
|
||||
mock_response = Mock(status_code=200)
|
||||
mock_response.ok = True
|
||||
mock_response.json.return_value = {"result": "success"}
|
||||
mock_post.return_value = mock_response
|
||||
@@ -98,10 +151,10 @@ class TestCrewAIPlatformActionToolVerify:
|
||||
assert call_args.kwargs["verify"] is False
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "TRUE"}, clear=True)
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post")
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post")
|
||||
def test_run_without_ssl_verification_factory_true_uppercase(self, mock_post):
|
||||
"""Test that _run disables SSL verification when CREWAI_FACTORY is 'TRUE' (case-insensitive)"""
|
||||
mock_response = Mock()
|
||||
mock_response = Mock(status_code=200)
|
||||
mock_response.ok = True
|
||||
mock_response.json.return_value = {"result": "success"}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
@@ -1,375 +0,0 @@
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from crewai_tools.tools.crewai_platform_tools import (
|
||||
CrewAIPlatformActionTool,
|
||||
CrewaiPlatformToolBuilder,
|
||||
)
|
||||
import pytest
|
||||
|
||||
|
||||
class TestCrewaiPlatformToolBuilder(unittest.TestCase):
|
||||
@pytest.fixture
|
||||
def platform_tool_builder(self):
|
||||
"""Create a CrewaiPlatformToolBuilder instance for testing"""
|
||||
return CrewaiPlatformToolBuilder(apps=["github", "slack"])
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api_response(self):
|
||||
return {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Issue title",
|
||||
},
|
||||
"body": {"type": "string", "description": "Issue body"},
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
}
|
||||
],
|
||||
"slack": [
|
||||
{
|
||||
"name": "send_message",
|
||||
"description": "Send a Slack message",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Channel name",
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Message text",
|
||||
},
|
||||
},
|
||||
"required": ["channel", "text"],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
)
|
||||
def test_fetch_actions_success(self, mock_get):
|
||||
mock_api_response = {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Issue title",
|
||||
}
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
builder = CrewaiPlatformToolBuilder(
|
||||
apps=["github", "slack/send_message", "custom/path/to/action"]
|
||||
)
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = mock_api_response
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
builder._fetch_actions()
|
||||
|
||||
mock_get.assert_called_once()
|
||||
args, kwargs = mock_get.call_args
|
||||
|
||||
assert "/actions" in args[0]
|
||||
assert kwargs["headers"]["Authorization"] == "Bearer test_token"
|
||||
assert kwargs["params"]["apps"] == (
|
||||
"github,slack/send_message,custom/path/to/action"
|
||||
)
|
||||
|
||||
assert "create_issue" in builder._actions_schema
|
||||
assert (
|
||||
builder._actions_schema["create_issue"]["function"]["name"]
|
||||
== "create_issue"
|
||||
)
|
||||
|
||||
def test_fetch_actions_no_token(self):
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github"])
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
with self.assertRaises(ValueError) as context:
|
||||
builder._fetch_actions()
|
||||
assert "No platform integration token found" in str(context.exception)
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
)
|
||||
def test_create_tools(self, mock_get):
|
||||
mock_api_response = {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Issue title",
|
||||
}
|
||||
},
|
||||
"required": ["title"],
|
||||
},
|
||||
}
|
||||
],
|
||||
"slack": [
|
||||
{
|
||||
"name": "send_message",
|
||||
"description": "Send a Slack message",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": {
|
||||
"type": "string",
|
||||
"description": "Channel name",
|
||||
}
|
||||
},
|
||||
"required": ["channel"],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github", "slack"])
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = mock_api_response
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
tools = builder.tools()
|
||||
|
||||
assert len(tools) == 2
|
||||
assert all(isinstance(tool, CrewAIPlatformActionTool) for tool in tools)
|
||||
|
||||
tool_names = [tool.action_name for tool in tools]
|
||||
assert "create_issue" in tool_names
|
||||
assert "send_message" in tool_names
|
||||
assert {tool.action_name: tool.app for tool in tools} == {
|
||||
"create_issue": "github",
|
||||
"send_message": "slack",
|
||||
}
|
||||
|
||||
github_tool = next((t for t in tools if t.action_name == "create_issue"), None)
|
||||
slack_tool = next((t for t in tools if t.action_name == "send_message"), None)
|
||||
|
||||
assert github_tool is not None
|
||||
assert slack_tool is not None
|
||||
assert "Create a GitHub issue" in github_tool.description
|
||||
assert "Send a Slack message" in slack_tool.description
|
||||
|
||||
def test_tools_caching(self):
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github"])
|
||||
|
||||
cached_tools = []
|
||||
|
||||
def mock_create_tools():
|
||||
builder._tools = cached_tools
|
||||
|
||||
with (
|
||||
patch.object(builder, "_fetch_actions") as mock_fetch,
|
||||
patch.object(
|
||||
builder, "_create_tools", side_effect=mock_create_tools
|
||||
) as mock_create,
|
||||
):
|
||||
tools1 = builder.tools()
|
||||
assert mock_fetch.call_count == 1
|
||||
assert mock_create.call_count == 1
|
||||
|
||||
tools2 = builder.tools()
|
||||
assert mock_fetch.call_count == 1
|
||||
assert mock_create.call_count == 1
|
||||
|
||||
assert tools1 is tools2
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
def test_empty_apps_list(self):
|
||||
builder = CrewaiPlatformToolBuilder(apps=[])
|
||||
|
||||
with patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
) as mock_get:
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"actions": {}}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
tools = builder.tools()
|
||||
|
||||
assert isinstance(tools, list)
|
||||
assert len(tools) == 0
|
||||
|
||||
_, kwargs = mock_get.call_args
|
||||
assert kwargs["params"]["apps"] == ""
|
||||
|
||||
class TestCrewaiPlatformToolBuilderVerify(unittest.TestCase):
|
||||
"""Test suite for SSL verification behavior in CrewaiPlatformToolBuilder"""
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"}, clear=True)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
)
|
||||
def test_fetch_actions_with_ssl_verification_default(self, mock_get):
|
||||
"""Test that _fetch_actions uses SSL verification by default when CREWAI_FACTORY is not set"""
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"actions": {}}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github"])
|
||||
builder._fetch_actions()
|
||||
|
||||
mock_get.assert_called_once()
|
||||
call_args = mock_get.call_args
|
||||
assert call_args.kwargs["verify"] is True
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "false"}, clear=True)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
)
|
||||
def test_fetch_actions_with_ssl_verification_factory_false(self, mock_get):
|
||||
"""Test that _fetch_actions uses SSL verification when CREWAI_FACTORY is 'false'"""
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"actions": {}}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github"])
|
||||
builder._fetch_actions()
|
||||
|
||||
mock_get.assert_called_once()
|
||||
call_args = mock_get.call_args
|
||||
assert call_args.kwargs["verify"] is True
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "FALSE"}, clear=True)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
)
|
||||
def test_fetch_actions_with_ssl_verification_factory_false_uppercase(self, mock_get):
|
||||
"""Test that _fetch_actions uses SSL verification when CREWAI_FACTORY is 'FALSE' (case-insensitive)"""
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"actions": {}}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github"])
|
||||
builder._fetch_actions()
|
||||
|
||||
mock_get.assert_called_once()
|
||||
call_args = mock_get.call_args
|
||||
assert call_args.kwargs["verify"] is True
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "true"}, clear=True)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
)
|
||||
def test_fetch_actions_without_ssl_verification_factory_true(self, mock_get):
|
||||
"""Test that _fetch_actions disables SSL verification when CREWAI_FACTORY is 'true'"""
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"actions": {}}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github"])
|
||||
builder._fetch_actions()
|
||||
|
||||
mock_get.assert_called_once()
|
||||
call_args = mock_get.call_args
|
||||
assert call_args.kwargs["verify"] is False
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token", "CREWAI_FACTORY": "TRUE"}, clear=True)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
)
|
||||
def test_fetch_actions_without_ssl_verification_factory_true_uppercase(self, mock_get):
|
||||
"""Test that _fetch_actions disables SSL verification when CREWAI_FACTORY is 'TRUE' (case-insensitive)"""
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"actions": {}}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
builder = CrewaiPlatformToolBuilder(apps=["github"])
|
||||
builder._fetch_actions()
|
||||
|
||||
mock_get.assert_called_once()
|
||||
call_args = mock_get.call_args
|
||||
assert call_args.kwargs["verify"] is False
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
)
|
||||
def test_connection_ids_are_parsed_but_not_sent(mock_get):
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"actions": {}}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
builder = CrewaiPlatformToolBuilder(
|
||||
apps=[
|
||||
"github@550E8400-E29B-41D4-A716-446655440000",
|
||||
"slack/send_message@67e55044-10b1-426f-9247-bb680e5fe0c8",
|
||||
]
|
||||
)
|
||||
|
||||
builder.tools()
|
||||
|
||||
assert mock_get.call_args.kwargs["params"]["apps"] == (
|
||||
"github,slack/send_message"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selector", "message"),
|
||||
[
|
||||
("", "cannot be empty"),
|
||||
(
|
||||
"@550e8400-e29b-41d4-a716-446655440000",
|
||||
"application cannot be empty",
|
||||
),
|
||||
("github/", "action cannot be empty"),
|
||||
("github@", "connection ID cannot be empty"),
|
||||
("github@not-a-uuid", "connection ID must be a valid UUID"),
|
||||
(
|
||||
"github@550e8400-e29b-41d4-a716-446655440000/issues",
|
||||
"connection ID must be the last segment",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_rejects_invalid_app_selector(selector, message):
|
||||
with pytest.raises(ValueError) as error:
|
||||
CrewaiPlatformToolBuilder(apps=[selector])
|
||||
|
||||
assert repr(selector) in str(error.value)
|
||||
assert message in str(error.value)
|
||||
@@ -7,7 +7,7 @@ from crewai_tools.tools.crewai_platform_tools import CrewaiPlatformTools
|
||||
class TestCrewaiPlatformTools(unittest.TestCase):
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_crewai_platform_tools_basic(self, mock_get):
|
||||
mock_response = Mock()
|
||||
@@ -17,11 +17,11 @@ class TestCrewaiPlatformTools(unittest.TestCase):
|
||||
|
||||
tools = CrewaiPlatformTools(apps=["github"])
|
||||
assert tools is not None
|
||||
assert isinstance(tools, list)
|
||||
assert type(tools) is list
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_crewai_platform_tools_multiple_apps(self, mock_get):
|
||||
mock_response = Mock()
|
||||
@@ -73,18 +73,62 @@ class TestCrewaiPlatformTools(unittest.TestCase):
|
||||
assert tools is not None
|
||||
assert isinstance(tools, list)
|
||||
assert len(tools) == 2
|
||||
assert [tool.name for tool in tools] == [
|
||||
"github_create_issue",
|
||||
"slack_send_message",
|
||||
]
|
||||
assert [tool.app for tool in tools] == ["github", "slack"]
|
||||
assert tools[0].description == "Create a GitHub issue"
|
||||
assert tools[1].description == "Send a Slack message"
|
||||
|
||||
mock_get.assert_called_once()
|
||||
args, kwargs = mock_get.call_args
|
||||
assert (
|
||||
"apps=github,slack" in args[0]
|
||||
or kwargs.get("params", {}).get("apps") == "github,slack"
|
||||
)
|
||||
assert [request.kwargs["params"] for request in mock_get.call_args_list] == [
|
||||
{"apps": "github"},
|
||||
{"apps": "slack"},
|
||||
]
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_invalid_parameter_schemas_do_not_abort_discovery(self, mock_get):
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"parameters": "invalid",
|
||||
},
|
||||
{
|
||||
"name": "close_issue",
|
||||
"description": "Close a GitHub issue",
|
||||
"parameters": [{"type": "string"}],
|
||||
},
|
||||
{
|
||||
"name": "list_issues",
|
||||
"description": "List GitHub issues",
|
||||
"parameters": {},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
tools = CrewaiPlatformTools(apps=["github"])
|
||||
|
||||
assert [tool.name for tool in tools] == [
|
||||
"github_create_issue",
|
||||
"github_close_issue",
|
||||
"github_list_issues",
|
||||
]
|
||||
assert all(tool.args_schema.model_fields == {} for tool in tools)
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
def test_crewai_platform_tools_empty_apps(self):
|
||||
with patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
) as mock_get:
|
||||
mock_response = Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
@@ -98,7 +142,7 @@ class TestCrewaiPlatformTools(unittest.TestCase):
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get"
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_crewai_platform_tools_api_error_handling(self, mock_get):
|
||||
mock_get.side_effect = Exception("API Error")
|
||||
@@ -113,3 +157,192 @@ class TestCrewaiPlatformTools(unittest.TestCase):
|
||||
with self.assertRaises(ValueError) as context:
|
||||
CrewaiPlatformTools(apps=["github"])
|
||||
assert "No platform integration token found" in str(context.exception)
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_discovered_tool_executes_through_legacy_api(self, mock_get, mock_post):
|
||||
discovery_response = Mock()
|
||||
discovery_response.raise_for_status.return_value = None
|
||||
discovery_response.json.return_value = {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}},
|
||||
"required": ["title"],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mock_get.return_value = discovery_response
|
||||
execution_response = Mock(ok=True, status_code=200)
|
||||
execution_response.json.return_value = {"issue": 42}
|
||||
mock_post.return_value = execution_response
|
||||
|
||||
tools = CrewaiPlatformTools(apps=["github"])
|
||||
result = tools[0].run(title="Contract test")
|
||||
|
||||
assert mock_get.call_args.kwargs["params"] == {"apps": "github"}
|
||||
assert mock_post.call_args.kwargs["url"].endswith(
|
||||
"/actions/create_issue/execute"
|
||||
)
|
||||
assert mock_post.call_args.kwargs["json"] == {
|
||||
"integration": {"title": "Contract test"}
|
||||
}
|
||||
assert result == '{\n "issue": 42\n}'
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token",
|
||||
"CREWAI_PLUS_URL": "https://platform.example.test/",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_connection_selects_clipper_api(self, mock_get, mock_post):
|
||||
discovery_response = Mock()
|
||||
discovery_response.raise_for_status.return_value = None
|
||||
discovery_response.json.return_value = {
|
||||
"data": {
|
||||
"slug": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}},
|
||||
"required": ["title"],
|
||||
},
|
||||
}
|
||||
}
|
||||
mock_get.return_value = discovery_response
|
||||
execution_response = Mock(status_code=200)
|
||||
execution_response.json.return_value = {"data": {"output": {"issue": 42}}}
|
||||
mock_post.return_value = execution_response
|
||||
connection_id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
tools = CrewaiPlatformTools(
|
||||
apps=[f"github/create_issue@{connection_id}"]
|
||||
)
|
||||
result = tools[0].run(title="Contract test")
|
||||
|
||||
assert mock_get.call_args.args[0].endswith(
|
||||
"/clipper/v1/applications/github/tools/create_issue"
|
||||
)
|
||||
assert mock_get.call_args.kwargs["params"] == {
|
||||
"connection_id": connection_id
|
||||
}
|
||||
assert mock_post.call_args.args[0].endswith(
|
||||
"/clipper/v1/applications/github/tools/create_issue/execute"
|
||||
)
|
||||
assert mock_post.call_args.kwargs["json"] == {
|
||||
"arguments": {"title": "Contract test"},
|
||||
"connection_id": connection_id,
|
||||
}
|
||||
assert result == '{\n "issue": 42\n}'
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token",
|
||||
"CREWAI_PLUS_URL": "https://platform.example.test/",
|
||||
},
|
||||
clear=True,
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_mixed_selectors_use_both_apis(self, mock_get):
|
||||
legacy_response = Mock()
|
||||
legacy_response.raise_for_status.return_value = None
|
||||
legacy_response.json.return_value = {"actions": {"slack": []}}
|
||||
clipper_response = Mock()
|
||||
clipper_response.raise_for_status.return_value = None
|
||||
clipper_response.json.return_value = {"data": []}
|
||||
mock_get.side_effect = [legacy_response, clipper_response]
|
||||
connection_id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
tools = CrewaiPlatformTools(apps=["slack", f"github@{connection_id}"])
|
||||
|
||||
assert tools == []
|
||||
assert mock_get.call_count == 2
|
||||
assert mock_get.call_args_list[0].kwargs["params"] == {"apps": "slack"}
|
||||
assert mock_get.call_args_list[1].args[0].endswith(
|
||||
"/clipper/v1/applications/github/tools"
|
||||
)
|
||||
assert mock_get.call_args_list[1].kwargs["params"] == {
|
||||
"connection_id": connection_id
|
||||
}
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_same_action_from_different_apps_has_unique_tool_names(self, mock_get):
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "search",
|
||||
"description": "Search GitHub",
|
||||
"parameters": {},
|
||||
}
|
||||
],
|
||||
"slack": [
|
||||
{
|
||||
"name": "search",
|
||||
"description": "Search Slack",
|
||||
"parameters": {},
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
mock_get.return_value = response
|
||||
|
||||
tools = CrewaiPlatformTools(apps=["github", "slack"])
|
||||
|
||||
assert len(tools) == 2
|
||||
assert [tool.name for tool in tools] == ["github_search", "slack_search"]
|
||||
assert [tool.app for tool in tools] == ["github", "slack"]
|
||||
assert [tool.description for tool in tools] == ["Search GitHub", "Search Slack"]
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_tool_name_uses_its_sanitized_identity(self, mock_get):
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {
|
||||
"data": {
|
||||
"slug": "CreateFile!",
|
||||
"description": "Create a file",
|
||||
"input_schema": {},
|
||||
}
|
||||
}
|
||||
mock_get.return_value = response
|
||||
|
||||
tools = CrewaiPlatformTools(
|
||||
apps=[
|
||||
"Google Drive/CreateFile!@550e8400-e29b-41d4-a716-446655440000"
|
||||
]
|
||||
)
|
||||
|
||||
assert tools[0].name == (
|
||||
"google_drive_create_file_550e8400_e29b_41d4_a716_446655440000"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
from dataclasses import FrozenInstanceError
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, call, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from requests.exceptions import JSONDecodeError
|
||||
|
||||
from crewai_tools.tools.crewai_platform_tools.integrations_client import (
|
||||
ApplicationSelector,
|
||||
ClipperClient,
|
||||
IntegrationsClient,
|
||||
LegacyClient,
|
||||
ToolExecutionFailure,
|
||||
ToolExecutionSuccess,
|
||||
ToolInfo,
|
||||
)
|
||||
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token",
|
||||
"CREWAI_DEPLOYMENT_INSTANCE_UUID": "deployment-instance-id",
|
||||
"CREWAI_FACTORY": "false",
|
||||
"CREWAI_PLUS_URL": "https://platform.example.test/",
|
||||
},
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_clipper_client_discovers_selected_actions(mock_get: Mock) -> None:
|
||||
index_response = Mock()
|
||||
index_response.raise_for_status.return_value = None
|
||||
index_response.json.return_value = {
|
||||
"data": [
|
||||
{
|
||||
"slug": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"input_schema": {"type": "object"},
|
||||
}
|
||||
]
|
||||
}
|
||||
show_response = Mock()
|
||||
show_response.raise_for_status.return_value = None
|
||||
show_response.json.return_value = {
|
||||
"data": {
|
||||
"slug": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"input_schema": {"type": "object"},
|
||||
}
|
||||
}
|
||||
mock_get.side_effect = [index_response, show_response]
|
||||
connection_id = UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
tools = ClipperClient().get_actions(
|
||||
[
|
||||
ApplicationSelector.from_string(f"github@{connection_id}"),
|
||||
ApplicationSelector.from_string("github/create_issue"),
|
||||
]
|
||||
)
|
||||
|
||||
expected_tool = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=connection_id,
|
||||
description="Create a GitHub issue",
|
||||
parameters={"type": "object"},
|
||||
)
|
||||
assert tools == [
|
||||
expected_tool,
|
||||
ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=None,
|
||||
description="Create a GitHub issue",
|
||||
parameters={"type": "object"},
|
||||
),
|
||||
]
|
||||
headers = {
|
||||
"Authorization": "Bearer test_token",
|
||||
"X-Crewai-Deployment-Instance-Id": "deployment-instance-id",
|
||||
}
|
||||
assert mock_get.call_args_list == [
|
||||
call(
|
||||
"https://platform.example.test/clipper/v1/applications/github/tools",
|
||||
headers=headers,
|
||||
params={"connection_id": str(connection_id)},
|
||||
timeout=30,
|
||||
verify=True,
|
||||
),
|
||||
call(
|
||||
"https://platform.example.test/clipper/v1/applications/github/tools/create_issue",
|
||||
headers=headers,
|
||||
params={},
|
||||
timeout=30,
|
||||
verify=True,
|
||||
),
|
||||
]
|
||||
index_response.raise_for_status.assert_called_once_with()
|
||||
show_response.raise_for_status.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("factory_value", "verify"),
|
||||
[
|
||||
(None, True),
|
||||
("false", True),
|
||||
("FALSE", True),
|
||||
("true", False),
|
||||
("TRUE", False),
|
||||
],
|
||||
)
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token",
|
||||
"CREWAI_DEPLOYMENT_INSTANCE_UUID": "deployment-instance-id",
|
||||
},
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_clipper_client_preserves_discovery_ssl_behavior(
|
||||
mock_get: Mock,
|
||||
factory_value: str | None,
|
||||
verify: bool,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
if factory_value is None:
|
||||
monkeypatch.delenv("CREWAI_FACTORY", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("CREWAI_FACTORY", factory_value)
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {"data": []}
|
||||
mock_get.return_value = response
|
||||
|
||||
ClipperClient().get_actions([ApplicationSelector.from_string("github")])
|
||||
|
||||
assert mock_get.call_args.kwargs["verify"] is verify
|
||||
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token",
|
||||
"CREWAI_DEPLOYMENT_INSTANCE_UUID": "deployment-instance-id",
|
||||
"CREWAI_FACTORY": "false",
|
||||
"CREWAI_PLUS_URL": "https://platform.example.test/",
|
||||
},
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("arguments", "connection_id"),
|
||||
[
|
||||
({}, UUID("550e8400-e29b-41d4-a716-446655440000")),
|
||||
(
|
||||
{
|
||||
"filters": {"labels": ["urgent"], "enabled": True},
|
||||
"values": [1, {"key": "value"}],
|
||||
},
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_clipper_client_executes_action(
|
||||
mock_post: Mock,
|
||||
arguments: dict[str, Any],
|
||||
connection_id: UUID | None,
|
||||
) -> None:
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {"data": {"output": {"issue": 42}}}
|
||||
mock_post.return_value = response
|
||||
tool = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=connection_id,
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
result = ClipperClient().execute_action(tool, arguments)
|
||||
|
||||
assert result == ToolExecutionSuccess(output={"issue": 42})
|
||||
expected_payload: dict[str, Any] = {"arguments": arguments}
|
||||
if connection_id is not None:
|
||||
expected_payload["connection_id"] = str(connection_id)
|
||||
mock_post.assert_called_once_with(
|
||||
"https://platform.example.test/clipper/v1/applications/github/tools/create_issue/execute",
|
||||
headers={
|
||||
"Authorization": "Bearer test_token",
|
||||
"X-Crewai-Deployment-Instance-Id": "deployment-instance-id",
|
||||
},
|
||||
json=expected_payload,
|
||||
timeout=60,
|
||||
verify=True,
|
||||
)
|
||||
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token",
|
||||
"CREWAI_DEPLOYMENT_INSTANCE_UUID": "deployment-instance-id",
|
||||
"CREWAI_FACTORY": "false",
|
||||
"CREWAI_PLUS_URL": "https://platform.example.test/",
|
||||
},
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("status_code", "code", "detail", "retryable"),
|
||||
[
|
||||
(
|
||||
422,
|
||||
"tool_execution_failed",
|
||||
"The provider rejected the request.",
|
||||
False,
|
||||
),
|
||||
(
|
||||
503,
|
||||
"service_unavailable",
|
||||
"The tool provider is unavailable.",
|
||||
True,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_clipper_client_normalizes_execution_failure(
|
||||
mock_post: Mock,
|
||||
status_code: int,
|
||||
code: str,
|
||||
detail: str,
|
||||
retryable: bool,
|
||||
) -> None:
|
||||
response = Mock(status_code=status_code)
|
||||
response.json.return_value = {
|
||||
"errors": [
|
||||
{
|
||||
"code": code,
|
||||
"detail": detail,
|
||||
}
|
||||
]
|
||||
}
|
||||
mock_post.return_value = response
|
||||
tool = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=None,
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
result = ClipperClient().execute_action(tool, {"title": "Contract test"})
|
||||
|
||||
assert result == ToolExecutionFailure(
|
||||
message=detail,
|
||||
code=code,
|
||||
retryable=retryable,
|
||||
)
|
||||
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"},
|
||||
clear=True,
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
def test_clipper_client_normalizes_non_json_service_failure(
|
||||
mock_post: Mock,
|
||||
) -> None:
|
||||
response = Mock(status_code=503)
|
||||
response.json.side_effect = JSONDecodeError("Expecting value", "", 0)
|
||||
mock_post.return_value = response
|
||||
tool = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=None,
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
result = ClipperClient().execute_action(tool, {"title": "Contract test"})
|
||||
|
||||
assert result == ToolExecutionFailure(
|
||||
message="Upstream API request failed with status 503.",
|
||||
code="503",
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"},
|
||||
clear=True,
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_clipper_client_discovers_without_deployment_instance_uuid(
|
||||
mock_get: Mock,
|
||||
) -> None:
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {"data": []}
|
||||
mock_get.return_value = response
|
||||
|
||||
assert ClipperClient().get_actions(
|
||||
[ApplicationSelector.from_string("github")]
|
||||
) == []
|
||||
assert mock_get.call_args.kwargs["headers"] == {
|
||||
"Authorization": "Bearer test_token"
|
||||
}
|
||||
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"},
|
||||
clear=True,
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
def test_clipper_client_executes_without_deployment_instance_uuid(
|
||||
mock_post: Mock,
|
||||
) -> None:
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {"data": {"output": {"issue": 42}}}
|
||||
mock_post.return_value = response
|
||||
tool = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=None,
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
result = ClipperClient().execute_action(tool, {})
|
||||
|
||||
assert result == ToolExecutionSuccess(output={"issue": 42})
|
||||
assert mock_post.call_args.kwargs["headers"] == {
|
||||
"Authorization": "Bearer test_token"
|
||||
}
|
||||
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{"CREWAI_DEPLOYMENT_INSTANCE_UUID": "deployment-instance-id"},
|
||||
clear=True,
|
||||
)
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
def test_clipper_client_requires_platform_integration_token(
|
||||
mock_post: Mock,
|
||||
) -> None:
|
||||
tool = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=None,
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="CREWAI_PLATFORM_INTEGRATION_TOKEN"):
|
||||
ClipperClient().execute_action(tool, {})
|
||||
|
||||
mock_post.assert_not_called()
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_legacy_client_normalizes_discovered_actions(mock_get: Mock) -> None:
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mock_get.return_value = response
|
||||
connection_id = UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
|
||||
tools = LegacyClient().get_actions(
|
||||
[ApplicationSelector.from_string(f"github/create_issue@{connection_id}")]
|
||||
)
|
||||
|
||||
assert tools == [
|
||||
ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=connection_id,
|
||||
description="Create a GitHub issue",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {"title": {"type": "string"}},
|
||||
},
|
||||
)
|
||||
]
|
||||
response.raise_for_status.assert_called_once_with()
|
||||
assert mock_get.call_args.kwargs["params"] == {"apps": "github/create_issue"}
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_legacy_client_emits_action_for_each_matching_selector(
|
||||
mock_get: Mock,
|
||||
) -> None:
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "create_issue",
|
||||
"description": "Create a GitHub issue",
|
||||
"parameters": {},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mock_get.return_value = response
|
||||
app_connection_id = UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
action_connection_id = UUID("8c5f9d69-902b-4b48-a23c-8d037c242e1e")
|
||||
|
||||
tools = LegacyClient().get_actions(
|
||||
[
|
||||
ApplicationSelector.from_string(f"github@{app_connection_id}"),
|
||||
ApplicationSelector.from_string(
|
||||
f"github/create_issue@{action_connection_id}"
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
assert [tool.connection_id for tool in tools] == [
|
||||
app_connection_id,
|
||||
action_connection_id,
|
||||
]
|
||||
assert [tool.qualified_name for tool in tools] == [
|
||||
"github_create_issue_550e8400_e29b_41d4_a716_446655440000",
|
||||
"github_create_issue_8c5f9d69_902b_4b48_a23c_8d037c242e1e",
|
||||
]
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_legacy_client_excludes_actions_without_a_matching_selector(
|
||||
mock_get: Mock,
|
||||
) -> None:
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {
|
||||
"actions": {
|
||||
"github": [
|
||||
{
|
||||
"name": "delete_issue",
|
||||
"description": "Delete a GitHub issue",
|
||||
"parameters": {},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
mock_get.return_value = response
|
||||
|
||||
tools = LegacyClient().get_actions(
|
||||
[ApplicationSelector.from_string("github/create_issue")]
|
||||
)
|
||||
|
||||
assert tools == []
|
||||
|
||||
|
||||
def test_tool_info_is_immutable() -> None:
|
||||
tool_info = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=None,
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
tool_info.action = "delete_issue"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("result", "field", "value"),
|
||||
[
|
||||
(ToolExecutionSuccess(output={"issue": 42}), "output", {"issue": 43}),
|
||||
(
|
||||
ToolExecutionFailure(
|
||||
message="Request failed", code="400", retryable=False
|
||||
),
|
||||
"message",
|
||||
"Another failure",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_tool_execution_results_are_immutable(
|
||||
result: ToolExecutionSuccess | ToolExecutionFailure,
|
||||
field: str,
|
||||
value: Any,
|
||||
) -> None:
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
setattr(result, field, value)
|
||||
|
||||
|
||||
def test_application_selector_is_immutable() -> None:
|
||||
selector = ApplicationSelector.from_string(
|
||||
"github/create_issue@550e8400-e29b-41d4-a716-446655440000"
|
||||
)
|
||||
|
||||
assert selector.app == "github"
|
||||
assert selector.action == "create_issue"
|
||||
assert selector.connection_id == UUID("550e8400-e29b-41d4-a716-446655440000")
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
selector.action = "delete_issue"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "message"),
|
||||
[
|
||||
("", "cannot be empty"),
|
||||
(
|
||||
"@550e8400-e29b-41d4-a716-446655440000",
|
||||
"application cannot be empty",
|
||||
),
|
||||
("github/", "action cannot be empty"),
|
||||
("github@", "connection ID cannot be empty"),
|
||||
("github@not-a-uuid", "connection ID must be a valid UUID"),
|
||||
(
|
||||
"github@550e8400-e29b-41d4-a716-446655440000/issues",
|
||||
"connection ID must be the last segment",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_application_selector_rejects_invalid_values(
|
||||
value: str, message: str
|
||||
) -> None:
|
||||
with pytest.raises(ValueError) as error:
|
||||
ApplicationSelector.from_string(value)
|
||||
|
||||
assert repr(value) in str(error.value)
|
||||
assert message in str(error.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("factory_value", "verify"),
|
||||
[
|
||||
(None, True),
|
||||
("false", True),
|
||||
("FALSE", True),
|
||||
("true", False),
|
||||
("TRUE", False),
|
||||
],
|
||||
)
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get"
|
||||
)
|
||||
def test_legacy_client_preserves_discovery_ssl_behavior(
|
||||
mock_get: Mock,
|
||||
factory_value: str | None,
|
||||
verify: bool,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
if factory_value is None:
|
||||
monkeypatch.delenv("CREWAI_FACTORY", raising=False)
|
||||
else:
|
||||
monkeypatch.setenv("CREWAI_FACTORY", factory_value)
|
||||
response = Mock()
|
||||
response.raise_for_status.return_value = None
|
||||
response.json.return_value = {"actions": {}}
|
||||
mock_get.return_value = response
|
||||
|
||||
LegacyClient().get_actions([ApplicationSelector.from_string("github")])
|
||||
|
||||
assert mock_get.call_args.kwargs["verify"] is verify
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("arguments", "integration"),
|
||||
[({"title": "Contract test"}, {"title": "Contract test"}), ({}, {"_noop": True})],
|
||||
)
|
||||
def test_legacy_client_preserves_execution_request(
|
||||
mock_post: Mock,
|
||||
arguments: dict[str, Any],
|
||||
integration: dict[str, Any],
|
||||
) -> None:
|
||||
response = Mock(status_code=200)
|
||||
response.json.return_value = {"issue": 42}
|
||||
mock_post.return_value = response
|
||||
tool_info = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=UUID("550e8400-e29b-41d4-a716-446655440000"),
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
client: IntegrationsClient = LegacyClient()
|
||||
result = client.execute_action(tool_info, arguments)
|
||||
|
||||
assert result == ToolExecutionSuccess(output={"issue": 42})
|
||||
mock_post.assert_called_once()
|
||||
assert mock_post.call_args.kwargs["url"].endswith(
|
||||
"/actions/create_issue/execute"
|
||||
)
|
||||
assert mock_post.call_args.kwargs["headers"] == {
|
||||
"Authorization": "Bearer test_token",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
assert mock_post.call_args.kwargs["json"] == {"integration": integration}
|
||||
assert mock_post.call_args.kwargs["timeout"] == 60
|
||||
assert mock_post.call_args.kwargs["allow_redirects"] is False
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("response_data", "status_code", "message", "retryable"),
|
||||
[
|
||||
({"error": {"message": "Invalid issue"}}, 400, "Invalid issue", False),
|
||||
({"error": "Rate limited"}, 429, "Rate limited", False),
|
||||
(["Service unavailable"], 503, "['Service unavailable']", True),
|
||||
({"reason": "Unknown"}, 500, '{"reason": "Unknown"}', True),
|
||||
],
|
||||
)
|
||||
def test_legacy_client_normalizes_execution_failures(
|
||||
mock_post: Mock,
|
||||
response_data: Any,
|
||||
status_code: int,
|
||||
message: str,
|
||||
retryable: bool,
|
||||
) -> None:
|
||||
response = Mock(status_code=status_code)
|
||||
response.json.return_value = response_data
|
||||
mock_post.return_value = response
|
||||
tool_info = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=None,
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
result = LegacyClient().execute_action(tool_info, {"title": "Contract test"})
|
||||
|
||||
assert result == ToolExecutionFailure(
|
||||
message=message,
|
||||
code=str(status_code),
|
||||
retryable=retryable,
|
||||
)
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch(
|
||||
"crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post"
|
||||
)
|
||||
def test_legacy_client_treats_redirect_as_execution_failure(
|
||||
mock_post: Mock,
|
||||
) -> None:
|
||||
response = Mock(status_code=302)
|
||||
response.json.return_value = {"error": {"message": "Redirected"}}
|
||||
mock_post.return_value = response
|
||||
tool_info = ToolInfo(
|
||||
app="github",
|
||||
action="create_issue",
|
||||
connection_id=None,
|
||||
description="Create an issue",
|
||||
parameters={},
|
||||
)
|
||||
|
||||
result = LegacyClient().execute_action(tool_info, {})
|
||||
|
||||
assert result == ToolExecutionFailure(
|
||||
message="Redirected",
|
||||
code="302",
|
||||
retryable=False,
|
||||
)
|
||||
@@ -576,7 +576,7 @@ pip install dist/*.tar.gz
|
||||
|
||||
CrewAI uses anonymous telemetry to collect usage data with the main purpose of helping us improve the library by focusing our efforts on the most used features, integrations and tools.
|
||||
|
||||
It's pivotal to understand that **NO data is collected** concerning prompts, task descriptions, agents' backstories or goals, usage of tools, API calls, responses, any data processed by the agents, or secrets and environment variables, with the exception of the conditions mentioned. When the `share_crew` feature is enabled, detailed data including task descriptions, agents' backstories or goals, and other specific attributes are collected to provide deeper insights while respecting user privacy. Users can disable telemetry by setting the environment variable OTEL_SDK_DISABLED to true.
|
||||
It's pivotal to understand that **NO data is collected** concerning prompts, task descriptions, agents' backstories or goals, usage of tools, API calls, responses, any data processed by the agents, or secrets and environment variables, with the exception of the conditions mentioned. When the `share_crew` feature is enabled, detailed data including task descriptions, agents' backstories or goals, and other specific attributes are collected to provide deeper insights while respecting user privacy. Users can disable telemetry by setting `CREWAI_DISABLE_TELEMETRY` to `true`, `1`, `yes`, or `on`. `OTEL_SDK_DISABLED` with the same values also disables CrewAI telemetry; the OpenTelemetry SDK itself still only honors `true` for other instrumentation.
|
||||
|
||||
Data collected includes:
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ __all__ = ["HumanFeedbackResult", "human_feedback"]
|
||||
def human_feedback(
|
||||
message: str,
|
||||
emit: Sequence[str] | None = None,
|
||||
llm: str | BaseLLM | None = "gpt-5.4-mini",
|
||||
llm: str | BaseLLM | None = None,
|
||||
default_outcome: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
provider: HumanFeedbackProvider | None = None,
|
||||
@@ -37,9 +37,7 @@ def human_feedback(
|
||||
configuration on the method, and the Flow engine collects and routes
|
||||
feedback after the method completes, driven by the flow's definition.
|
||||
"""
|
||||
_validate_human_feedback_options(
|
||||
emit=emit, llm=llm, default_outcome=default_outcome
|
||||
)
|
||||
_validate_human_feedback_options(emit=emit, default_outcome=default_outcome)
|
||||
config = HumanFeedbackConfig(
|
||||
message=message,
|
||||
emit=list(emit) if emit is not None else None,
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from functools import lru_cache
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple, TypeAlias, cast
|
||||
@@ -21,6 +23,46 @@ _CEL_MACROS_WITH_LOCAL_BINDINGS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CelRunContext:
|
||||
now: datetime
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CelFunctionSpec:
|
||||
annotation: Any
|
||||
factory: Callable[[_CelRunContext], Callable[..., Any]]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _cel_function_registry() -> dict[str, _CelFunctionSpec]:
|
||||
from celpy import celtypes
|
||||
|
||||
return {
|
||||
"now": _CelFunctionSpec(
|
||||
annotation=celtypes.FunctionType,
|
||||
factory=lambda run: lambda: celtypes.TimestampType(run.now),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _cel_environment() -> Any:
|
||||
from celpy import Environment
|
||||
|
||||
return Environment(
|
||||
annotations={
|
||||
name: spec.annotation for name, spec in _cel_function_registry().items()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _cel_functions(run_context: _CelRunContext) -> dict[str, Any]:
|
||||
return {
|
||||
name: spec.factory(run_context)
|
||||
for name, spec in _cel_function_registry().items()
|
||||
}
|
||||
|
||||
|
||||
def _find_cel_eval_error(value: Any) -> Exception | None:
|
||||
from celpy.evaluation import CELEvalError
|
||||
|
||||
@@ -103,6 +145,9 @@ FLOW_TEMPLATE_EXPRESSION_RULES: tuple[str, ...] = (
|
||||
"Use this for numbers, booleans, objects, and lists.",
|
||||
"If the string has other text, the final value is text. Non-text values "
|
||||
"become JSON. `null` becomes empty text.",
|
||||
"Use `now()` for the current UTC time as a CEL timestamp, frozen for the "
|
||||
"whole run. Use standard CEL on it: `string(now())` for ISO text, "
|
||||
"`now().getFullYear()`, or `now() - duration('24h')`.",
|
||||
)
|
||||
FLOW_TEMPLATE_EXPRESSION_CONTRACT = " ".join(FLOW_TEMPLATE_EXPRESSION_RULES)
|
||||
FLOW_TEMPLATE_EXPRESSION_EXAMPLES: dict[str, tuple[dict[str, str], ...]] = {
|
||||
@@ -176,10 +221,15 @@ class Expression:
|
||||
"""CEL expression helper used for definition-time checks and runtime rendering."""
|
||||
|
||||
def __init__(
|
||||
self, value: ExpressionData, *, context: dict[str, Any] | None = None
|
||||
self,
|
||||
value: ExpressionData,
|
||||
*,
|
||||
context: dict[str, Any] | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
self.value = value
|
||||
self.context = context
|
||||
self.now = now
|
||||
|
||||
@classmethod
|
||||
def from_flow(
|
||||
@@ -190,7 +240,11 @@ class Expression:
|
||||
local_context: dict[str, Any] | None = None,
|
||||
) -> Expression:
|
||||
"""Build an expression with the standard Flow runtime context."""
|
||||
return cls(value, context=cls._flow_context(flow, local_context=local_context))
|
||||
return cls(
|
||||
value,
|
||||
context=cls._flow_context(flow, local_context=local_context),
|
||||
now=getattr(flow, "_cel_now", None),
|
||||
)
|
||||
|
||||
def validate_expression(
|
||||
self,
|
||||
@@ -231,6 +285,7 @@ class Expression:
|
||||
return self._evaluate_cel(
|
||||
self._require_cel_source(cast(str, self.value)),
|
||||
resolved_context or {},
|
||||
self._run_context(),
|
||||
)
|
||||
|
||||
def render_template(self, context: dict[str, Any] | None = None) -> Any:
|
||||
@@ -240,7 +295,12 @@ class Expression:
|
||||
type; strings mixing literals and expressions render as text.
|
||||
"""
|
||||
resolved_context = self.context if context is None else context
|
||||
return self._render_template_value(self.value, resolved_context or {})
|
||||
return self._render_template_value(
|
||||
self.value, resolved_context or {}, self._run_context()
|
||||
)
|
||||
|
||||
def _run_context(self) -> _CelRunContext:
|
||||
return _CelRunContext(now=self.now or datetime.now(timezone.utc))
|
||||
|
||||
@staticmethod
|
||||
def _validate_template_value(
|
||||
@@ -311,20 +371,27 @@ class Expression:
|
||||
return context
|
||||
|
||||
@staticmethod
|
||||
def _render_template_value(value: ExpressionData, context: dict[str, Any]) -> Any:
|
||||
def _render_template_value(
|
||||
value: ExpressionData, context: dict[str, Any], run_context: _CelRunContext
|
||||
) -> Any:
|
||||
if isinstance(value, str):
|
||||
return Expression._render_template_string(value, context)
|
||||
return Expression._render_template_string(value, context, run_context)
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: Expression._render_template_value(item, context)
|
||||
key: Expression._render_template_value(item, context, run_context)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [Expression._render_template_value(item, context) for item in value]
|
||||
return [
|
||||
Expression._render_template_value(item, context, run_context)
|
||||
for item in value
|
||||
]
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _render_template_string(value: str, context: dict[str, Any]) -> Any:
|
||||
def _render_template_string(
|
||||
value: str, context: dict[str, Any], run_context: _CelRunContext
|
||||
) -> Any:
|
||||
segments = _parse_template_segments(value)
|
||||
expressions = [
|
||||
segment for segment in segments if isinstance(segment, _ExpressionSegment)
|
||||
@@ -333,26 +400,28 @@ class Expression:
|
||||
return value
|
||||
literals = [segment for segment in segments if isinstance(segment, str)]
|
||||
if len(expressions) == 1 and all(not literal.strip() for literal in literals):
|
||||
return Expression._evaluate_cel(expressions[0].source, context)
|
||||
return Expression._evaluate_cel(expressions[0].source, context, run_context)
|
||||
rendered: list[str] = []
|
||||
for segment in segments:
|
||||
if isinstance(segment, str):
|
||||
rendered.append(segment)
|
||||
continue
|
||||
result = Expression._evaluate_cel(segment.source, context)
|
||||
result = Expression._evaluate_cel(segment.source, context, run_context)
|
||||
rendered.append("" if result is None else _stringify_cel_value(result))
|
||||
return "".join(rendered)
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_cel(expression: str, context: dict[str, Any]) -> Any:
|
||||
def _evaluate_cel(
|
||||
expression: str, context: dict[str, Any], run_context: _CelRunContext
|
||||
) -> Any:
|
||||
try:
|
||||
from celpy import Environment
|
||||
from celpy.adapter import CELJSONEncoder, json_to_cel
|
||||
from celpy.evaluation import Context
|
||||
|
||||
environment = Environment()
|
||||
environment = _cel_environment()
|
||||
program = environment.program(
|
||||
Expression._compile_cel(expression, environment=environment)
|
||||
Expression._compile_cel(expression, environment=environment),
|
||||
functions=_cel_functions(run_context),
|
||||
)
|
||||
result = program.evaluate(cast(Context, json_to_cel(context)))
|
||||
if (eval_error := _find_cel_eval_error(result)) is not None:
|
||||
@@ -371,9 +440,7 @@ class Expression:
|
||||
environment: Any | None = None,
|
||||
) -> Any:
|
||||
if environment is None:
|
||||
from celpy import Environment
|
||||
|
||||
environment = Environment()
|
||||
environment = _cel_environment()
|
||||
try:
|
||||
return environment.compile(expression)
|
||||
except Exception as e:
|
||||
|
||||
@@ -295,8 +295,12 @@ class FlowHumanFeedbackDefinition(BaseModel):
|
||||
examples=[["approved", "revise"]],
|
||||
)
|
||||
llm: Any = Field(
|
||||
default="gpt-4o-mini",
|
||||
description="LLM configuration used to assist or process human feedback.",
|
||||
default=None,
|
||||
description=(
|
||||
"LLM used to collapse feedback to an emit outcome. "
|
||||
"None resolves at runtime via create_llm (project MODEL env, "
|
||||
"then DEFAULT_LLM_MODEL)."
|
||||
),
|
||||
examples=["gpt-4o-mini"],
|
||||
)
|
||||
default_outcome: str | None = Field(
|
||||
@@ -1006,14 +1010,6 @@ def log_flow_definition_issues(definition: FlowDefinition) -> None:
|
||||
)
|
||||
if method.human_feedback:
|
||||
human_feedback_config = method.human_feedback
|
||||
if human_feedback_config.emit and not human_feedback_config.llm:
|
||||
_log_flow_definition_issue(
|
||||
definition.name,
|
||||
code="human_feedback_llm_required",
|
||||
severity="error",
|
||||
path=f"{path}.human_feedback.llm",
|
||||
message="llm is required when human_feedback.emit is set",
|
||||
)
|
||||
if (
|
||||
human_feedback_config.default_outcome is not None
|
||||
and not human_feedback_config.emit
|
||||
|
||||
@@ -77,7 +77,37 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
F = TypeVar("F", bound=Callable[..., Any])
|
||||
|
||||
__all__ = ["HumanFeedbackResult", "human_feedback"]
|
||||
__all__ = ["HumanFeedbackCollapseError", "HumanFeedbackResult", "human_feedback"]
|
||||
|
||||
|
||||
class HumanFeedbackCollapseError(ValueError):
|
||||
"""Raised when human feedback cannot be mapped to an emit outcome."""
|
||||
|
||||
|
||||
def _match_collapse_outcome(response_text: str, outcomes: Sequence[str]) -> str | None:
|
||||
"""Return the emit label that best matches ``response_text``, or None."""
|
||||
response_clean = response_text.strip()
|
||||
for outcome in outcomes:
|
||||
if outcome.lower() == response_clean.lower():
|
||||
return outcome
|
||||
response_lower = response_clean.lower()
|
||||
best_outcome: str | None = None
|
||||
best_len = -1
|
||||
for outcome in outcomes:
|
||||
if outcome.lower() in response_lower and len(outcome) > best_len:
|
||||
best_outcome = outcome
|
||||
best_len = len(outcome)
|
||||
return best_outcome
|
||||
|
||||
|
||||
def _require_collapse_outcome(response_text: str, outcomes: Sequence[str]) -> str:
|
||||
matched = _match_collapse_outcome(response_text, outcomes)
|
||||
if matched is None:
|
||||
raise HumanFeedbackCollapseError(
|
||||
f"Could not match LLM response {response_text!r} to outcomes "
|
||||
f"{list(outcomes)}."
|
||||
)
|
||||
return matched
|
||||
|
||||
|
||||
def _serialize_llm_for_context(llm: Any) -> dict[str, Any] | str | None:
|
||||
@@ -165,7 +195,10 @@ class HumanFeedbackConfig:
|
||||
Attributes:
|
||||
message: The message shown to the human when requesting feedback.
|
||||
emit: Optional sequence of outcome strings for routing.
|
||||
llm: The LLM model to use for collapsing feedback to outcomes.
|
||||
llm: The LLM used to collapse feedback to an emit outcome.
|
||||
None means resolve at runtime via create_llm (decorator
|
||||
value, then MODEL / MODEL_NAME / OPENAI_MODEL_NAME, then
|
||||
DEFAULT_LLM_MODEL).
|
||||
default_outcome: The outcome to use when no feedback is provided.
|
||||
metadata: Optional metadata for enterprise integrations.
|
||||
provider: Optional custom feedback provider for async workflows.
|
||||
@@ -173,7 +206,7 @@ class HumanFeedbackConfig:
|
||||
|
||||
message: str
|
||||
emit: Sequence[str] | None = None
|
||||
llm: str | BaseLLM | None = "gpt-5.4-mini"
|
||||
llm: str | BaseLLM | None = None
|
||||
default_outcome: str | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
provider: HumanFeedbackProvider | None = None
|
||||
@@ -205,17 +238,9 @@ class DistilledLessons(BaseModel):
|
||||
|
||||
def _validate_human_feedback_options(
|
||||
emit: Sequence[str] | None,
|
||||
llm: Any,
|
||||
default_outcome: str | None,
|
||||
) -> None:
|
||||
if emit is not None:
|
||||
if not llm:
|
||||
raise ValueError(
|
||||
"llm is required when emit is specified. "
|
||||
"Provide an LLM model string (e.g., 'gpt-5.4-mini') or a BaseLLM instance. "
|
||||
"See the CrewAI Human-in-the-Loop (HITL) documentation for more information: "
|
||||
"https://docs.crewai.com/en/learn/human-feedback-in-flows"
|
||||
)
|
||||
if default_outcome is not None and default_outcome not in emit:
|
||||
raise ValueError(
|
||||
f"default_outcome '{default_outcome}' must be one of the "
|
||||
@@ -232,16 +257,23 @@ def _get_hitl_prompt(key: str) -> str:
|
||||
|
||||
|
||||
def _resolve_llm_instance(llm: Any) -> Any:
|
||||
"""Resolve a collapse/learn LLM the same way agents resolve theirs.
|
||||
|
||||
Explicit decorator values win. ``None`` follows ``create_llm``:
|
||||
``MODEL`` / ``MODEL_NAME`` / ``OPENAI_MODEL_NAME``, then
|
||||
``DEFAULT_LLM_MODEL``.
|
||||
"""
|
||||
from crewai.llm import LLM
|
||||
from crewai.utilities.llm_utils import create_llm
|
||||
|
||||
if llm is None:
|
||||
return LLM(model="gpt-5.4-mini")
|
||||
return create_llm(None)
|
||||
if isinstance(llm, str):
|
||||
return LLM(model=llm)
|
||||
if isinstance(llm, dict):
|
||||
deserialized = _deserialize_llm_from_context(llm)
|
||||
return deserialized if deserialized is not None else LLM(model="gpt-5.4-mini")
|
||||
return llm # already a BaseLLM instance
|
||||
return deserialized if deserialized is not None else create_llm(None)
|
||||
return llm
|
||||
|
||||
|
||||
def _pre_review_with_lessons(
|
||||
@@ -370,7 +402,7 @@ def _distill_and_store_lessons(
|
||||
def human_feedback(
|
||||
message: str,
|
||||
emit: Sequence[str] | None = None,
|
||||
llm: str | BaseLLM | None = "gpt-5.4-mini",
|
||||
llm: str | BaseLLM | None = None,
|
||||
default_outcome: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
provider: HumanFeedbackProvider | None = None,
|
||||
|
||||
@@ -13,7 +13,7 @@ from collections.abc import Callable, Iterator, Sequence
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
import contextvars
|
||||
import copy
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import enum
|
||||
import inspect
|
||||
import logging
|
||||
@@ -110,10 +110,12 @@ from crewai.flow.flow_wrappers import (
|
||||
StartMethod,
|
||||
)
|
||||
from crewai.flow.human_feedback import (
|
||||
HumanFeedbackCollapseError,
|
||||
HumanFeedbackResult,
|
||||
_deserialize_llm_from_context,
|
||||
_distill_and_store_lessons,
|
||||
_pre_review_with_lessons,
|
||||
_require_collapse_outcome,
|
||||
_resolve_llm_instance,
|
||||
_serialize_llm_for_context,
|
||||
)
|
||||
from crewai.flow.input_provider import InputProvider
|
||||
@@ -772,6 +774,7 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
# duration span emitted at the end does not need to hold a span open for
|
||||
# the life of the run.
|
||||
_telemetry_started_at: float | None = PrivateAttr(default=None)
|
||||
_cel_now: datetime | None = PrivateAttr(default=None)
|
||||
_event_futures: list[Future[None]] = PrivateAttr(default_factory=list)
|
||||
_pending_feedback_context: PendingFeedbackContext | None = PrivateAttr(default=None)
|
||||
_human_feedback_method_outputs: dict[str, Any] = PrivateAttr(default_factory=dict)
|
||||
@@ -1382,6 +1385,10 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
"No pending feedback context. Use from_pending() to restore a paused flow."
|
||||
)
|
||||
|
||||
# A fresh instant, not the persisted kickoff one: a flow can pause on
|
||||
# feedback for days, and expressions after resume must see today.
|
||||
self._cel_now = datetime.now(timezone.utc)
|
||||
|
||||
execution_token = begin_execution(self._pending_feedback_context.execution_uuid)
|
||||
|
||||
# Force `current_flow_id` to this flow's match id for the
|
||||
@@ -2171,6 +2178,8 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
restore_from_state_id=restore_from_state_id,
|
||||
)
|
||||
|
||||
self._cel_now = datetime.now(timezone.utc)
|
||||
|
||||
ctx = baggage.set_baggage("flow_inputs", inputs or {})
|
||||
ctx = baggage.set_baggage("flow_input_files", input_files or {}, context=ctx)
|
||||
flow_token = attach(ctx)
|
||||
@@ -3606,13 +3615,11 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
method_output: Any,
|
||||
) -> Any:
|
||||
llm = feedback_definition.llm
|
||||
llm_instance = (
|
||||
_deserialize_llm_from_context(llm) if isinstance(llm, (str, dict)) else llm
|
||||
)
|
||||
emit = feedback_definition.emit
|
||||
default_outcome = feedback_definition.default_outcome
|
||||
metadata = feedback_definition.metadata
|
||||
learn = feedback_definition.learn and self.memory is not None
|
||||
llm_instance = _resolve_llm_instance(llm) if (emit or learn) else llm
|
||||
|
||||
if learn:
|
||||
method_output = await asyncio.to_thread(
|
||||
@@ -3705,22 +3712,23 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
elif emit:
|
||||
collapsed_outcome = emit[0]
|
||||
elif emit:
|
||||
collapse_llm = (
|
||||
_deserialize_llm_from_context(llm)
|
||||
if isinstance(llm, (str, dict))
|
||||
else llm
|
||||
)
|
||||
if collapse_llm is not None:
|
||||
collapsed_outcome = await asyncio.to_thread(
|
||||
self._collapse_to_outcome,
|
||||
feedback=raw_feedback,
|
||||
outcomes=emit,
|
||||
llm=collapse_llm,
|
||||
collapse_llm = _resolve_llm_instance(llm)
|
||||
if collapse_llm is None:
|
||||
raise HumanFeedbackCollapseError(
|
||||
"Could not resolve an LLM to classify human feedback. "
|
||||
"Set llm= on @human_feedback or MODEL / MODEL_NAME / "
|
||||
"OPENAI_MODEL_NAME."
|
||||
)
|
||||
else:
|
||||
collapsed_outcome = emit[0]
|
||||
collapsed_outcome = await asyncio.to_thread(
|
||||
self._collapse_to_outcome,
|
||||
feedback=raw_feedback,
|
||||
outcomes=emit,
|
||||
llm=collapse_llm,
|
||||
)
|
||||
if emit and collapsed_outcome is None:
|
||||
collapsed_outcome = default_outcome or emit[0]
|
||||
raise HumanFeedbackCollapseError(
|
||||
f"Could not classify human feedback into one of {list(emit)}."
|
||||
)
|
||||
|
||||
result = HumanFeedbackResult(
|
||||
output=method_output,
|
||||
@@ -3842,6 +3850,10 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
|
||||
Returns:
|
||||
One of the outcome strings that best matches the feedback intent.
|
||||
|
||||
Raises:
|
||||
HumanFeedbackCollapseError: If the LLM cannot be called or its
|
||||
response cannot be mapped to one of ``outcomes``.
|
||||
"""
|
||||
from typing import Literal
|
||||
|
||||
@@ -3882,27 +3894,6 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
response_model=FeedbackOutcome,
|
||||
)
|
||||
|
||||
if isinstance(response, str):
|
||||
import json
|
||||
|
||||
try:
|
||||
parsed = json.loads(response)
|
||||
return str(parsed.get("outcome", outcomes[0]))
|
||||
except json.JSONDecodeError:
|
||||
response_clean = response.strip()
|
||||
for outcome in outcomes:
|
||||
if outcome.lower() == response_clean.lower():
|
||||
return outcome
|
||||
return outcomes[0]
|
||||
elif isinstance(response, FeedbackOutcome):
|
||||
return str(response.outcome)
|
||||
elif hasattr(response, "outcome"):
|
||||
return str(response.outcome)
|
||||
else:
|
||||
logger.warning(f"Unexpected response type: {type(response)}")
|
||||
return outcomes[0]
|
||||
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -3913,37 +3904,34 @@ class Flow(BaseModel, Generic[T], metaclass=FlowMeta):
|
||||
response = llm_instance.call(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
)
|
||||
response_clean = str(response).strip()
|
||||
|
||||
for outcome in outcomes:
|
||||
if outcome.lower() == response_clean.lower():
|
||||
return outcome
|
||||
|
||||
# Partial match (longest wins, first on length ties)
|
||||
response_lower = response_clean.lower()
|
||||
best_outcome: str | None = None
|
||||
best_len = -1
|
||||
for outcome in outcomes:
|
||||
if outcome.lower() in response_lower and len(outcome) > best_len:
|
||||
best_outcome = outcome
|
||||
best_len = len(outcome)
|
||||
if best_outcome is not None:
|
||||
return best_outcome
|
||||
|
||||
logger.warning(
|
||||
f"Could not match LLM response '{response_clean}' to outcomes {list(outcomes)}. "
|
||||
f"Falling back to first outcome: {outcomes[0]}"
|
||||
)
|
||||
return outcomes[0]
|
||||
|
||||
except HookAborted:
|
||||
raise
|
||||
except Exception as fallback_err:
|
||||
logger.warning(
|
||||
f"Simple prompting also failed: {fallback_err}. "
|
||||
f"Falling back to first outcome: {outcomes[0]}"
|
||||
)
|
||||
return outcomes[0]
|
||||
raise HumanFeedbackCollapseError(
|
||||
f"Could not classify human feedback into {list(outcomes)}: "
|
||||
f"{fallback_err}"
|
||||
) from fallback_err
|
||||
return _require_collapse_outcome(str(response), outcomes)
|
||||
|
||||
if isinstance(response, str):
|
||||
import json
|
||||
|
||||
try:
|
||||
parsed = json.loads(response)
|
||||
except json.JSONDecodeError:
|
||||
return _require_collapse_outcome(response, outcomes)
|
||||
if isinstance(parsed, dict):
|
||||
outcome = parsed.get("outcome")
|
||||
if isinstance(outcome, str):
|
||||
return _require_collapse_outcome(outcome, outcomes)
|
||||
return _require_collapse_outcome(response, outcomes)
|
||||
if isinstance(response, FeedbackOutcome):
|
||||
return str(response.outcome)
|
||||
if hasattr(response, "outcome"):
|
||||
return _require_collapse_outcome(str(response.outcome), outcomes)
|
||||
raise HumanFeedbackCollapseError(
|
||||
f"Unexpected collapse response type: {type(response)}"
|
||||
)
|
||||
|
||||
def _log_flow_event(
|
||||
self,
|
||||
|
||||
@@ -662,6 +662,19 @@ class LLM(BaseLLM):
|
||||
if model in AZURE_MODELS:
|
||||
return "azure"
|
||||
|
||||
# Bedrock namespaces Anthropic models as "anthropic.claude-*", optionally
|
||||
# region-prefixed ("us.anthropic.claude-*"). That form also satisfies the
|
||||
# anthropic pattern below, so it has to be settled first.
|
||||
if "anthropic." in model.lower():
|
||||
return "bedrock"
|
||||
|
||||
# Only anthropic and gemini have prefixes unambiguous enough to infer from.
|
||||
# Bedrock matches any model containing a dot (so "gpt-3.5-turbo") and Azure
|
||||
# matches every OpenAI prefix, so both would steal models from openai here.
|
||||
for provider in ("anthropic", "gemini"):
|
||||
if cls._matches_provider_pattern(model, provider):
|
||||
return provider
|
||||
|
||||
return "openai"
|
||||
|
||||
@classmethod
|
||||
@@ -2378,6 +2391,14 @@ class LLM(BaseLLM):
|
||||
):
|
||||
return [*messages, {"role": "user", "content": ""}] # type: ignore[list-item]
|
||||
|
||||
# Handle Gemini models - they require the last message to not be 'assistant'
|
||||
if (
|
||||
("gemini" in self.model.lower() or "google" in self.model.lower())
|
||||
and messages
|
||||
and messages[-1]["role"] == "assistant"
|
||||
):
|
||||
return [*messages, {"role": "user", "content": "Please continue."}] # type: ignore[list-item]
|
||||
|
||||
if not self.is_anthropic:
|
||||
return messages # type: ignore[return-value]
|
||||
|
||||
|
||||
@@ -81,6 +81,11 @@ def _default_max_tokens_for_model(model: str) -> int:
|
||||
|
||||
NATIVE_STRUCTURED_OUTPUT_MODELS: Final[
|
||||
tuple[
|
||||
Literal["claude-fable-5"],
|
||||
Literal["claude-opus-5"],
|
||||
Literal["claude-sonnet-5"],
|
||||
Literal["claude-opus-4-8"],
|
||||
Literal["claude-opus-4.8"],
|
||||
Literal["claude-sonnet-4-5"],
|
||||
Literal["claude-sonnet-4.5"],
|
||||
Literal["claude-opus-4-5"],
|
||||
@@ -89,6 +94,11 @@ NATIVE_STRUCTURED_OUTPUT_MODELS: Final[
|
||||
Literal["claude-haiku-4.5"],
|
||||
]
|
||||
] = (
|
||||
"claude-fable-5",
|
||||
"claude-opus-5",
|
||||
"claude-sonnet-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4.8",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-sonnet-4.5",
|
||||
"claude-opus-4-5",
|
||||
@@ -101,9 +111,9 @@ NATIVE_STRUCTURED_OUTPUT_MODELS: Final[
|
||||
def _supports_native_structured_outputs(model: str) -> bool:
|
||||
"""Check if the model supports native structured outputs.
|
||||
|
||||
Native structured outputs are only available for Claude 4.5 models
|
||||
(Sonnet 4.5, Opus 4.5, Haiku 4.5).
|
||||
Other models require the tool-based fallback approach.
|
||||
Covers Claude Fable 5, Opus 5, Sonnet 5, Opus 4.8 and the 4.5-era models
|
||||
(Sonnet 4.5, Opus 4.5, Haiku 4.5). Other models require the tool-based
|
||||
fallback approach.
|
||||
|
||||
Args:
|
||||
model: The model name/identifier.
|
||||
|
||||
@@ -562,12 +562,19 @@ class GeminiCompletion(BaseLLM):
|
||||
- System messages are separate system_instruction
|
||||
- Content is organized as Content objects with Parts
|
||||
- Roles are 'user' and 'model' (not 'assistant')
|
||||
- History may not end on a model turn; a "Please continue." user turn
|
||||
is appended when it does
|
||||
|
||||
Args:
|
||||
messages: Input messages
|
||||
|
||||
Returns:
|
||||
Tuple of (formatted_contents, system_instruction)
|
||||
|
||||
Raises:
|
||||
ValueError: If the history ends on a model turn with an unresolved
|
||||
function call, which requires a function response rather than a
|
||||
continuation prompt.
|
||||
"""
|
||||
base_formatted = super()._format_messages(messages)
|
||||
|
||||
@@ -680,6 +687,23 @@ class GeminiCompletion(BaseLLM):
|
||||
gemini_content = types.Content(role=gemini_role, parts=parts)
|
||||
contents.append(gemini_content)
|
||||
|
||||
if contents and contents[-1].role == "model":
|
||||
# Gemini's generateContent API rejects a request whose history ends
|
||||
# on a model turn (agent loops can produce this, e.g. after
|
||||
# max-iteration handling or a guardrail retry).
|
||||
last_parts = contents[-1].parts or []
|
||||
if any(part.function_call for part in last_parts):
|
||||
raise ValueError(
|
||||
"Gemini message history ends on an unresolved function call "
|
||||
"-- a function response must be provided before calling the "
|
||||
"model again."
|
||||
)
|
||||
contents.append(
|
||||
types.Content(
|
||||
role="user", parts=[types.Part.from_text(text="Please continue.")]
|
||||
)
|
||||
)
|
||||
|
||||
return contents, system_instruction
|
||||
|
||||
def _validate_and_emit_structured_output(
|
||||
|
||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
import os
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
from pydantic import model_validator
|
||||
|
||||
@@ -91,23 +92,39 @@ OPENAI_COMPATIBLE_PROVIDERS: dict[str, ProviderConfig] = {
|
||||
),
|
||||
}
|
||||
|
||||
_OLLAMA_DEFAULT_PORT = 11434
|
||||
|
||||
|
||||
def _normalize_ollama_base_url(base_url: str) -> str:
|
||||
"""Normalize Ollama base URL to ensure it ends with /v1.
|
||||
"""Normalize an Ollama base URL into a full OpenAI-compatible endpoint.
|
||||
|
||||
Ollama uses OLLAMA_HOST which may not include the /v1 suffix,
|
||||
but the OpenAI-compatible endpoint requires it.
|
||||
``OLLAMA_HOST`` follows Ollama's own convention and may be a bare host
|
||||
(``0.0.0.0``), a ``host:port`` pair (``127.0.0.1:11434``), or a full URL.
|
||||
Whichever parts are missing are filled in: ``http://`` when no scheme is
|
||||
given, the default Ollama port when none is given and the scheme is
|
||||
``http`` (``https`` implies 443), and the ``/v1`` suffix that the
|
||||
OpenAI-compatible endpoint requires.
|
||||
|
||||
Args:
|
||||
base_url: The base URL, potentially without /v1 suffix.
|
||||
base_url: The base URL, potentially missing scheme, port or /v1.
|
||||
|
||||
Returns:
|
||||
The base URL with /v1 suffix if needed.
|
||||
A fully-qualified base URL ending in /v1.
|
||||
"""
|
||||
base_url = base_url.rstrip("/")
|
||||
if not base_url.endswith("/v1"):
|
||||
return f"{base_url}/v1"
|
||||
return base_url
|
||||
if "://" not in base_url:
|
||||
base_url = f"http://{base_url}"
|
||||
|
||||
parts = urlsplit(base_url)
|
||||
|
||||
netloc = parts.netloc
|
||||
if parts.scheme == "http" and parts.port is None:
|
||||
netloc = f"{netloc}:{_OLLAMA_DEFAULT_PORT}"
|
||||
|
||||
path = parts.path.rstrip("/")
|
||||
if not path.endswith("/v1"):
|
||||
path = f"{path}/v1"
|
||||
|
||||
return urlunsplit((parts.scheme, netloc, path, parts.query, parts.fragment))
|
||||
|
||||
|
||||
class OpenAICompatibleCompletion(OpenAICompletion):
|
||||
|
||||
@@ -26,6 +26,7 @@ def _ensure_memory_kind(value: Any) -> Any:
|
||||
Pass-through for non-dict values (instances, ``bool``, ``None``).
|
||||
"""
|
||||
if isinstance(value, dict) and "memory_kind" not in value:
|
||||
value = dict(value)
|
||||
if "scopes" in value:
|
||||
value["memory_kind"] = "slice"
|
||||
elif "root_path" in value:
|
||||
@@ -55,6 +56,7 @@ class MemoryScope(BaseModel):
|
||||
return data
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Expected dict or MemoryScope, got {type(data).__name__}")
|
||||
data = dict(data)
|
||||
memory = data.pop("memory", None)
|
||||
instance: MemoryScope = handler(data)
|
||||
if memory is not None:
|
||||
@@ -245,6 +247,7 @@ class MemorySlice(BaseModel):
|
||||
return data
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"Expected dict or MemorySlice, got {type(data).__name__}")
|
||||
data = dict(data)
|
||||
memory = data.pop("memory", None)
|
||||
data["scopes"] = [s.rstrip("/") or "/" for s in data.get("scopes", [])]
|
||||
instance: MemorySlice = handler(data)
|
||||
|
||||
@@ -22,6 +22,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
from crewai_core.telemetry import (
|
||||
CommonAttributesSpanProcessor,
|
||||
Telemetry as CoreTelemetry,
|
||||
common_span_attributes,
|
||||
)
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
||||
@@ -161,11 +162,7 @@ class Telemetry:
|
||||
@classmethod
|
||||
def _is_telemetry_disabled(cls) -> bool:
|
||||
"""Check if telemetry should be disabled based on environment variables."""
|
||||
return (
|
||||
os.getenv("OTEL_SDK_DISABLED", "false").lower() == "true"
|
||||
or os.getenv("CREWAI_DISABLE_TELEMETRY", "false").lower() == "true"
|
||||
or os.getenv("CREWAI_DISABLE_TRACKING", "false").lower() == "true"
|
||||
)
|
||||
return CoreTelemetry._is_telemetry_disabled()
|
||||
|
||||
def _should_execute_telemetry(self) -> bool:
|
||||
"""Check if telemetry operations should be executed."""
|
||||
|
||||
@@ -612,8 +612,8 @@ def test_lite_agent_with_invalid_llm():
|
||||
|
||||
|
||||
@patch.dict("os.environ", {"CREWAI_PLATFORM_INTEGRATION_TOKEN": "test_token"})
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool.requests.post")
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.crewai_platform_tool_builder.requests.get")
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.post")
|
||||
@patch("crewai_tools.tools.crewai_platform_tools.integrations_client.requests.get")
|
||||
@pytest.mark.vcr()
|
||||
def test_agent_kickoff_with_platform_tools(mock_get, mock_post):
|
||||
"""Test that Agent.kickoff() properly integrates platform tools with LiteAgent"""
|
||||
|
||||
@@ -3,6 +3,7 @@ import sys
|
||||
import types
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from crewai.llm import CONTEXT_WINDOW_USAGE_RATIO, LLM
|
||||
from crewai.crew import Crew
|
||||
@@ -1761,3 +1762,196 @@ def test_anthropic_missing_cache_fields_default_to_zero():
|
||||
usage = llm._extract_anthropic_token_usage(mock_response)
|
||||
assert usage["cached_prompt_tokens"] == 0
|
||||
assert usage["cache_creation_tokens"] == 0
|
||||
|
||||
|
||||
# --- Native structured outputs: model gate -----------------------------------
|
||||
|
||||
NATIVE_STRUCTURED_OUTPUT_MODELS_UNDER_TEST = [
|
||||
"claude-opus-5",
|
||||
"claude-sonnet-5",
|
||||
"claude-fable-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-haiku-4-5",
|
||||
]
|
||||
|
||||
|
||||
class _Answer(BaseModel):
|
||||
answer: str
|
||||
|
||||
|
||||
_ANSWER_JSON = '{"answer": "42"}'
|
||||
|
||||
_WEATHER_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get the weather for a city",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _structured_text_response():
|
||||
"""A response whose single text block holds the structured JSON payload."""
|
||||
from anthropic.types.beta import BetaTextBlock
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = [
|
||||
BetaTextBlock(type="text", text=_ANSWER_JSON, citations=None)
|
||||
]
|
||||
mock_response.usage = MagicMock(input_tokens=10, output_tokens=5)
|
||||
mock_response.stop_reason = "end_turn"
|
||||
mock_response.id = "msg_structured"
|
||||
return mock_response
|
||||
|
||||
|
||||
def _structured_stream_events():
|
||||
return [
|
||||
types.SimpleNamespace(
|
||||
type="content_block_delta",
|
||||
index=0,
|
||||
delta=types.SimpleNamespace(type="text_delta", text=_ANSWER_JSON),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", NATIVE_STRUCTURED_OUTPUT_MODELS_UNDER_TEST)
|
||||
def test_native_structured_output_sync(model):
|
||||
"""Current Claude models ask the API for JSON directly, not via a forced tool."""
|
||||
from crewai.llms.providers.anthropic.completion import (
|
||||
ANTHROPIC_STRUCTURED_OUTPUTS_BETA,
|
||||
AnthropicCompletion,
|
||||
)
|
||||
|
||||
llm = AnthropicCompletion(model=model)
|
||||
mock_client = MagicMock()
|
||||
mock_client.beta.messages.create.return_value = _structured_text_response()
|
||||
llm._client = mock_client
|
||||
|
||||
result = llm.call("What is the answer?", response_model=_Answer)
|
||||
|
||||
assert result == _Answer(answer="42")
|
||||
kwargs = mock_client.beta.messages.create.call_args.kwargs
|
||||
assert ANTHROPIC_STRUCTURED_OUTPUTS_BETA in kwargs["betas"]
|
||||
assert kwargs["extra_body"]["output_format"]["type"] == "json_schema"
|
||||
mock_client.messages.create.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", NATIVE_STRUCTURED_OUTPUT_MODELS_UNDER_TEST)
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_structured_output_async(model):
|
||||
from crewai.llms.providers.anthropic.completion import (
|
||||
ANTHROPIC_STRUCTURED_OUTPUTS_BETA,
|
||||
AnthropicCompletion,
|
||||
)
|
||||
|
||||
llm = AnthropicCompletion(model=model)
|
||||
mock_client = MagicMock()
|
||||
mock_client.beta.messages.create = AsyncMock(
|
||||
return_value=_structured_text_response()
|
||||
)
|
||||
llm._async_client = mock_client
|
||||
|
||||
result = await llm.acall("What is the answer?", response_model=_Answer)
|
||||
|
||||
assert result == _Answer(answer="42")
|
||||
kwargs = mock_client.beta.messages.create.call_args.kwargs
|
||||
assert ANTHROPIC_STRUCTURED_OUTPUTS_BETA in kwargs["betas"]
|
||||
assert kwargs["extra_body"]["output_format"]["type"] == "json_schema"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", NATIVE_STRUCTURED_OUTPUT_MODELS_UNDER_TEST)
|
||||
def test_native_structured_output_sync_streaming(model):
|
||||
from crewai.llms.providers.anthropic.completion import (
|
||||
ANTHROPIC_STRUCTURED_OUTPUTS_BETA,
|
||||
AnthropicCompletion,
|
||||
)
|
||||
|
||||
llm = AnthropicCompletion(model=model, stream=True)
|
||||
mock_client = MagicMock()
|
||||
mock_client.beta.messages.stream.return_value = _SyncAnthropicStream(
|
||||
_structured_stream_events(), _structured_text_response()
|
||||
)
|
||||
llm._client = mock_client
|
||||
llm._emit_stream_chunk_event = MagicMock()
|
||||
|
||||
result = llm.call("What is the answer?", response_model=_Answer)
|
||||
|
||||
assert result == _Answer(answer="42")
|
||||
kwargs = mock_client.beta.messages.stream.call_args.kwargs
|
||||
assert ANTHROPIC_STRUCTURED_OUTPUTS_BETA in kwargs["betas"]
|
||||
assert kwargs["extra_body"]["output_format"]["type"] == "json_schema"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", NATIVE_STRUCTURED_OUTPUT_MODELS_UNDER_TEST)
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_structured_output_async_streaming(model):
|
||||
from crewai.llms.providers.anthropic.completion import (
|
||||
ANTHROPIC_STRUCTURED_OUTPUTS_BETA,
|
||||
AnthropicCompletion,
|
||||
)
|
||||
|
||||
llm = AnthropicCompletion(model=model, stream=True)
|
||||
mock_client = MagicMock()
|
||||
mock_client.beta.messages.stream.return_value = _AsyncAnthropicStream(
|
||||
_structured_stream_events(), _structured_text_response()
|
||||
)
|
||||
llm._async_client = mock_client
|
||||
llm._emit_stream_chunk_event = MagicMock()
|
||||
|
||||
result = await llm.acall("What is the answer?", response_model=_Answer)
|
||||
|
||||
assert result == _Answer(answer="42")
|
||||
kwargs = mock_client.beta.messages.stream.call_args.kwargs
|
||||
assert ANTHROPIC_STRUCTURED_OUTPUTS_BETA in kwargs["betas"]
|
||||
assert kwargs["extra_body"]["output_format"]["type"] == "json_schema"
|
||||
|
||||
|
||||
def test_native_structured_output_keeps_caller_tools():
|
||||
"""The native path leaves the caller's tools in place; the fallback replaces them."""
|
||||
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
|
||||
|
||||
llm = AnthropicCompletion(model="claude-opus-5")
|
||||
mock_client = MagicMock()
|
||||
mock_client.beta.messages.create.return_value = _structured_text_response()
|
||||
llm._client = mock_client
|
||||
|
||||
llm.call("What is the answer?", tools=[_WEATHER_TOOL], response_model=_Answer)
|
||||
|
||||
sent_tools = mock_client.beta.messages.create.call_args.kwargs["tools"]
|
||||
assert [tool["name"] for tool in sent_tools] == ["get_weather"]
|
||||
|
||||
|
||||
def test_tool_fallback_still_used_for_models_without_native_support():
|
||||
"""Models outside the supported set keep the forced-tool-call behavior."""
|
||||
from crewai.llms.providers.anthropic.completion import AnthropicCompletion
|
||||
|
||||
llm = AnthropicCompletion(model="claude-3-5-haiku-20241022")
|
||||
mock_response = MagicMock()
|
||||
mock_response.content = [
|
||||
{
|
||||
"type": "tool_use",
|
||||
"id": "toolu_1",
|
||||
"name": "structured_output",
|
||||
"input": {"answer": "42"},
|
||||
}
|
||||
]
|
||||
mock_response.usage = MagicMock(input_tokens=10, output_tokens=5)
|
||||
mock_response.stop_reason = "tool_use"
|
||||
mock_response.id = "msg_fallback"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.messages.create.return_value = mock_response
|
||||
llm._client = mock_client
|
||||
|
||||
result = llm.call("What is the answer?", response_model=_Answer)
|
||||
|
||||
assert result == _Answer(answer="42")
|
||||
kwargs = mock_client.messages.create.call_args.kwargs
|
||||
assert kwargs["tool_choice"] == {"type": "tool", "name": "structured_output"}
|
||||
assert "betas" not in kwargs
|
||||
mock_client.beta.messages.create.assert_not_called()
|
||||
|
||||
@@ -500,6 +500,66 @@ def test_gemini_message_formatting():
|
||||
assert formatted_contents[1].role == "model"
|
||||
|
||||
|
||||
def test_gemini_message_formatting_appends_user_turn_after_trailing_model_turn():
|
||||
"""
|
||||
Gemini's generateContent API rejects a request whose history ends on a
|
||||
model turn ("Requests ending with a model turn are not supported"). Agent
|
||||
loops can produce this (e.g. after max-iteration handling or a guardrail
|
||||
retry), so formatting must append a synthetic user turn to recover.
|
||||
"""
|
||||
llm = LLM(model="google/gemini-2.0-flash-001")
|
||||
|
||||
test_messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
]
|
||||
|
||||
formatted_contents, _ = llm._format_messages_for_gemini(test_messages)
|
||||
|
||||
assert [content.role for content in formatted_contents] == [
|
||||
"user",
|
||||
"model",
|
||||
"user",
|
||||
]
|
||||
assert formatted_contents[0].parts[0].text == "Hello"
|
||||
assert formatted_contents[1].parts[0].text == "Hi there!"
|
||||
assert formatted_contents[2].parts[0].text == "Please continue."
|
||||
assert test_messages == [
|
||||
{"role": "user", "content": "Hello"},
|
||||
{"role": "assistant", "content": "Hi there!"},
|
||||
]
|
||||
|
||||
|
||||
def test_gemini_message_formatting_leaves_user_terminated_history_unchanged():
|
||||
llm = LLM(model="google/gemini-2.0-flash-001")
|
||||
|
||||
test_messages = [{"role": "user", "content": "Hello"}]
|
||||
|
||||
formatted_contents, _ = llm._format_messages_for_gemini(test_messages)
|
||||
|
||||
assert len(formatted_contents) == 1
|
||||
assert formatted_contents[0].role == "user"
|
||||
assert formatted_contents[0].parts[0].text == "Hello"
|
||||
|
||||
|
||||
def test_gemini_message_formatting_raises_on_unresolved_function_call():
|
||||
llm = LLM(model="google/gemini-2.0-flash-001")
|
||||
|
||||
test_messages = [
|
||||
{"role": "user", "content": "What's the weather?"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"function": {"name": "get_weather", "arguments": "{}"}}
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="unresolved function call"):
|
||||
llm._format_messages_for_gemini(test_messages)
|
||||
|
||||
|
||||
def test_gemini_streaming_parameter():
|
||||
"""
|
||||
Test that streaming parameter is properly handled
|
||||
|
||||
@@ -114,7 +114,34 @@ class TestNormalizeOllamaBaseUrl:
|
||||
def test_handles_v1_with_trailing_slash(self):
|
||||
"""Test /v1/ is normalized."""
|
||||
assert _normalize_ollama_base_url("http://localhost:11434/v1/") == "http://localhost:11434/v1"
|
||||
|
||||
def test_bare_host_gets_scheme_and_port(self):
|
||||
"""Bare host from OLLAMA_HOST gets http:// and the default port."""
|
||||
assert _normalize_ollama_base_url("0.0.0.0") == "http://0.0.0.0:11434/v1"
|
||||
|
||||
def test_bare_localhost_gets_scheme_and_port(self):
|
||||
"""Bare localhost gets http:// and the default port."""
|
||||
assert _normalize_ollama_base_url("localhost") == "http://localhost:11434/v1"
|
||||
|
||||
def test_host_port_without_scheme_gets_scheme(self):
|
||||
"""host:port without a scheme gets http:// prepended."""
|
||||
assert _normalize_ollama_base_url("127.0.0.1:11434") == "http://127.0.0.1:11434/v1"
|
||||
|
||||
def test_lan_host_port_without_scheme(self):
|
||||
"""A LAN host:port without a scheme gets http:// prepended."""
|
||||
assert _normalize_ollama_base_url("192.168.1.5:11434") == "http://192.168.1.5:11434/v1"
|
||||
|
||||
def test_https_url_keeps_scheme_and_gets_no_default_port(self):
|
||||
"""An explicit https:// URL keeps its scheme and gets no default port."""
|
||||
assert _normalize_ollama_base_url("https://ollama.example.com") == "https://ollama.example.com/v1"
|
||||
|
||||
def test_root_path_with_query_does_not_double_slash(self):
|
||||
"""A root path alongside a query yields /v1, not //v1."""
|
||||
assert _normalize_ollama_base_url("http://ollama/?tenant=acme") == "http://ollama:11434/v1?tenant=acme"
|
||||
|
||||
def test_trailing_slash_in_query_is_preserved(self):
|
||||
"""Only the path is stripped, so a query ending in / keeps that character."""
|
||||
assert _normalize_ollama_base_url("http://ollama:11434/?x=a/") == "http://ollama:11434/v1?x=a/"
|
||||
|
||||
class TestOpenAICompatibleCompletion:
|
||||
"""Tests for OpenAICompatibleCompletion class."""
|
||||
|
||||
@@ -240,6 +240,50 @@ def test_memory_scope_slice(tmp_path: Path, mock_embedder: MagicMock) -> None:
|
||||
assert "/a" in sl.scopes and "/b" in sl.scopes
|
||||
|
||||
|
||||
def test_memory_scope_config_can_be_reused() -> None:
|
||||
"""Constructing a scope must not remove the memory from caller-owned config."""
|
||||
from crewai.memory.memory_scope import MemoryScope
|
||||
|
||||
memory = MagicMock()
|
||||
config = {"memory": memory, "root_path": "/agent/1"}
|
||||
|
||||
first = MemoryScope.model_validate(config)
|
||||
second = MemoryScope.model_validate(config)
|
||||
|
||||
assert config == {"memory": memory, "root_path": "/agent/1"}
|
||||
assert first._require_memory() is memory
|
||||
assert second._require_memory() is memory
|
||||
|
||||
|
||||
def test_memory_slice_config_can_be_reused_without_normalizing_it_in_place() -> None:
|
||||
"""Constructing a slice must preserve caller-owned dependencies and paths."""
|
||||
from crewai.memory.memory_scope import MemorySlice
|
||||
|
||||
memory = MagicMock()
|
||||
config = {"memory": memory, "scopes": ["/team/", "/"]}
|
||||
|
||||
first = MemorySlice.model_validate(config)
|
||||
second = MemorySlice.model_validate(config)
|
||||
|
||||
assert config == {"memory": memory, "scopes": ["/team/", "/"]}
|
||||
assert first.scopes == ["/team", "/"]
|
||||
assert second.scopes == ["/team", "/"]
|
||||
assert first._require_memory() is memory
|
||||
assert second._require_memory() is memory
|
||||
|
||||
|
||||
def test_memory_kind_inference_preserves_input() -> None:
|
||||
"""Inferring a legacy config's discriminator must not mutate that config."""
|
||||
from crewai.memory.memory_scope import _ensure_memory_kind
|
||||
|
||||
config = {"root_path": "/agent/1"}
|
||||
|
||||
normalized = _ensure_memory_kind(config)
|
||||
|
||||
assert config == {"root_path": "/agent/1"}
|
||||
assert normalized == {"root_path": "/agent/1", "memory_kind": "scope"}
|
||||
|
||||
|
||||
def test_memory_list_scopes_info_tree(tmp_path: Path, mock_embedder: MagicMock) -> None:
|
||||
from crewai.memory.unified_memory import Memory
|
||||
|
||||
|
||||
@@ -28,10 +28,21 @@ def cleanup_telemetry():
|
||||
[
|
||||
("OTEL_SDK_DISABLED", "true", False),
|
||||
("OTEL_SDK_DISABLED", "TRUE", False),
|
||||
("OTEL_SDK_DISABLED", "1", False),
|
||||
("OTEL_SDK_DISABLED", "yes", False),
|
||||
("OTEL_SDK_DISABLED", "on", False),
|
||||
("CREWAI_DISABLE_TELEMETRY", "true", False),
|
||||
("CREWAI_DISABLE_TELEMETRY", "TRUE", False),
|
||||
("CREWAI_DISABLE_TELEMETRY", "1", False),
|
||||
("CREWAI_DISABLE_TELEMETRY", "yes", False),
|
||||
("CREWAI_DISABLE_TELEMETRY", "on", False),
|
||||
("CREWAI_DISABLE_TRACKING", "1", False),
|
||||
("OTEL_SDK_DISABLED", "false", True),
|
||||
("OTEL_SDK_DISABLED", "0", True),
|
||||
("CREWAI_DISABLE_TELEMETRY", "false", True),
|
||||
("CREWAI_DISABLE_TELEMETRY", "0", True),
|
||||
("CREWAI_DISABLE_TELEMETRY", "no", True),
|
||||
("CREWAI_DISABLE_TELEMETRY", "off", True),
|
||||
],
|
||||
)
|
||||
def test_telemetry_environment_variables(env_var, value, expected_ready):
|
||||
|
||||
@@ -19,10 +19,21 @@ def cleanup_telemetry():
|
||||
[
|
||||
("OTEL_SDK_DISABLED", "true", False),
|
||||
("OTEL_SDK_DISABLED", "TRUE", False),
|
||||
("OTEL_SDK_DISABLED", "1", False),
|
||||
("OTEL_SDK_DISABLED", "yes", False),
|
||||
("OTEL_SDK_DISABLED", "on", False),
|
||||
("CREWAI_DISABLE_TELEMETRY", "true", False),
|
||||
("CREWAI_DISABLE_TELEMETRY", "TRUE", False),
|
||||
("CREWAI_DISABLE_TELEMETRY", "1", False),
|
||||
("CREWAI_DISABLE_TELEMETRY", "yes", False),
|
||||
("CREWAI_DISABLE_TELEMETRY", "on", False),
|
||||
("CREWAI_DISABLE_TRACKING", "1", False),
|
||||
("OTEL_SDK_DISABLED", "false", True),
|
||||
("OTEL_SDK_DISABLED", "0", True),
|
||||
("CREWAI_DISABLE_TELEMETRY", "false", True),
|
||||
("CREWAI_DISABLE_TELEMETRY", "0", True),
|
||||
("CREWAI_DISABLE_TELEMETRY", "no", True),
|
||||
("CREWAI_DISABLE_TELEMETRY", "off", True),
|
||||
],
|
||||
)
|
||||
def test_telemetry_environment_variables(env_var, value, expected_ready):
|
||||
|
||||
@@ -1099,6 +1099,42 @@ class TestCollapseToOutcomeJsonParsing:
|
||||
|
||||
assert result == "approved"
|
||||
|
||||
def test_json_without_outcome_raises(self) -> None:
|
||||
"""Parsed JSON missing a usable outcome fails closed."""
|
||||
from crewai.flow.human_feedback import HumanFeedbackCollapseError
|
||||
|
||||
flow = Flow()
|
||||
|
||||
with patch("crewai.llm.LLM") as MockLLM:
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.call.return_value = '{"foo": "bar"}'
|
||||
MockLLM.return_value = mock_llm
|
||||
|
||||
with pytest.raises(HumanFeedbackCollapseError, match="Could not match"):
|
||||
flow._collapse_to_outcome(
|
||||
feedback="looks good",
|
||||
outcomes=["approved", "rejected"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
def test_non_object_json_does_not_attribute_error(self) -> None:
|
||||
"""JSON that is not an object is matched as raw text, not parsed.get."""
|
||||
from crewai.flow.human_feedback import HumanFeedbackCollapseError
|
||||
|
||||
flow = Flow()
|
||||
|
||||
with patch("crewai.llm.LLM") as MockLLM:
|
||||
mock_llm = MagicMock()
|
||||
mock_llm.call.return_value = "null"
|
||||
MockLLM.return_value = mock_llm
|
||||
|
||||
with pytest.raises(HumanFeedbackCollapseError, match="Could not match"):
|
||||
flow._collapse_to_outcome(
|
||||
feedback="looks good",
|
||||
outcomes=["approved", "rejected"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
def test_llm_exception_falls_back_to_simple_prompting(self) -> None:
|
||||
"""Test that LLM exception triggers fallback to simple prompting."""
|
||||
flow = Flow()
|
||||
|
||||
@@ -3047,6 +3047,94 @@ def test_expression_keeps_short_circuited_cel_errors():
|
||||
)
|
||||
|
||||
|
||||
def test_expression_now_evaluates_with_frozen_timestamp():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from crewai.flow.expressions import Expression
|
||||
|
||||
frozen = datetime(2026, 1, 15, 12, 30, tzinfo=timezone.utc)
|
||||
|
||||
assert Expression("now().getFullYear()", context={}, now=frozen).evaluate() == 2026
|
||||
assert (
|
||||
Expression("string(now())", context={}, now=frozen).evaluate()
|
||||
== "2026-01-15T12:30:00Z"
|
||||
)
|
||||
assert (
|
||||
Expression("string(now() - duration('24h'))", context={}, now=frozen).evaluate()
|
||||
== "2026-01-14T12:30:00Z"
|
||||
)
|
||||
|
||||
|
||||
def test_expression_now_defaults_to_current_time():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from crewai.flow.expressions import Expression
|
||||
|
||||
year = Expression("now().getFullYear()", context={}).evaluate()
|
||||
|
||||
assert year == datetime.now(timezone.utc).year
|
||||
|
||||
|
||||
def test_expression_now_renders_in_templates():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from crewai.flow.expressions import Expression
|
||||
|
||||
frozen = datetime(2026, 1, 15, tzinfo=timezone.utc)
|
||||
rendered = Expression(
|
||||
{"query": "News from ${string(now().getFullYear())}"},
|
||||
context={},
|
||||
now=frozen,
|
||||
).render_template()
|
||||
|
||||
assert rendered == {"query": "News from 2026"}
|
||||
|
||||
|
||||
def test_expression_now_passes_root_validation():
|
||||
from crewai.flow.expressions import Expression
|
||||
|
||||
Expression("string(now().getFullYear())").validate_expression(
|
||||
allowed_roots=["state", "outputs"]
|
||||
)
|
||||
|
||||
|
||||
def test_expression_from_flow_uses_run_frozen_now():
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from crewai.flow.expressions import Expression
|
||||
|
||||
flow = Flow()
|
||||
flow._cel_now = datetime(2026, 1, 15, tzinfo=timezone.utc)
|
||||
|
||||
assert (
|
||||
Expression.from_flow("now().getFullYear()", flow).evaluate() == 2026
|
||||
)
|
||||
|
||||
|
||||
def test_expression_action_can_use_now():
|
||||
definition = FlowDefinition.from_declaration(contents=
|
||||
{
|
||||
"schema": "crewai.flow/v1",
|
||||
"name": "NowFlow",
|
||||
"methods": {
|
||||
"today": {
|
||||
"start": True,
|
||||
"do": {
|
||||
"call": "expression",
|
||||
"expr": "string(now().getFullYear())",
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
result = Flow.from_declaration(contents=definition).kickoff()
|
||||
|
||||
assert result == str(datetime.now(timezone.utc).year)
|
||||
|
||||
|
||||
def test_expression_action_can_route_like_if_else():
|
||||
yaml_str = f"""
|
||||
schema: crewai.flow/v1
|
||||
@@ -3738,6 +3826,41 @@ def test_resume_synthetic_completion_persists():
|
||||
assert _saved_methods("resume-synthetic") == ["generate"]
|
||||
|
||||
|
||||
def test_resume_freezes_fresh_cel_now():
|
||||
from crewai.flow.expressions import Expression
|
||||
|
||||
backend = DefinitionStoreBackend(store="resume-cel-now")
|
||||
frozen_at_listener: list[Any] = []
|
||||
|
||||
class NowResumableFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(message="Review:")
|
||||
def generate(self):
|
||||
return "content"
|
||||
|
||||
@listen(generate)
|
||||
def process(self, result):
|
||||
frozen_at_listener.append(self._cel_now)
|
||||
return Expression.from_flow("string(now())", self).evaluate()
|
||||
|
||||
context = PendingFeedbackContext(
|
||||
flow_id="resume-cel-now-1",
|
||||
flow_class="NowResumableFlow",
|
||||
method_name="generate",
|
||||
method_output="content",
|
||||
message="Review:",
|
||||
)
|
||||
backend.save_pending_feedback("resume-cel-now-1", context, {"id": "resume-cel-now-1"})
|
||||
|
||||
flow = NowResumableFlow.from_pending("resume-cel-now-1", backend)
|
||||
assert flow._cel_now is None
|
||||
|
||||
result = flow.resume("looks good")
|
||||
|
||||
assert frozen_at_listener[0] is not None
|
||||
assert result == frozen_at_listener[0].strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
class ReviewFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
|
||||
@@ -13,29 +13,45 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.constants import DEFAULT_LLM_MODEL
|
||||
from crewai.flow import Flow, human_feedback, listen, persist, start
|
||||
from crewai.flow.human_feedback import (
|
||||
HumanFeedbackCollapseError,
|
||||
HumanFeedbackConfig,
|
||||
HumanFeedbackResult,
|
||||
_resolve_llm_instance,
|
||||
)
|
||||
|
||||
|
||||
class TestHumanFeedbackValidation:
|
||||
"""Tests for decorator parameter validation."""
|
||||
|
||||
def test_emit_requires_llm(self):
|
||||
"""Test that specifying emit with llm=None raises ValueError."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
def test_emit_allows_omitted_llm(self):
|
||||
"""emit without an explicit llm is allowed; the engine resolves one later."""
|
||||
|
||||
@human_feedback(
|
||||
message="Review this:",
|
||||
emit=["approve", "reject"],
|
||||
llm=None,
|
||||
)
|
||||
def test_method(self):
|
||||
return "output"
|
||||
@human_feedback(
|
||||
message="Review this:",
|
||||
emit=["approve", "reject"],
|
||||
)
|
||||
def test_method(self):
|
||||
return "output"
|
||||
|
||||
assert "llm is required" in str(exc_info.value)
|
||||
config = test_method.__human_feedback_config__
|
||||
assert config.emit == ["approve", "reject"]
|
||||
assert config.llm is None
|
||||
|
||||
def test_emit_allows_explicit_llm_none(self):
|
||||
"""llm=None with emit is the same as omitting llm."""
|
||||
|
||||
@human_feedback(
|
||||
message="Review this:",
|
||||
emit=["approve", "reject"],
|
||||
llm=None,
|
||||
)
|
||||
def test_method(self):
|
||||
return "output"
|
||||
|
||||
assert test_method.__human_feedback_config__.llm is None
|
||||
|
||||
def test_default_outcome_requires_emit(self):
|
||||
"""Test that specifying default_outcome without emit raises ValueError."""
|
||||
@@ -129,6 +145,38 @@ class TestHumanFeedbackConfig:
|
||||
assert config.metadata == {"key": "value"}
|
||||
|
||||
|
||||
class TestResolveLlmInstance:
|
||||
"""Omitted decorator llm follows create_llm (project MODEL, then default)."""
|
||||
|
||||
def test_none_uses_model_env(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("MODEL", "anthropic/claude-sonnet-4-5")
|
||||
monkeypatch.delenv("MODEL_NAME", raising=False)
|
||||
monkeypatch.delenv("OPENAI_MODEL_NAME", raising=False)
|
||||
|
||||
llm = _resolve_llm_instance(None)
|
||||
|
||||
assert llm is not None
|
||||
assert "claude-sonnet-4-5" in getattr(llm, "model", "")
|
||||
|
||||
def test_explicit_string_wins_over_model_env(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setenv("MODEL", "anthropic/claude-sonnet-4-5")
|
||||
|
||||
llm = _resolve_llm_instance("gpt-4o-mini")
|
||||
|
||||
assert llm is not None
|
||||
assert getattr(llm, "model", "") == "gpt-4o-mini"
|
||||
|
||||
def test_none_falls_back_to_default_model(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.delenv("MODEL", raising=False)
|
||||
monkeypatch.delenv("MODEL_NAME", raising=False)
|
||||
monkeypatch.delenv("OPENAI_MODEL_NAME", raising=False)
|
||||
|
||||
llm = _resolve_llm_instance(None)
|
||||
|
||||
assert llm is not None
|
||||
assert getattr(llm, "model", "") == DEFAULT_LLM_MODEL
|
||||
|
||||
|
||||
class TestHumanFeedbackResult:
|
||||
"""Tests for HumanFeedbackResult dataclass."""
|
||||
|
||||
@@ -318,6 +366,41 @@ class TestHumanFeedbackExecution:
|
||||
# But the outcome is still correctly set for routing purposes
|
||||
assert flow.last_human_feedback.outcome == "approved"
|
||||
|
||||
@patch("builtins.input", return_value="Approved!")
|
||||
@patch("builtins.print")
|
||||
def test_omitted_llm_collapse_uses_project_model(
|
||||
self, mock_print, mock_input, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""When emit is set without llm=, collapse uses MODEL via create_llm."""
|
||||
monkeypatch.setenv("MODEL", "anthropic/claude-sonnet-4-5")
|
||||
monkeypatch.delenv("MODEL_NAME", raising=False)
|
||||
monkeypatch.delenv("OPENAI_MODEL_NAME", raising=False)
|
||||
|
||||
class TestFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review:",
|
||||
emit=["approved", "rejected"],
|
||||
)
|
||||
def review(self):
|
||||
return "Content"
|
||||
|
||||
flow = TestFlow()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
flow, "_request_human_feedback", return_value="Looks great, approved!"
|
||||
),
|
||||
patch.object(flow, "_collapse_to_outcome", return_value="approved") as mock_collapse,
|
||||
):
|
||||
result = flow.kickoff()
|
||||
|
||||
assert result == "Content"
|
||||
mock_collapse.assert_called_once()
|
||||
collapse_llm = mock_collapse.call_args.kwargs["llm"]
|
||||
assert collapse_llm is not None
|
||||
assert "claude-sonnet-4-5" in getattr(collapse_llm, "model", "")
|
||||
|
||||
|
||||
class TestHumanFeedbackHistory:
|
||||
"""Tests for human feedback history tracking."""
|
||||
@@ -405,8 +488,8 @@ class TestCollapseToOutcome:
|
||||
|
||||
assert result == "approved"
|
||||
|
||||
def test_fallback_to_first(self):
|
||||
"""Test that unmatched response falls back to first outcome."""
|
||||
def test_unmatched_response_raises(self):
|
||||
"""Unmatched LLM text fails closed instead of taking emit[0]."""
|
||||
flow = Flow()
|
||||
|
||||
with patch("crewai.llm.LLM") as MockLLM:
|
||||
@@ -414,31 +497,28 @@ class TestCollapseToOutcome:
|
||||
mock_llm.call.return_value = "something completely different"
|
||||
MockLLM.return_value = mock_llm
|
||||
|
||||
result = flow._collapse_to_outcome(
|
||||
feedback="Unclear feedback",
|
||||
outcomes=["approved", "rejected"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
with pytest.raises(HumanFeedbackCollapseError, match="Could not match"):
|
||||
flow._collapse_to_outcome(
|
||||
feedback="Unclear feedback",
|
||||
outcomes=["approved", "rejected"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
|
||||
assert result == "approved"
|
||||
|
||||
def test_both_llm_calls_fail_returns_first_outcome(self):
|
||||
"""When both structured and simple prompting fail, return outcomes[0]."""
|
||||
def test_both_llm_calls_fail_raises(self):
|
||||
"""When both structured and simple prompting fail, raise instead of emit[0]."""
|
||||
flow = Flow()
|
||||
|
||||
with patch("crewai.llm.LLM") as MockLLM:
|
||||
mock_llm = MagicMock()
|
||||
# Both calls raise — simulates wrong provider / auth failure
|
||||
mock_llm.call.side_effect = RuntimeError("Model not found")
|
||||
MockLLM.return_value = mock_llm
|
||||
|
||||
result = flow._collapse_to_outcome(
|
||||
feedback="looks great, approve it",
|
||||
outcomes=["needs_changes", "approved"],
|
||||
llm="gemini-3-flash-preview",
|
||||
)
|
||||
|
||||
assert result == "needs_changes" # First in list (safe fallback)
|
||||
with pytest.raises(HumanFeedbackCollapseError, match="Could not classify"):
|
||||
flow._collapse_to_outcome(
|
||||
feedback="looks great, approve it",
|
||||
outcomes=["needs_changes", "approved"],
|
||||
llm="gemini-3-flash-preview",
|
||||
)
|
||||
|
||||
def test_structured_fails_but_simple_succeeds(self):
|
||||
"""When structured output fails but simple prompting works, use that."""
|
||||
@@ -460,7 +540,70 @@ class TestCollapseToOutcome:
|
||||
|
||||
assert result == "approved"
|
||||
|
||||
def test_collapse_failure_does_not_route_to_first_emit(self):
|
||||
"""A failed collapse must not fire the first emit listener."""
|
||||
routed: list[str] = []
|
||||
|
||||
class TestFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review:",
|
||||
emit=["approved", "rejected"],
|
||||
llm="gpt-4o-mini",
|
||||
)
|
||||
def review(self):
|
||||
return "payment of $10"
|
||||
|
||||
@listen("approved")
|
||||
def send_money(self):
|
||||
routed.append("approved")
|
||||
return "sent"
|
||||
|
||||
@listen("rejected")
|
||||
def stop(self):
|
||||
routed.append("rejected")
|
||||
return "stopped"
|
||||
|
||||
flow = TestFlow()
|
||||
with (
|
||||
patch.object(
|
||||
flow, "_request_human_feedback", return_value="no, reject this"
|
||||
),
|
||||
patch.object(
|
||||
flow,
|
||||
"_collapse_to_outcome",
|
||||
side_effect=HumanFeedbackCollapseError("failed"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(HumanFeedbackCollapseError, match="failed"):
|
||||
flow.kickoff()
|
||||
|
||||
assert routed == []
|
||||
|
||||
def test_unresolved_collapse_llm_raises(self):
|
||||
"""If no LLM can be resolved, do not take emit[0]."""
|
||||
|
||||
class TestFlow(Flow):
|
||||
@start()
|
||||
@human_feedback(
|
||||
message="Review:",
|
||||
emit=["approved", "rejected"],
|
||||
)
|
||||
def review(self):
|
||||
return "payment of $10"
|
||||
|
||||
flow = TestFlow()
|
||||
with (
|
||||
patch.object(
|
||||
flow, "_request_human_feedback", return_value="no, reject this"
|
||||
),
|
||||
patch(
|
||||
"crewai.flow.runtime._resolve_llm_instance",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
with pytest.raises(HumanFeedbackCollapseError, match="Could not resolve"):
|
||||
flow.kickoff()
|
||||
|
||||
|
||||
class TestHumanFeedbackLearn:
|
||||
@@ -596,8 +739,8 @@ class TestHumanFeedbackLearn:
|
||||
|
||||
flow.memory.remember_many.assert_not_called()
|
||||
|
||||
def test_learn_true_uses_default_llm(self):
|
||||
"""When learn=True and llm is not explicitly set, the default gpt-5.4-mini is used."""
|
||||
def test_learn_true_omits_llm_by_default(self):
|
||||
"""When learn=True and llm is not set, config.llm stays None for runtime resolve."""
|
||||
|
||||
@human_feedback(message="Review:", learn=True)
|
||||
def test_method(self):
|
||||
@@ -606,8 +749,7 @@ class TestHumanFeedbackLearn:
|
||||
config = test_method.__human_feedback_config__
|
||||
assert config is not None
|
||||
assert config.learn is True
|
||||
# llm defaults to "gpt-5.4-mini" at the function level
|
||||
assert config.llm == "gpt-5.4-mini"
|
||||
assert config.llm is None
|
||||
|
||||
def test_pre_review_failure_logs_and_returns_raw_output(self, caplog):
|
||||
"""Pre-review LLM failure falls back to raw output AND logs a warning."""
|
||||
|
||||
@@ -849,6 +849,33 @@ def test_ollama_does_not_modify_when_last_is_user(ollama_llm):
|
||||
assert formatted == original_messages
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gemini_litellm_llm():
|
||||
return LLM(model="gemini/gemini-flash-latest", is_litellm=True)
|
||||
|
||||
|
||||
def test_gemini_litellm_appends_user_message_when_last_is_assistant(gemini_litellm_llm):
|
||||
original_messages = [
|
||||
{"role": "user", "content": "Hi there"},
|
||||
{"role": "assistant", "content": "Hello!"},
|
||||
]
|
||||
|
||||
formatted = gemini_litellm_llm._format_messages_for_provider(original_messages)
|
||||
|
||||
assert len(formatted) == len(original_messages) + 1
|
||||
assert formatted[-1] == {"role": "user", "content": "Please continue."}
|
||||
|
||||
|
||||
def test_gemini_litellm_does_not_modify_when_last_is_user(gemini_litellm_llm):
|
||||
original_messages = [
|
||||
{"role": "user", "content": "Tell me a joke."},
|
||||
]
|
||||
|
||||
formatted = gemini_litellm_llm._format_messages_for_provider(original_messages)
|
||||
|
||||
assert formatted == original_messages
|
||||
|
||||
|
||||
def test_native_provider_raises_error_when_supported_but_fails():
|
||||
"""Test that when a native provider is in SUPPORTED_NATIVE_PROVIDERS but fails to instantiate, we raise the error."""
|
||||
with patch("crewai.llm.SUPPORTED_NATIVE_PROVIDERS", ["openai"]):
|
||||
@@ -994,6 +1021,71 @@ def test_unprefixed_models_use_native_sdk():
|
||||
assert llm3.provider == "gemini"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
["claude-opus-5", "claude-sonnet-5", "claude-fable-5", "claude-opus-4-8"],
|
||||
)
|
||||
def test_current_claude_models_route_to_anthropic(model):
|
||||
"""Current Claude models are in the constants list and use the Anthropic SDK."""
|
||||
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}):
|
||||
llm = LLM(model=model, is_litellm=False)
|
||||
assert llm.provider == "anthropic"
|
||||
|
||||
|
||||
def test_claude_model_newer_than_constants_routes_to_anthropic():
|
||||
"""A Claude release we have not listed yet must not fall through to OpenAI."""
|
||||
with patch.dict(os.environ, {"ANTHROPIC_API_KEY": "test-key"}):
|
||||
llm = LLM(model="claude-opus-6-20990101", is_litellm=False)
|
||||
assert llm.provider == "anthropic"
|
||||
|
||||
|
||||
def test_gemini_model_newer_than_constants_routes_to_gemini():
|
||||
with patch.dict(os.environ, {"GOOGLE_API_KEY": "test-key"}):
|
||||
llm = LLM(model="gemini-9-pro-preview", is_litellm=False)
|
||||
assert llm.provider == "gemini"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"anthropic.claude-opus-9-20990101-v1:0",
|
||||
"us.anthropic.claude-opus-9-20990101-v1:0",
|
||||
"eu.anthropic.claude-sonnet-9-20990101-v1:0",
|
||||
],
|
||||
)
|
||||
def test_unlisted_bedrock_anthropic_ids_route_to_bedrock(model):
|
||||
"""Bedrock names Anthropic models "anthropic.claude-*"; that is not the direct API."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AWS_ACCESS_KEY_ID": "test-key",
|
||||
"AWS_SECRET_ACCESS_KEY": "test-secret",
|
||||
"AWS_DEFAULT_REGION": "us-east-1",
|
||||
},
|
||||
):
|
||||
llm = LLM(model=model, is_litellm=False)
|
||||
assert llm.provider == "bedrock"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "expected_provider"),
|
||||
[
|
||||
# Bedrock's pattern is `"." in model` and Azure's covers every OpenAI
|
||||
# prefix, so these pin that pattern inference did not steal them.
|
||||
("gpt-3.5-turbo", "openai"),
|
||||
("gpt-4.1", "openai"),
|
||||
("gpt-4o", "openai"),
|
||||
("gpt-4o-mini", "openai"),
|
||||
("o1", "openai"),
|
||||
("some-unknown-model", "openai"),
|
||||
],
|
||||
)
|
||||
def test_non_claude_models_keep_their_inferred_provider(model, expected_provider):
|
||||
with patch.dict(os.environ, {"OPENAI_API_KEY": "test-key"}):
|
||||
llm = LLM(model=model, is_litellm=False)
|
||||
assert llm.provider == expected_provider
|
||||
|
||||
|
||||
def test_explicit_provider_kwarg_takes_priority():
|
||||
"""Test that explicit provider kwarg takes priority over model name inference."""
|
||||
# Explicit provider=openai should use OpenAI even if model name suggests otherwise
|
||||
|
||||
@@ -1695,26 +1695,25 @@ class TestPlatformActionTool:
|
||||
@staticmethod
|
||||
def _tool() -> Any:
|
||||
import crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool as mod
|
||||
import crewai_tools.tools.crewai_platform_tools.integrations_client as client_mod
|
||||
|
||||
return mod.CrewAIPlatformActionTool(
|
||||
description="Send a Slack message",
|
||||
app="slack",
|
||||
action_name="slackbot_send_message",
|
||||
action_schema={
|
||||
"function": {
|
||||
"name": "slackbot_send_message",
|
||||
"parameters": {
|
||||
"properties": {"channel": {"type": "string"}},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
},
|
||||
client_mod.ToolInfo(
|
||||
app="slack",
|
||||
action="slackbot_send_message",
|
||||
connection_id=None,
|
||||
description="Send a Slack message",
|
||||
parameters={
|
||||
"properties": {"channel": {"type": "string"}},
|
||||
"required": [],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def test_non_ok_response_becomes_a_tool_failure(self, monkeypatch) -> None: # noqa: ANN001
|
||||
from unittest.mock import Mock
|
||||
|
||||
import crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool as mod
|
||||
import crewai_tools.tools.crewai_platform_tools.integrations_client as client_mod
|
||||
|
||||
response = Mock()
|
||||
response.ok = False
|
||||
@@ -1722,7 +1721,7 @@ class TestPlatformActionTool:
|
||||
response.json.return_value = {
|
||||
"error": "Failed to execute action: Slack API error: channel_not_found"
|
||||
}
|
||||
monkeypatch.setattr(mod.requests, "post", Mock(return_value=response))
|
||||
monkeypatch.setattr(client_mod.requests, "post", Mock(return_value=response))
|
||||
monkeypatch.setenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", "t")
|
||||
|
||||
result = self._tool()._run(channel="#joao-message")
|
||||
@@ -1734,12 +1733,12 @@ class TestPlatformActionTool:
|
||||
def test_ok_response_still_returns_json(self, monkeypatch) -> None: # noqa: ANN001
|
||||
from unittest.mock import Mock
|
||||
|
||||
import crewai_tools.tools.crewai_platform_tools.crewai_platform_action_tool as mod
|
||||
import crewai_tools.tools.crewai_platform_tools.integrations_client as client_mod
|
||||
|
||||
response = Mock()
|
||||
response = Mock(status_code=200)
|
||||
response.ok = True
|
||||
response.json.return_value = {"ts": "1234.5678"}
|
||||
monkeypatch.setattr(mod.requests, "post", Mock(return_value=response))
|
||||
monkeypatch.setattr(client_mod.requests, "post", Mock(return_value=response))
|
||||
monkeypatch.setenv("CREWAI_PLATFORM_INTEGRATION_TOKEN", "t")
|
||||
|
||||
result = self._tool()._run(channel="#general")
|
||||
|
||||
@@ -172,7 +172,7 @@ info = "Commits must follow Conventional Commits 1.0.0."
|
||||
[tool.uv]
|
||||
exclude-newer = "3 days"
|
||||
# These security fixes are newer than the global supply-chain cutoff.
|
||||
exclude-newer-package = { pypdf = "2026-08-07T00:00:00Z", msgpack = "2026-06-20T00:00:00Z", pydantic-settings = "2026-06-20T00:00:00Z", langsmith = "2026-06-20T00:00:00Z", gitpython = "2026-08-05T00:00:00Z" }
|
||||
exclude-newer-package = { msgpack = "2026-06-20T00:00:00Z", pydantic-settings = "2026-06-20T00:00:00Z", langsmith = "2026-06-20T00:00:00Z" }
|
||||
|
||||
# composio-core pins rich<14 but textual requires rich>=14.
|
||||
# onnxruntime 1.24+ dropped Python 3.10 wheels; cap it so qdrant[fastembed] resolves on 3.10.
|
||||
@@ -187,8 +187,11 @@ exclude-newer-package = { pypdf = "2026-08-07T00:00:00Z", msgpack = "2026-06-20T
|
||||
# pypdf <6.10.2 has GHSA-4pxv-j86v-mhcw, GHSA-7gw9-cf7v-778f, GHSA-x284-j5p8-9c5p.
|
||||
# pypdf <6.14.2 has GHSA-jm82-fx9c-mx94 and GHSA-5qjq-93h5-hrgp/GHSA-55h5-xmcq-c37v/GHSA-g867-7843-wf8q/GHSA-5xf7-4p34-54qr; force 6.14.2+.
|
||||
# pypdf <6.15.0 has GHSA-fwg2-594c-jp42 and GHSA-fp3f-mc75-235c (unbounded runtime/memory on large content
|
||||
# and /ToUnicode streams); force 6.15.0+. Its exclude-newer-package cutoff is bumped to 2026-08-07 to admit
|
||||
# that release.
|
||||
# and /ToUnicode streams); force 6.15.0+.
|
||||
# pypdf <6.16.0 has GHSA-jp53-mhqp-8xcg (infinite loop in TreeObject.insert_child).
|
||||
# pypdf <6.16.1 has GHSA-23w6-3w8w-8484 and GHSA-763m-79hh-57f2 (unbounded runtime/memory on
|
||||
# outlines and XForm extraction); force 6.16.1+. 6.16.2 is older than the global 3-day cutoff,
|
||||
# so no exclude-newer-package override is needed.
|
||||
# uv <0.11.15 has GHSA-4gg8-gxpx-9rph (and earlier GHSA-pjjw-68hj-v9mw); force 0.11.15+.
|
||||
# python-multipart <0.0.27 has GHSA-pp6c-gr5w-3c5g (DoS via unbounded multipart headers).
|
||||
# gitpython <3.1.50 has GHSA-mv93-w799-cj2w (config_writer newline injection bypassing the 3.1.49 patch -> RCE via core.hooksPath).
|
||||
@@ -200,8 +203,14 @@ exclude-newer-package = { pypdf = "2026-08-07T00:00:00Z", msgpack = "2026-06-20T
|
||||
# TagReference); force 3.1.57+.
|
||||
# gitpython <3.1.58 has GHSA-9rj7-rf2p-w77r, GHSA-4gmw-gg2m-w46p, GHSA-hh9p-6wh2-4mfc, GHSA-wvpp-8hx9-p66j and
|
||||
# GHSA-jm78-9fvv-mhgr (further unguarded git option forwarding in Repo.init, read-tree and git-config, plus
|
||||
# arbitrary file read via --pathspec-from-file); force 3.1.58+. Its exclude-newer-package cutoff is bumped to
|
||||
# 2026-08-05 to admit that release.
|
||||
# arbitrary file read via --pathspec-from-file).
|
||||
# gitpython <3.1.59 has PYSEC-2026-3785/GHSA-7833-fr7j-v32q (.gitmodules [include] file disclosure),
|
||||
# PYSEC-2026-3786/GHSA-284h-m62q-gf8w (multi-line git-config re-serialization RCE),
|
||||
# PYSEC-2026-3787/GHSA-8mcc-hrx5-hvxc (clone --separate-git-dir omitted from unsafe options),
|
||||
# and PYSEC-2026-3788/GHSA-5xxx-qhh7-9287 (Repo.blame --contents/-S arbitrary file read).
|
||||
# gitpython 3.1.60 hardens config escape semantics, diff/actor parsing, and
|
||||
# filesystem diffs; force 3.1.60+. 3.1.60 is older than the global 3-day cutoff,
|
||||
# so no exclude-newer-package override is needed.
|
||||
# pyasn1 <0.6.4 has GHSA-8ppf-4f7h-5ppj and GHSA-hm4w-wwcw-mr6r; force 0.6.4+.
|
||||
# urllib3 <2.7.0 has GHSA-qccp-gfcp-xxvc (ProxyManager cross-origin redirect leaks Authorization/Cookie) and GHSA-mf9v-mfxr-j63j (streaming decompression-bomb bypass); force 2.7.0+.
|
||||
# langsmith <0.8.18 has GHSA-3644-q5cj-c5c7 (public prompt manifest deserialization, SSRF/secret disclosure)
|
||||
@@ -223,7 +232,11 @@ exclude-newer-package = { pypdf = "2026-08-07T00:00:00Z", msgpack = "2026-06-20T
|
||||
# 3.9.4, so that ignore is no longer needed. 3.10.0-3.10.1 have PYSEC-2026-3726
|
||||
# (symlink-based arbitrary file read in IPIPANCorpusReader); fixed in 3.10.2.
|
||||
# 3.10.3 also clears later 3.10.2 findings (proxy SSRF, pickle allowlist RCE,
|
||||
# JVM option injection, XML entity expansion). Transitive via
|
||||
# JVM option injection, XML entity expansion). 3.10.3 still has
|
||||
# GHSA-8mgp-746c-j5xp (CVE-2026-81726; model-artifact pathsec bypass); no
|
||||
# patched PyPI release yet, so that GHSA is ignored in pip-audit until one
|
||||
# ships. TODO: drop --ignore-vuln GHSA-8mgp-746c-j5xp when bumping nltk
|
||||
# past 3.10.3. Transitive via
|
||||
# crewai-tools[xml] -> unstructured.
|
||||
# pydantic-settings <2.14.2 has GHSA-4xgf-cpjx-pc3j.
|
||||
# h2 <=4.4.0 has GHSA-6hr6-w5qg-qmwg (CVE-2026-71554): duplicate Host headers
|
||||
@@ -231,6 +244,10 @@ exclude-newer-package = { pypdf = "2026-08-07T00:00:00Z", msgpack = "2026-06-20T
|
||||
# qdrant-client -> httpx[http2].
|
||||
# torch <=2.12.1 has GHSA-rrmf-rvhw-rf47 (CVE-2025-3000): memory corruption in
|
||||
# torch.jit.script; fixed in 2.13.0. Transitive via docling/unstructured extras.
|
||||
# snowflake-connector-python >=4.0.0,<4.7.1 has GHSA-5cc2-282f-jjq2 (CVE-2026-15925):
|
||||
# TLS hostnames are not verified, so a network attacker can impersonate the endpoint;
|
||||
# fixed in 4.7.1. Declared as crewai-tools[snowflake] "snowflake-connector-python>=3.12.4",
|
||||
# which the lock resolved to 4.6.0.
|
||||
# Keep OpenAI on the SDK range required by CrewAI when transitive dependencies
|
||||
# loosen or pin their own lower versions.
|
||||
override-dependencies = [
|
||||
@@ -243,10 +260,10 @@ override-dependencies = [
|
||||
"urllib3>=2.7.0",
|
||||
"transformers>=5.4.0; python_version >= '3.10'",
|
||||
"cryptography>=50.0.0",
|
||||
"pypdf>=6.15.0,<7",
|
||||
"pypdf>=6.16.1,<7",
|
||||
"uv>=0.11.15,<1",
|
||||
"python-multipart>=0.0.27,<1",
|
||||
"gitpython>=3.1.58,<4",
|
||||
"gitpython>=3.1.60,<4",
|
||||
"pyasn1>=0.6.4",
|
||||
"langsmith>=0.8.18,<1",
|
||||
"authlib>=1.6.12",
|
||||
@@ -263,6 +280,7 @@ override-dependencies = [
|
||||
"nltk>=3.10.3",
|
||||
"h2>=4.4.1",
|
||||
"torch>=2.13.0",
|
||||
"snowflake-connector-python>=4.7.1",
|
||||
]
|
||||
|
||||
[tool.uv.workspace]
|
||||
|
||||
185
uv.lock
generated
185
uv.lock
generated
@@ -19,8 +19,6 @@ exclude-newer-span = "P3D"
|
||||
[options.exclude-newer-package]
|
||||
msgpack = "2026-06-20T00:00:00Z"
|
||||
langsmith = "2026-06-20T00:00:00Z"
|
||||
gitpython = "2026-08-05T00:00:00Z"
|
||||
pypdf = "2026-08-07T00:00:00Z"
|
||||
pydantic-settings = "2026-06-20T00:00:00Z"
|
||||
|
||||
[manifest]
|
||||
@@ -37,7 +35,7 @@ overrides = [
|
||||
{ name = "authlib", specifier = ">=1.6.12" },
|
||||
{ name = "cryptography", specifier = ">=50.0.0" },
|
||||
{ name = "docling-core", extras = ["chunking"], specifier = ">=2.74.1" },
|
||||
{ name = "gitpython", specifier = ">=3.1.58,<4" },
|
||||
{ name = "gitpython", specifier = ">=3.1.60,<4" },
|
||||
{ name = "h2", specifier = ">=4.4.1" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3,<2" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2,<2" },
|
||||
@@ -51,10 +49,11 @@ overrides = [
|
||||
{ name = "pip", specifier = ">=26.2" },
|
||||
{ name = "pyasn1", specifier = ">=0.6.4" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.14.2" },
|
||||
{ name = "pypdf", specifier = ">=6.15.0,<7" },
|
||||
{ name = "pypdf", specifier = ">=6.16.1,<7" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.27,<1" },
|
||||
{ name = "rich", specifier = ">=13.7.1" },
|
||||
{ name = "setuptools", specifier = ">=83.0.0" },
|
||||
{ name = "snowflake-connector-python", specifier = ">=4.7.1" },
|
||||
{ name = "starlette", specifier = ">=1.3.1" },
|
||||
{ name = "torch", specifier = ">=2.13.0" },
|
||||
{ name = "transformers", marker = "python_full_version >= '3.10'", specifier = ">=5.4.0" },
|
||||
@@ -1058,7 +1057,7 @@ name = "coloredlogs"
|
||||
version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "humanfriendly" },
|
||||
{ name = "humanfriendly", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" }
|
||||
wheels = [
|
||||
@@ -1156,7 +1155,7 @@ resolution-markers = [
|
||||
"python_full_version < '3.11' and platform_machine == 's390x'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" }
|
||||
wheels = [
|
||||
@@ -1231,7 +1230,7 @@ resolution-markers = [
|
||||
"python_full_version == '3.11.*' and platform_machine == 's390x'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" }
|
||||
wheels = [
|
||||
@@ -1601,7 +1600,7 @@ requires-dist = [
|
||||
{ name = "aiofiles", specifier = "~=24.1.0" },
|
||||
{ name = "av", specifier = "~=13.0.0" },
|
||||
{ name = "pillow", specifier = "~=12.3.0" },
|
||||
{ name = "pypdf", specifier = "~=6.14.2" },
|
||||
{ name = "pypdf", specifier = "~=6.16.1" },
|
||||
{ name = "python-magic", specifier = ">=0.4.27" },
|
||||
{ name = "tinytag", specifier = "~=2.2.1" },
|
||||
]
|
||||
@@ -1762,7 +1761,7 @@ requires-dist = [
|
||||
{ name = "e2b-code-interpreter", marker = "extra == 'e2b'", specifier = "~=2.6.0" },
|
||||
{ name = "exa-py", marker = "extra == 'exa-py'", specifier = ">=1.8.7" },
|
||||
{ name = "firecrawl-py", marker = "extra == 'firecrawl-py'", specifier = ">=1.8.0" },
|
||||
{ name = "gitpython", marker = "extra == 'github'", specifier = ">=3.1.58,<4" },
|
||||
{ name = "gitpython", marker = "extra == 'github'", specifier = ">=3.1.60,<4" },
|
||||
{ name = "hyperbrowser", marker = "extra == 'hyperbrowser'", specifier = ">=0.18.0" },
|
||||
{ name = "langchain-apify", marker = "extra == 'apify'", specifier = ">=0.1.2,<1.0.0" },
|
||||
{ name = "linkup-sdk", marker = "extra == 'linkup-sdk'", specifier = ">=0.2.2" },
|
||||
@@ -1885,43 +1884,43 @@ wheels = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
cublas = [
|
||||
{ name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
cudart = [
|
||||
{ name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
cufft = [
|
||||
{ name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
cufile = [
|
||||
{ name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
]
|
||||
cupti = [
|
||||
{ name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
curand = [
|
||||
{ name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
cusolver = [
|
||||
{ name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
{ name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
{ name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
cusparse = [
|
||||
{ name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
nvjitlink = [
|
||||
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
nvrtc = [
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
nvtx = [
|
||||
{ name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux') or (platform_machine == 'AMD64' and sys_platform == 'win32')" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2446,7 +2445,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
@@ -2782,14 +2781,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "gitpython"
|
||||
version = "3.1.58"
|
||||
version = "3.1.61"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "gitdb" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/26/d6/5f358ff283325580c2003a6d953aea18cfe10ae87b46f5ebc80fa3a386dc/gitpython-3.1.58.tar.gz", hash = "sha256:621416df10ef3fd0e19fabf9172ddeed0fa704d353d04f194eec56a625a95b22", size = 228498, upload-time = "2026-08-04T15:05:49.47Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/61/3285044215fb596bf093e39ccb96ece0a1076a8ca57a61e069a6a33cdb1b/gitpython-3.1.61.tar.gz", hash = "sha256:f51c24d8c0f733a195447385f5774a5dfe8767f5acfd7994a33755644c6ecc95", size = 231680, upload-time = "2026-08-28T11:01:13.761Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/0c/9d8752098bc442f0726e64aa6135940b3a96809915d1aa4206c1bb97881d/gitpython-3.1.58-py3-none-any.whl", hash = "sha256:d331e722577f0fd7fc1f857419b3ecc07af66282b933d2a4d95f84a042fdd50f", size = 220183, upload-time = "2026-08-04T15:05:48.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/5e/49cc172da4d0578644ba37cec5cb365b1fefc603b26edea9bcac1c7f830a/gitpython-3.1.61-py3-none-any.whl", hash = "sha256:8ab28c9da863cdd9e7d7694ec46cf3e6c9a12d8a30a1acd3447aec11975d530c", size = 222118, upload-time = "2026-08-28T11:01:12.262Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3035,8 +3034,8 @@ name = "grpcio-health-checking"
|
||||
version = "1.71.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "grpcio" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "grpcio", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" },
|
||||
{ name = "protobuf", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/86/20994347ef36b7626fb74539f13128100dd8b7eaac67efc063264e6cdc80/grpcio_health_checking-1.71.2.tar.gz", hash = "sha256:1c21ece88c641932f432b573ef504b20603bdf030ad4e1ec35dd7fdb4ea02637", size = 16770, upload-time = "2025-06-28T04:24:08.768Z" }
|
||||
wheels = [
|
||||
@@ -3240,7 +3239,7 @@ name = "humanfriendly"
|
||||
version = "10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
|
||||
{ name = "pyreadline3", marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" }
|
||||
wheels = [
|
||||
@@ -4477,10 +4476,10 @@ resolution-markers = [
|
||||
"python_full_version < '3.11' and platform_machine == 's390x'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "jsonref" },
|
||||
{ name = "mcp", extra = ["ws"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "jsonref", marker = "python_full_version < '3.12'" },
|
||||
{ name = "mcp", extra = ["ws"], marker = "python_full_version < '3.12'" },
|
||||
{ name = "pydantic", marker = "python_full_version < '3.12'" },
|
||||
{ name = "python-dotenv", marker = "python_full_version < '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/28/64fc666fa5d86bb1b048c167975d4ea19210f9f8571b64b26563739774ac/mcpadapt-0.1.19.tar.gz", hash = "sha256:dfab84fc75cc84a49a40bd61079773b1faf840227b74b82c71a7755b9c1957c5", size = 4227721, upload-time = "2025-10-16T07:11:56.736Z" }
|
||||
wheels = [
|
||||
@@ -4498,10 +4497,10 @@ resolution-markers = [
|
||||
"python_full_version == '3.12.*' and platform_machine == 's390x'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "jsonref" },
|
||||
{ name = "mcp", extra = ["ws"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "jsonref", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "mcp", extra = ["ws"], marker = "python_full_version >= '3.12'" },
|
||||
{ name = "pydantic", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "python-dotenv", marker = "python_full_version >= '3.12'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/71/1bbbe157e55d30ab4a74fa878f6942cc0586e9820f03e03451a3d2297e9b/mcpadapt-0.1.20.tar.gz", hash = "sha256:4047c0da61e481dd0673a48936a427da9e6547c6cf0d580ff4e4761dcf058ed1", size = 4203656, upload-time = "2025-10-24T15:35:02.135Z" }
|
||||
wheels = [
|
||||
@@ -5277,7 +5276,7 @@ name = "nvidia-cufft"
|
||||
version = "12.0.0.61"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ name = "nvidia-nvjitlink", marker = "platform_machine != 's390x'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
|
||||
@@ -5307,9 +5306,9 @@ name = "nvidia-cusolver"
|
||||
version = "12.0.4.66"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas" },
|
||||
{ name = "nvidia-cusparse" },
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ name = "nvidia-cublas", marker = "platform_machine != 's390x'" },
|
||||
{ name = "nvidia-cusparse", marker = "platform_machine != 's390x'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "platform_machine != 's390x'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
|
||||
@@ -5321,7 +5320,7 @@ name = "nvidia-cusparse"
|
||||
version = "12.6.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
{ name = "nvidia-nvjitlink", marker = "platform_machine != 's390x'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
|
||||
@@ -5517,12 +5516,12 @@ name = "onnxruntime"
|
||||
version = "1.23.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "coloredlogs" },
|
||||
{ name = "flatbuffers" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "packaging" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "sympy" },
|
||||
{ name = "coloredlogs", marker = "python_full_version < '3.11'" },
|
||||
{ name = "flatbuffers", marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "packaging", marker = "python_full_version < '3.11'" },
|
||||
{ name = "protobuf", marker = "python_full_version < '3.11'" },
|
||||
{ name = "sympy", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/35/d6/311b1afea060015b56c742f3531168c1644650767f27ef40062569960587/onnxruntime-1.23.2-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:a7730122afe186a784660f6ec5807138bf9d792fa1df76556b27307ea9ebcbe3", size = 17195934, upload-time = "2025-10-27T23:06:14.143Z" },
|
||||
@@ -7197,14 +7196,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pypdf"
|
||||
version = "6.15.0"
|
||||
version = "6.16.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/17/17/ee75a92718ec7212de831e71454d702225aa5e474a805cce169806044453/pypdf-6.15.0.tar.gz", hash = "sha256:d39c4d955a76409284a905e2d65b40076d77ab76129e0faaeeb6612403ecfc79", size = 6993794, upload-time = "2026-08-06T13:06:49.929Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/44/66/54212e75406afd9f3e933d0dda23072f6aecc55c5a273077dc2e0b028b23/pypdf-6.16.2.tar.gz", hash = "sha256:595647f6191de6f402cfde1d0c455d6cbccbd509aac32b34783009c032de5d6e", size = 7008996, upload-time = "2026-08-23T13:50:07.135Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/af/72/ce3067ac31e214a66388159f8462ddb8c13dd00170f24d555a1f1ae8ee91/pypdf-6.15.0-py3-none-any.whl", hash = "sha256:14e001d6504822cb1ca9c7ed9a69bccb320f59b320730f55af804361abe4d5ee", size = 378123, upload-time = "2026-08-06T13:06:47.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/f1/a2da3b55acd4ab737bf728c97edaaed5ec1d3c1236acb639dcdfa97e42c7/pypdf-6.16.2-py3-none-any.whl", hash = "sha256:c8b09a59399062fb45a1b8156c18a787a10a3dae03ac9674397a226712c94604", size = 385060, upload-time = "2026-08-23T13:50:05.349Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8170,7 +8169,7 @@ resolution-markers = [
|
||||
"python_full_version < '3.11' and platform_machine == 's390x'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
|
||||
wheels = [
|
||||
@@ -8234,7 +8233,7 @@ resolution-markers = [
|
||||
"python_full_version == '3.11.*' and platform_machine == 's390x'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
|
||||
wheels = [
|
||||
@@ -8499,7 +8498,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "snowflake-connector-python"
|
||||
version = "4.6.0"
|
||||
version = "4.7.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asn1crypto" },
|
||||
@@ -8520,28 +8519,28 @@ dependencies = [
|
||||
{ name = "tomlkit" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/4d/7e6a9088381386b4cfae4c5d1d23ea0c3618ca694bc2290118737af59f36/snowflake_connector_python-4.6.0.tar.gz", hash = "sha256:06e2dba02703da6fd60e07bb0574506f810a85e5831d3461247753ecce4b8335", size = 937999, upload-time = "2026-05-28T13:01:48.582Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/73/e6e45343dd133207a6f710a88e0675ae69f8c12c23efe7f192448bbdace9/snowflake_connector_python-4.7.2.tar.gz", hash = "sha256:c261b73b8dbe3f3d5b9bfdc19196f38e3eade42cb2f24322b7a2bb60fef76534", size = 954059, upload-time = "2026-08-07T13:59:54.061Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/51/49a334361f7a110bddd3312ca687646a52c92545dcaec39720f1904aa28f/snowflake_connector_python-4.6.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:3ff98c3213674c5ed18ba6bb9288c4e88e790150f350824434d49a23d15c0fc3", size = 1168884, upload-time = "2026-05-28T13:01:50.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/08/0ab1bdfa12b6a6302d553e654ce8f8b898243d0e9b72f90ad9e4dfd4f893/snowflake_connector_python-4.6.0-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:04ea8906ac06bdf98ab265f7870b532f32dd2b0f6b3b06a542b6e25a43e01665", size = 1181128, upload-time = "2026-05-28T13:01:51.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/6d/834f9c4be07ff894987a8cfcb885b035f4da8d843091d8069fd7c2708b07/snowflake_connector_python-4.6.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:324b15278ee84ea6f0af7fef5e916778c23c4569b2c8ba7fdc90d288478772b9", size = 2812016, upload-time = "2026-05-28T13:01:28.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/19/996b45846fcc5f2015bd70ed98420b11c847c1b79b57b82b250e0a409f49/snowflake_connector_python-4.6.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9005d226b234bf190409e5d7e8db9f7daba271880de9105f5173a6858b8e6b", size = 2839431, upload-time = "2026-05-28T13:01:31.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/c4/ad81bf9f0802f0bb26b7acca2f59d1cccc649387b4b7adbc02cdf79f53ac/snowflake_connector_python-4.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:8edc8bbcbaaa25a08d43f943fe45f00dc465684ef243859b0f3f7498d800f1ce", size = 5389250, upload-time = "2026-05-28T13:02:06.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/a3/37e0da0d18ef60f354902e39a64ccaaecdf2188818dfd1daeff643445238/snowflake_connector_python-4.6.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:03b0a232d8d0a1c78eb0d4e9f8a422a1553b2f69ef1387d50a3223bb1829a249", size = 1168603, upload-time = "2026-05-28T13:01:53.435Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/bc/f7ad29daa12c22cfdceea8d5374a771f45507075aa19fc8e97352b63fc36/snowflake_connector_python-4.6.0-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:531dcb07eee8405e5d8a9f4e7f8c1ca7916e3afbb4ffb3dd2c9a12ec5bd0e46a", size = 1180881, upload-time = "2026-05-28T13:01:55.104Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/30/c6b38a6823295a6ef0f61a522b654b93f21d7ef12a771dee734a25a97137/snowflake_connector_python-4.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c3124fd4a5dc702173ccd73d821ceba1442134d5f347b4c8d1ecb76489f44671", size = 2824331, upload-time = "2026-05-28T13:01:32.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/42/51d8c1c8dc0e66da9a7d300ac68f48291310f4807785aef1b316a9074eed/snowflake_connector_python-4.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ab64f46b18d77d1e6c159a29cd86eeff0be9ff01a9904fa873a3c29d20063d1", size = 2852252, upload-time = "2026-05-28T13:01:34.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/3c/924ea6bfe749eb540c35a022d37cc63d4e41977547ac93992bd863416c1e/snowflake_connector_python-4.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:18cc5402695b8e958503d6d7ab96403db90c481b63c31520305876ef3cb797e9", size = 5389180, upload-time = "2026-05-28T13:02:08.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/4e/a839eddf87df7fe91fd8086e6a43e10e6afddf7c6b718ef036643f032867/snowflake_connector_python-4.6.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a7701b702dbeb348769c5d1248231e18544c4ff1fb4118ad73d48e8f801cfb6e", size = 1167890, upload-time = "2026-05-28T13:01:56.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/81/632b4ca9459cd801abfaa5396a60d9e60b9e2f051d015a577af0493782d3/snowflake_connector_python-4.6.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:00abbcfe958f60da18297191f3499b1e61802e64622521a2e8da1c059c14e1c0", size = 1181169, upload-time = "2026-05-28T13:01:58.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/31/79871d7eea206c60a7891a8d4349fdd8933822101af87204231162a5c3e8/snowflake_connector_python-4.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:72aaee21a70e00fbe4dadcc60b9b1012b6411dddc90f94804d5efe5706fb9621", size = 2878875, upload-time = "2026-05-28T13:01:36.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/ff/ea43b9f87cf632bd9735f4da18d7982572fb67073fd55c67841091a20f1a/snowflake_connector_python-4.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6d3f6120edeb0d6edd208831d006cc3e769ec51bc346727f22d7aeaecbf20f77", size = 2910491, upload-time = "2026-05-28T13:01:37.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/b1/80bc142ce5afee2e9b0520e4444bcdf1a02627c1066653705e4c36b475ab/snowflake_connector_python-4.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:f15e2493a316ce79ab3d7fb16add10252bb2401723e5cfbc7a2ebc44d89a7b2b", size = 5388193, upload-time = "2026-05-28T13:02:10.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/7b/29af48b122f5df4e2c23a1733bd5ed28193f24734a7cf48e345e5c7c3012/snowflake_connector_python-4.6.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e0ca5a035b1afa690fb36a767ba59c8db85ef6295b88c2bbc2040449e99992ad", size = 1166660, upload-time = "2026-05-28T13:01:59.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/af/9c5f1551278a309bbda06662e842b34fc17a60916032e5402033482c0367/snowflake_connector_python-4.6.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:1894504c69a76ac4a205d01fbb3e18c6a6e974e6ad26dad263edd06343bea501", size = 1179744, upload-time = "2026-05-28T13:02:01.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/ef/fdaf6150dacf80edd4dac948fd9a08930944d2ad2e978fe33aca598aa0a5/snowflake_connector_python-4.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ed40d1e9d867253596860b9d5240280489ff4692b7a3fa21e2d45d63b4b61d36", size = 2844736, upload-time = "2026-05-28T13:01:40.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/a1/25fdb592dfed3150b429f1bbb22b495c2590e5a5007153be9d1b798c72c9/snowflake_connector_python-4.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c8476781cfef961fc5f6f75a5238e668d3e0ca5ebf1d055661b2fcf2831c254", size = 2878174, upload-time = "2026-05-28T13:01:42.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/1f/081d2fb06fca926bb2e9af81533516af4f86ca13abe2b7cbb16ee4938339/snowflake_connector_python-4.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:e8ccbf8b5e12177a86bd3ab8292cc5a99e9ac97d7645ef4a3ed0f767b4ec6594", size = 5388257, upload-time = "2026-05-28T13:02:13.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/4a/38bd506f0812d63710f2616f5d789d0543b51bba5d70ccbe086c348a4c8e/snowflake_connector_python-4.7.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:30f859dcb6d5b9d58c65e42d2c9d269a92ff86205bae4f198693493364ff3c9f", size = 1182017, upload-time = "2026-08-07T13:59:56.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/39/4c14635ff03358dce29bd35c42bf1cfa07dd55351f555222908a32b0403c/snowflake_connector_python-4.7.2-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:2fc6fdd40beb3e6baf639bc073dbc9375af58b09df87f0cc5794355d861b5370", size = 1193449, upload-time = "2026-08-07T13:59:57.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/f6/93c0c3441f465624490501c0565b9a448d3cf6179fa0a417ca7b61c907cb/snowflake_connector_python-4.7.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:383303d06cf79299b8f71e53ee7eca02c36c5ca4c4987d6e8e25a92500d38dcf", size = 2855332, upload-time = "2026-08-07T13:59:38.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/a7/c43876b1a92932640722eeeb512f04b96a0a7902bda77e4273010cb5c0b3/snowflake_connector_python-4.7.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f4e0e083675bdf445a906c9b82a9472d1ab0b4ea69c25c82a6007b50a72f15cf", size = 2884052, upload-time = "2026-08-07T13:59:40.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/6e/707d5584fbec7bcea64b23fecc7e3e0551371d29565e797dd363b29b9a17/snowflake_connector_python-4.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:4a196d5c5b74132832cd6a5e25beb87cee3f06205239e9b81f678f2f31cb64cb", size = 5420676, upload-time = "2026-08-07T14:00:12.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/32/710cc28319ddd8032b82a1f6c2118b0d773de4820c950ccb52620a80d4d7/snowflake_connector_python-4.7.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:5e807b896c7d2d6f95d5f45e4a069198bfeca66589cb06e0da24feb50b309e23", size = 1181594, upload-time = "2026-08-07T13:59:59.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/f3/e4947de8c5166eb5c750a784476e309da93c58adc7fc3fea7cf32283ecd3/snowflake_connector_python-4.7.2-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:80f55ce890f7ced50bf45b04243d244171b4fce81fdf02970daad99902aa8ac8", size = 1193292, upload-time = "2026-08-07T14:00:00.914Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/38/be5959f563adf266c17963b3333a19ef0cf5463651f14aa806eb03eed917/snowflake_connector_python-4.7.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cbce7e41111545cfbf844fe96e3c9e5f01aff04ce7ab3843ee74da415590bf74", size = 2867890, upload-time = "2026-08-07T13:59:41.635Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/9e/9c6e764dc97d82188b74c36c12130ba1fba0b94c9f1e57869b4662410d7f/snowflake_connector_python-4.7.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0506eec17f6e237f89458d46ccb8933e398505e412ef5901a1a20a2b52eb56ad", size = 2895739, upload-time = "2026-08-07T13:59:43.854Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/f6/b64395b0b99931c4083948c99afec4997b59e835b63f63225ecd46a954de/snowflake_connector_python-4.7.2-cp311-cp311-win_amd64.whl", hash = "sha256:989a205accfcca60544f8656958add965c46c2c09b4e0a4f20d59707672f6ada", size = 5420684, upload-time = "2026-08-07T14:00:14.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/17/a3f4c38a4535b137139a353e60874c83e22eea535b37ce43651191ac5d1f/snowflake_connector_python-4.7.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:4d19ab07f57ec14d074086d7a4eadb51b32e75b37c91e9893ad3702ecd2e3505", size = 1181036, upload-time = "2026-08-07T14:00:02.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/2b/c9446d830f33961ea7c86b4b5343a0fefda3882a31f577d74025ee8d26f5/snowflake_connector_python-4.7.2-cp312-cp312-macosx_15_0_x86_64.whl", hash = "sha256:1d94f27d67520e6729709114ee00916d89defa909d4cd456d53fe6106dc14df1", size = 1193269, upload-time = "2026-08-07T14:00:04.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/b5/0cee4d1d6ddd268c2443b2c3e8e2a98139198746525bcf73a1da876d940e/snowflake_connector_python-4.7.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:243c4e2621a58a34909a7863bbffaa886870c9fe19e88804a608660fb775f025", size = 2915086, upload-time = "2026-08-07T13:59:45.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/2b/a52387d5abd5988498deeee502c3889b576f21e486f128115ce548006517/snowflake_connector_python-4.7.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:24e7a787cae7745756cb25c829e4fef1b828d2872dd70e0e31ff37a9323a62c7", size = 2947242, upload-time = "2026-08-07T13:59:46.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/29/4cc9353e21ec19570ac2916ed15a237d9cd42675ad70d7b9aa3dd2be6ea7/snowflake_connector_python-4.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:d7b1eabecdeaff6b0df8f583dc1b7a8bbd92e996e29212c324013e4ad7662c75", size = 5419590, upload-time = "2026-08-07T14:00:16.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/c5/12defd8c0022ded5a11d3e2204ee52c0484654a869db3d2bf9027c7d2384/snowflake_connector_python-4.7.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9cb47d1d4881fe7338af91a38029c68926fab016b58fb6963954492257e2a1d9", size = 1179960, upload-time = "2026-08-07T14:00:05.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/70/b5ccddf8b3689455a3652f71eba16b35753dd90dc6936a1f0905c41a4db9/snowflake_connector_python-4.7.2-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:06281d6f66e857b4d8362fc0738600dafd635da1c1cf91537f0cd57f658ec1ec", size = 1192786, upload-time = "2026-08-07T14:00:07.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/0f/839bcabc5231daf009db958b11838fd991f68f7de137bb3918f8e5368de4/snowflake_connector_python-4.7.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0771229b11862b0eac60d8a771c66af79b5c3248b5200ad2898217ab4c7d385b", size = 2886123, upload-time = "2026-08-07T13:59:48.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/77/85754d5a5f3ba7f11a3f0aa569b2edd2429aa96f2342486b1da4530329c6/snowflake_connector_python-4.7.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eb1e56efaaf258bb8a28968bbebea82488a02a2ffda354e65ea7921935289a19", size = 2918812, upload-time = "2026-08-07T13:59:49.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/01/9015c0918f4146281c2d36f72069a9ec538b4165bddb2b5cd3bc9ff4f24c/snowflake_connector_python-4.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:17f9cee8985b726afe027920b2842c41b0c52280747d37efa1643c3f77fd5505", size = 5419347, upload-time = "2026-08-07T14:00:18.013Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9854,13 +9853,13 @@ resolution-markers = [
|
||||
"python_full_version < '3.11' and platform_machine == 's390x'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "authlib" },
|
||||
{ name = "deprecation" },
|
||||
{ name = "grpcio" },
|
||||
{ name = "grpcio-health-checking" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "validators" },
|
||||
{ name = "authlib", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" },
|
||||
{ name = "deprecation", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" },
|
||||
{ name = "grpcio", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" },
|
||||
{ name = "grpcio-health-checking", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" },
|
||||
{ name = "httpx", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" },
|
||||
{ name = "pydantic", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" },
|
||||
{ name = "validators", marker = "python_full_version < '3.11' or (python_full_version >= '3.13' and platform_machine != 's390x')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/b9/7b9e05cf923743aa1479afcd85c48ebca82d031c3c3a5d02b1b3fcb52eb9/weaviate_client-4.16.2.tar.gz", hash = "sha256:eb7107a3221a5ad68d604cafc65195bd925a9709512ea0b6fe0dd212b0678fab", size = 681321, upload-time = "2025-07-22T09:10:48.79Z" }
|
||||
wheels = [
|
||||
@@ -9879,13 +9878,13 @@ resolution-markers = [
|
||||
"python_full_version == '3.11.*' and platform_machine == 's390x'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "authlib" },
|
||||
{ name = "grpcio" },
|
||||
{ name = "httpx" },
|
||||
{ name = "packaging" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "validators" },
|
||||
{ name = "authlib", marker = "(python_full_version >= '3.11' and python_full_version < '3.13') or (python_full_version >= '3.11' and platform_machine == 's390x')" },
|
||||
{ name = "grpcio", marker = "(python_full_version >= '3.11' and python_full_version < '3.13') or (python_full_version >= '3.11' and platform_machine == 's390x')" },
|
||||
{ name = "httpx", marker = "(python_full_version >= '3.11' and python_full_version < '3.13') or (python_full_version >= '3.11' and platform_machine == 's390x')" },
|
||||
{ name = "packaging", marker = "(python_full_version >= '3.11' and python_full_version < '3.13') or (python_full_version >= '3.11' and platform_machine == 's390x')" },
|
||||
{ name = "protobuf", marker = "(python_full_version >= '3.11' and python_full_version < '3.13') or (python_full_version >= '3.11' and platform_machine == 's390x')" },
|
||||
{ name = "pydantic", marker = "(python_full_version >= '3.11' and python_full_version < '3.13') or (python_full_version >= '3.11' and platform_machine == 's390x')" },
|
||||
{ name = "validators", marker = "(python_full_version >= '3.11' and python_full_version < '3.13') or (python_full_version >= '3.11' and platform_machine == 's390x')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2b/b8/103f3aaa246d4e932f4cfeb846e51436966f2aeedf60c2665a3fc51a975a/weaviate_client-4.21.3.tar.gz", hash = "sha256:d7b1f2b0cecbc747e9427f4e3b9463cdfee090746bfbbd40e59cfa25ea2afd4a", size = 847895, upload-time = "2026-06-02T13:03:51.598Z" }
|
||||
wheels = [
|
||||
|
||||
Reference in New Issue
Block a user