Compare commits

...

16 Commits

Author SHA1 Message Date
Iris Clawd
8cc6e5d225 fix(deps): bump gitpython to >=3.1.60 for PYSEC-2026-3785–3788
Raise gitpython floor from >=3.1.58 to >=3.1.60 in both the workspace
override and crewai-tools[github] to clear pip-audit findings on
PYSEC-2026-3785 through PYSEC-2026-3788 (.gitmodules include disclosure,
config-injection RCE, --separate-git-dir clone, Repo.blame file read).

Drop the gitpython exclude-newer-package cutoff (3.1.60+ is older than
the global 3-day window). Lock resolves to 3.1.61.

Co-authored-by: Vidit Ostwal <viditostwal@gmail.com>
2026-09-04 08:12:43 +00:00
Havel Cyrus
92eb5f9183 fix: append trailing user turn in native Gemini provider (#6973)
* fix: append trailing user turn in native Gemini provider

GeminiCompletion._format_messages_for_gemini maps assistant messages
to Gemini's 'model' role but never guards against the resulting
contents list ending on a model turn. CrewAI's own agent loop (max
iterations, guardrail retries) can produce exactly that history, and
Gemini's generateContent API rejects it with 400 'Requests ending
with a model turn are not supported'.

Mirrors the existing Mistral/Ollama guard in
LLM._format_messages_for_provider, which never applies to Gemini
since gemini/google model strings resolve to this native provider
instead of the LiteLLM fallback path.

Fixes #6972

* fix: append trailing user turn for Gemini on the LiteLLM fallback path

LLM._format_messages_for_provider already guards Mistral/Ollama
against a trailing assistant turn, but Gemini models routed through
the LiteLLM fallback (no google-genai installed, or a model name not
recognized as native) had no equivalent guard. litellm's own
Vertex/Gemini transformation doesn't handle this either, so the
request reaches Gemini's generateContent API unguarded and 400s.

Complements the native-provider fix in GeminiCompletion, covering
both dispatch paths.

* fix: don't append text turn after unresolved Gemini function call

Address CodeRabbit review on #6973: appending a plain 'Please
continue.' user turn after a trailing model turn that contains an
unresolved function_call violates Gemini's function-calling protocol
-- it requires a matching functionResponse, not free text. Raise a
targeted error instead so the caller notices rather than silently
sending a malformed follow-up.

Also strengthens the native-provider formatting tests to assert exact
role sequence and text content (not just the last role), per review,
and adds a regression test for the unresolved-function-call case.

* fix: guard None parts when checking Gemini history for unresolved function call

contents[-1].parts is typed list[Part] | None; iterating it directly
failed mypy (union-attr) on 3.10-3.13. Narrow to [] before the any()
check and document the ValueError in the docstring.

---------

Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
2026-09-03 16:26:44 +05:30
Parthiban Sivakumar
c90337ba5a fix(llms): normalize scheme and port in Ollama base URL (#7206)
* fix(llms): normalize scheme and port in Ollama base URL

OLLAMA_HOST follows Ollama's own convention and may be a bare host
("0.0.0.0") or a host:port pair ("127.0.0.1:11434") rather than a full
URL. _normalize_ollama_base_url only appended "/v1", so those values
produced invalid base URLs such as "0.0.0.0/v1", and every request
failed with the misleading error "Failed to connect to OpenAI API:
Connection error." - confusing, since no OpenAI model was requested.

Fill in the missing parts the way Ollama's own client does: prepend
http:// when no scheme is present, append the default port 11434 when
none is present and the scheme is http (https implies 443), then append
the /v1 suffix the OpenAI-compatible endpoint requires.

Six of nine realistic OLLAMA_HOST forms were affected, including
127.0.0.1:11434, which is Ollama's documented default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(llms): strip only the parsed path when normalizing Ollama base URL

Stripping trailing slashes from the whole URL before parsing corrupted
inputs that carry a query or fragment. "http://ollama/?tenant=acme" kept
a "/" path and produced a doubled "//v1", and a query or fragment ending
in "/" silently lost that character.

Parse first, then rstrip only parts.path. Adds regression tests for a
root path alongside a query and for a query value ending in "/".

Reported by CodeRabbit on #7206.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
2026-09-03 16:05:56 +05:30
Vidit Ostwal
3d72c707d5 chore(ci): ignore unpatched nltk GHSA-8mgp-746c-j5xp (#7215)
* chore(ci): ignore unpatched nltk GHSA-8mgp-746c-j5xp

No patched PyPI release exists beyond 3.10.3. nltk is transitive via
crewai-tools[xml] -> unstructured; CrewAI does not call the vulnerable
model-artifact APIs.

Co-authored-by: Vidit Ostwal <Vidit-Ostwal@users.noreply.github.com>

* chore(ci): note dropping nltk GHSA ignore on the next bump

Leave an explicit TODO beside the ignore so GHSA-8mgp-746c-j5xp is
removed when nltk moves past the unpatched 3.10.3 floor.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Vidit Ostwal <Vidit-Ostwal@users.noreply.github.com>
2026-09-02 10:04:20 -07:00
Zhewen Tan
98799a3b09 fix(memory): preserve reusable scope configs (#7068)
Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
2026-09-02 19:35:30 +05:30
Vidit Ostwal
1cef70de52 fix: bump pypdf to 6.16.2 for GHSA-jp53-mhqp-8xcg (#7200)
* fix: bump pypdf to 6.16.2 for GHSA-jp53-mhqp-8xcg

pypdf 6.15.0 fails pip-audit on three moderate DoS advisories; 6.16.1+ patches them.

* chore: keep existing uv.lock environment markers

A full uv lock refresh rewrote unrelated dependency markers; restore them so the pypdf bump stays isolated.

* chore: drop unused pypdf exclude-newer-package in crewai-files

~=6.16.1 plus the global 3-day cutoff already admits 6.16.2.

* chore: drop pypdf from exclude-newer-package

6.16.2 is already older than the global 3-day cutoff; the version floor is enough.
2026-09-02 10:38:33 -03:00
Fang Kaiqi
b608a3595c docs: remove CodeInterpreterTool from AI/ML overview examples (#7100)
Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
2026-09-02 10:31:18 +05:30
Fang Kaiqi
f5db5a1788 docs: point prompt-template link at its current path (#7101)
Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
2026-09-02 10:21:40 +05:30
Vinicius Brasil
968c3065d3 Add Clipper integrations client (#7196)
* Add Clipper integrations client

Implement the internal Clipper discovery and execution contract with
deployment authentication and normalized results. Keep
CrewAIPlatformTools on LegacyClient until the new path is ready for
selection.

* Allow Clipper requests without deployment instances

Local and self-hosted CrewAI executions can have a valid integration
token without a deployment instance UUID. Send the deployment header
when it is available and allow Clipper to attribute other executions to
the organization.

* fixup! Allow Clipper requests without deployment instances

* fixup! Add Clipper integrations client
2026-09-01 14:11:43 -07:00
Vidit Ostwal
818f2624e8 [OSS-149] Accept 1/yes/on on telemetry disable flags (#7185)
* fix(telemetry): accept 1/yes/on on disable flags

CREWAI_DISABLE_TELEMETRY=1 was ignored because the gate only matched true, so telemetry stayed on with no warning.

* fix(telemetry): warn once on unrecognized disable values

Stop repeating the same invalid-flag warning on every telemetry check, and drop the undocumented CREWAI_DISABLE_TRACKING alias from docs.

---------

Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>
2026-09-01 11:41:20 -07:00
Vidit Ostwal
8e46205619 [OSS-151] Fail closed when human-feedback emit cannot classify (#7188)
* fix(flow): resolve @human_feedback emit LLM from the project model

Omitting llm= no longer hardcodes OpenAI. Collapse and learn resolve through create_llm so MODEL / MODEL_NAME / OPENAI_MODEL_NAME win, then DEFAULT_LLM_MODEL.

* fix(flow): fail closed when human-feedback collapse cannot classify

Stop routing to emit[0] when the collapse LLM cannot be called or its response does not match an outcome. Empty skip still uses default_outcome.

* refactor(flow): extract human-feedback collapse matching helpers

Move match/require outcome helpers out of _collapse_to_outcome so the classify path stays flat.

* refactor(flow): catch only LLM call failures in collapse

Keep HumanFeedbackCollapseError from matching outside the call try so it is raised once and does not trigger a second prompt.

* fix(flow): treat non-object JSON as raw collapse text

Avoid AttributeError when the collapse LLM returns JSON that is not an object.
2026-09-01 17:25:07 +00:00
Thiago Moretto
1bc2e0722d feat(flows): add now() to the CEL expression environment (#7194)
* feat(flows): add now() to the CEL expression environment

CEL expressions in flow definitions had no way to produce the current
date: the environment was built bare, so date-dependent flows failed at
runtime. Register a now() function that returns the current UTC time as
a CEL timestamp. The value is frozen once per kickoff so every
expression in a run sees the same instant, even across midnight.

Standard CEL covers formatting from there: string(now()),
now().getFullYear(), now() - duration('24h').

* chore(flows): drop redundant comment on _cel_now

* refactor(flows): derive CEL env and functions from one registry

A function now lives in one _CelFunctionSpec entry: its annotation for
compile and its implementation factory for evaluate, so the two cannot
drift. Run-scoped values move into _CelRunContext; adding one is a
field, not a new parameter through every helper signature.

* chore(flows): drop _CelRunContext docstring

* fix(flows): freeze a fresh cel now() on human-feedback resume

resume_async never passes through kickoff_async, so a flow restored
with from_pending() had no frozen instant and now() fell back to live
wall-clock per expression. Freeze a fresh instant at resume instead of
persisting the kickoff one: a flow can pause on feedback for days, and
expressions after resume must see today.
2026-09-01 17:05:47 +00:00
Vinicius Brasil
48cc5d4e5e Decouple platform tools from the integrations API (#7180)
* Decouple platform tools from the integrations API

Define normalized selector and tool data so platform tool creation does
not depend on the legacy API response shape. This contract makes the
legacy client easier to replace later.

- Move action discovery and response normalization into LegacyClient.
- Pass ToolInfo from discovery through tool creation and execution.
- Replace the builder flow with direct factory orchestration.
- Preserve app, action, and connection data in immutable models.
- Build sanitized tool names from the full tool identity.
- Preserve legacy request, SSL, and failure behavior with contract tests.

* fixup! Decouple platform tools from the integrations API

* fixup! Decouple platform tools from the integrations API

* fixup! Decouple platform tools from the integrations API

* fixup! Decouple platform tools from the integrations API
2026-09-01 16:07:08 +00:00
Lorenze Jay
917b9df6d7 Validate JSON crews in project environments (#7171)
* Validate JSON crews in project environments

* fix(cli): address standalone deploy review feedback

---------

Co-authored-by: Vidit Ostwal <110953813+Vidit-Ostwal@users.noreply.github.com>
2026-09-01 16:00:59 +00:00
João Moura
ec53d6f534 fix(llms): native structured outputs for current claude models, and snowflake CVE floor (#7182)
* fix(llms): let current claude models use native structured outputs

NATIVE_STRUCTURED_OUTPUT_MODELS only listed 4.5-era prefixes, so Opus 5,
Sonnet 5, Fable 5 and Opus 4.8 fell through to the forced-tool-call
fallback. That path also overwrites params["tools"], so a call combining
tools with a response_model silently lost the caller's tools.

_infer_provider_from_model documented a pattern-matching fallback it never
performed, so a Claude release newer than the constants list resolved to
"openai". Bedrock ('.' in model) and Azure (every OpenAI prefix) are left
out of that fallback because they would capture gpt-* models.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(llms): route bedrock-namespaced anthropic ids to bedrock

"anthropic.claude-*" is Bedrock's namespace, not the Anthropic API, and it
satisfies the anthropic prefix pattern. Settle it before the pattern loop so
an unlisted Bedrock id picks BedrockCompletion. The region-prefixed form
("us.anthropic.claude-*") was resolving to openai, so this repairs that too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(deps): raise snowflake-connector-python floor for CVE-2026-15925

GHSA-5cc2-282f-jjq2 (CRITICAL): the connector does not verify TLS hostnames,
so a network attacker can impersonate the Snowflake endpoint. Fixed in 4.7.1.

crewai-tools[snowflake] declares "snowflake-connector-python>=3.12.4", which
the lock had resolved to 4.6.0. Following the existing convention, the security
floor goes in [tool.uv] override-dependencies rather than the source
declaration, matching how cryptography is handled.

Relocking also refreshes numpy/humanfriendly/nvidia environment markers, which
re-resolution under the relative exclude-newer window produces regardless of
this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 12:01:22 +05:30
Vinicius Brasil
381fef73be Add injectable client for CrewAI platform tools (#7177)
* Add injectable client for CrewAI platform tools

Define an integrations client contract for action discovery and
execution. Keep the existing platform API as the default client to
preserve current behavior.

Allow callers to provide a custom client through CrewaiPlatformTools.

* Fix platform action tool failure tests

* Remove redundant protocol placeholders
2026-08-31 16:01:54 -07:00
60 changed files with 3062 additions and 1012 deletions

View File

@@ -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

View File

@@ -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]

View File

@@ -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) لمعرفة كيفية تنظيم هذه العناصر. من هناك، يمكنك تجاوزها أو تكييفها حسب الحاجة لفتح سلوكيات متقدمة.
## فهم تعليمات النظام الافتراضية

View File

@@ -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` خاص به ولا يسجل نفسه

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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

View File

@@ -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"
)

View File

@@ -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)을 확인하세요. 여기서 필요에 따라 오버라이드하거나 수정하여 고급 동작을 구현할 수 있습니다.
## 기본 시스템 지침 이해하기

View File

@@ -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`에서 실행되며 자신을 전역

View File

@@ -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"
)
```

View File

@@ -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

View File

@@ -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

View File

@@ -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"
)
```

View File

@@ -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:

View File

@@ -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:

View File

@@ -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")

View 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

View File

@@ -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():

View File

@@ -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:

View File

@@ -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:

View File

@@ -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"]

View File

@@ -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 = [

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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},
)

View File

@@ -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."""

View File

@@ -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

View File

@@ -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()

View File

@@ -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(

View File

@@ -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

View File

@@ -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)

View File

@@ -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"
)

View File

@@ -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,
)

View File

@@ -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:

View File

@@ -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,

View File

@@ -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:

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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]

View File

@@ -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.

View File

@@ -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(

View File

@@ -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):

View File

@@ -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)

View File

@@ -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."""

View File

@@ -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"""

View File

@@ -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()

View File

@@ -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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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):

View File

@@ -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):

View File

@@ -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()

View File

@@ -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(

View File

@@ -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."""

View File

@@ -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

View File

@@ -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")

View File

@@ -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
View File

@@ -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 = [