diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml
index f08befefb8..a5a5071fae 100644
--- a/.github/workflows/api-tests.yml
+++ b/.github/workflows/api-tests.yml
@@ -47,15 +47,17 @@ jobs:
- name: Run Unit tests
run: |
uv run --project api bash dev/pytest/pytest_unit_tests.sh
+
+ - name: Coverage Summary
+ run: |
+ set -x
# Extract coverage percentage and create a summary
TOTAL_COVERAGE=$(python -c 'import json; print(json.load(open("coverage.json"))["totals"]["percent_covered_display"])')
# Create a detailed coverage summary
echo "### Test Coverage Summary :test_tube:" >> $GITHUB_STEP_SUMMARY
echo "Total Coverage: ${TOTAL_COVERAGE}%" >> $GITHUB_STEP_SUMMARY
- echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
- uv run --project api coverage report >> $GITHUB_STEP_SUMMARY
- echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
+ uv run --project api coverage report --format=markdown >> $GITHUB_STEP_SUMMARY
- name: Run dify config tests
run: uv run --project api dev/pytest/pytest_config_tests.py
@@ -83,9 +85,15 @@ jobs:
compose-file: |
docker/docker-compose.middleware.yaml
services: |
+ db
+ redis
sandbox
ssrf_proxy
+ - name: setup test config
+ run: |
+ cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
+
- name: Run Workflow
run: uv run --project api bash dev/pytest/pytest_workflow.sh
diff --git a/.github/workflows/deploy-rag-dev.yml b/.github/workflows/deploy-rag-dev.yml
new file mode 100644
index 0000000000..86265aad6d
--- /dev/null
+++ b/.github/workflows/deploy-rag-dev.yml
@@ -0,0 +1,28 @@
+name: Deploy RAG Dev
+
+permissions:
+ contents: read
+
+on:
+ workflow_run:
+ workflows: ["Build and Push API & Web"]
+ branches:
+ - "deploy/rag-dev"
+ types:
+ - completed
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ if: |
+ github.event.workflow_run.conclusion == 'success' &&
+ github.event.workflow_run.head_branch == 'deploy/rag-dev'
+ steps:
+ - name: Deploy to server
+ uses: appleboy/ssh-action@v0.1.8
+ with:
+ host: ${{ secrets.RAG_SSH_HOST }}
+ username: ${{ secrets.SSH_USER }}
+ key: ${{ secrets.SSH_PRIVATE_KEY }}
+ script: |
+ ${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}
diff --git a/.github/workflows/expose_service_ports.sh b/.github/workflows/expose_service_ports.sh
index 10d95cb736..01772ccf9f 100755
--- a/.github/workflows/expose_service_ports.sh
+++ b/.github/workflows/expose_service_ports.sh
@@ -10,6 +10,7 @@ yq eval '.services["elasticsearch"].ports += ["9200:9200"]' -i docker/docker-com
yq eval '.services.couchbase-server.ports += ["8091-8096:8091-8096"]' -i docker/docker-compose.yaml
yq eval '.services.couchbase-server.ports += ["11210:11210"]' -i docker/docker-compose.yaml
yq eval '.services.tidb.ports += ["4000:4000"]' -i docker/tidb/docker-compose.yaml
+yq eval '.services.oceanbase.ports += ["2881:2881"]' -i docker/docker-compose.yaml
yq eval '.services.opengauss.ports += ["6600:6600"]' -i docker/docker-compose.yaml
echo "Ports exposed for sandbox, weaviate, tidb, qdrant, chroma, milvus, pgvector, pgvecto-rs, elasticsearch, couchbase, opengauss"
diff --git a/.github/workflows/vdb-tests.yml b/.github/workflows/vdb-tests.yml
index c784817e72..912267094b 100644
--- a/.github/workflows/vdb-tests.yml
+++ b/.github/workflows/vdb-tests.yml
@@ -31,6 +31,13 @@ jobs:
with:
persist-credentials: false
+ - name: Free Disk Space
+ uses: endersonmenezes/free-disk-space@v2
+ with:
+ remove_dotnet: true
+ remove_haskell: true
+ remove_tool_cache: true
+
- name: Setup UV and Python
uses: ./.github/actions/setup-uv
with:
@@ -59,7 +66,7 @@ jobs:
tidb
tiflash
- - name: Set up Vector Stores (Weaviate, Qdrant, PGVector, Milvus, PgVecto-RS, Chroma, MyScale, ElasticSearch, Couchbase)
+ - name: Set up Vector Stores (Weaviate, Qdrant, PGVector, Milvus, PgVecto-RS, Chroma, MyScale, ElasticSearch, Couchbase, OceanBase)
uses: hoverkraft-tech/compose-action@v2.0.2
with:
compose-file: |
@@ -75,8 +82,15 @@ jobs:
pgvector
chroma
elasticsearch
+ oceanbase
+
+ - name: setup test config
+ run: |
+ echo $(pwd)
+ ls -lah .
+ cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
- - name: Check TiDB Ready
+ - name: Check VDB Ready (TiDB)
run: uv run --project api python api/tests/integration_tests/vdb/tidb_vector/check_tiflash_ready.py
- name: Test Vector Stores
diff --git a/.gitignore b/.gitignore
index 74a9ef63ef..dd4673a3d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -179,6 +179,7 @@ docker/volumes/pgvecto_rs/data/*
docker/volumes/couchbase/*
docker/volumes/oceanbase/*
docker/volumes/plugin_daemon/*
+docker/volumes/matrixone/*
!docker/volumes/oceanbase/init.d
docker/nginx/conf.d/default.conf
@@ -210,3 +211,7 @@ mise.toml
# Next.js build output
.next/
+
+# AI Assistant
+.roo/
+api/.env.backup
diff --git a/README.md b/README.md
index ca09adec08..2909e0e6cf 100644
--- a/README.md
+++ b/README.md
@@ -54,7 +54,7 @@
-Dify is an open-source LLM app development platform. Its intuitive interface combines agentic AI workflow, RAG pipeline, agent capabilities, model management, observability features, and more, allowing you to quickly move from prototype to production.
+Dify is an open-source platform for developing LLM applications. Its intuitive interface combines agentic AI workflows, RAG pipelines, agent capabilities, model management, observability features, and more—allowing you to quickly move from prototype to production.
## Quick start
@@ -65,7 +65,7 @@ Dify is an open-source LLM app development platform. Its intuitive interface com
-The easiest way to start the Dify server is through [docker compose](docker/docker-compose.yaml). Before running Dify with the following commands, make sure that [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) are installed on your machine:
+The easiest way to start the Dify server is through [Docker Compose](docker/docker-compose.yaml). Before running Dify with the following commands, make sure that [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) are installed on your machine:
```bash
cd dify
@@ -205,6 +205,7 @@ If you'd like to configure a highly-available setup, there are community-contrib
- [Helm Chart by @magicsong](https://github.com/magicsong/ai-charts)
- [YAML file by @Winson-030](https://github.com/Winson-030/dify-kubernetes)
- [YAML file by @wyy-holding](https://github.com/wyy-holding/dify-k8s)
+- [🚀 NEW! YAML files (Supports Dify v1.6.0) by @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
#### Using Terraform for Deployment
@@ -226,6 +227,15 @@ Deploy Dify to AWS with [CDK](https://aws.amazon.com/cdk/)
- [AWS CDK by @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
+#### Using Alibaba Cloud Computing Nest
+
+Quickly deploy Dify to Alibaba cloud with [Alibaba Cloud Computing Nest](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
+
+#### Using Alibaba Cloud Data Management
+
+One-Click deploy Dify to Alibaba Cloud with [Alibaba Cloud Data Management](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/)
+
+
## Contributing
For those who'd like to contribute code, see our [Contribution Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md).
@@ -252,8 +262,8 @@ At the same time, please consider supporting Dify by sharing it on social media
## Security disclosure
-To protect your privacy, please avoid posting security issues on GitHub. Instead, send your questions to security@dify.ai and we will provide you with a more detailed answer.
+To protect your privacy, please avoid posting security issues on GitHub. Instead, report issues to security@dify.ai, and our team will respond with detailed answer.
## License
-This repository is available under the [Dify Open Source License](LICENSE), which is essentially Apache 2.0 with a few additional restrictions.
+This repository is licensed under the [Dify Open Source License](LICENSE), based on Apache 2.0 with additional conditions.
diff --git a/README_AR.md b/README_AR.md
index df288fd33c..e959ca0f78 100644
--- a/README_AR.md
+++ b/README_AR.md
@@ -188,6 +188,7 @@ docker compose up -d
- [رسم بياني Helm من قبل @magicsong](https://github.com/magicsong/ai-charts)
- [ملف YAML من قبل @Winson-030](https://github.com/Winson-030/dify-kubernetes)
- [ملف YAML من قبل @wyy-holding](https://github.com/wyy-holding/dify-k8s)
+- [🚀 جديد! ملفات YAML (تدعم Dify v1.6.0) بواسطة @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
#### استخدام Terraform للتوزيع
@@ -209,6 +210,14 @@ docker compose up -d
- [AWS CDK بواسطة @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
+#### استخدام Alibaba Cloud للنشر
+ [بسرعة نشر Dify إلى سحابة علي بابا مع عش الحوسبة السحابية علي بابا](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
+
+#### استخدام Alibaba Cloud Data Management للنشر
+
+انشر Dify على علي بابا كلاود بنقرة واحدة باستخدام [Alibaba Cloud Data Management](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/)
+
+
## المساهمة
لأولئك الذين يرغبون في المساهمة، انظر إلى [دليل المساهمة](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) لدينا.
diff --git a/README_BN.md b/README_BN.md
index 4a5b5f3928..29d7374ea5 100644
--- a/README_BN.md
+++ b/README_BN.md
@@ -204,6 +204,8 @@ GitHub-এ ডিফাইকে স্টার দিয়ে রাখুন
- [Helm Chart by @magicsong](https://github.com/magicsong/ai-charts)
- [YAML file by @Winson-030](https://github.com/Winson-030/dify-kubernetes)
- [YAML file by @wyy-holding](https://github.com/wyy-holding/dify-k8s)
+- [🚀 নতুন! YAML ফাইলসমূহ (Dify v1.6.0 সমর্থিত) তৈরি করেছেন @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
+
#### টেরাফর্ম ব্যবহার করে ডিপ্লয়
@@ -225,6 +227,15 @@ GitHub-এ ডিফাইকে স্টার দিয়ে রাখুন
- [AWS CDK by @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
+#### Alibaba Cloud ব্যবহার করে ডিপ্লয়
+
+ [Alibaba Cloud Computing Nest](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
+
+#### Alibaba Cloud Data Management ব্যবহার করে ডিপ্লয়
+
+ [Alibaba Cloud Data Management](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/)
+
+
## Contributing
যারা কোড অবদান রাখতে চান, তাদের জন্য আমাদের [অবদান নির্দেশিকা] দেখুন (https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md)।
diff --git a/README_CN.md b/README_CN.md
index ba7ee0006d..486a368c09 100644
--- a/README_CN.md
+++ b/README_CN.md
@@ -194,9 +194,9 @@ docker compose up -d
如果您需要自定义配置,请参考 [.env.example](docker/.env.example) 文件中的注释,并更新 `.env` 文件中对应的值。此外,您可能需要根据您的具体部署环境和需求对 `docker-compose.yaml` 文件本身进行调整,例如更改镜像版本、端口映射或卷挂载。完成任何更改后,请重新运行 `docker-compose up -d`。您可以在[此处](https://docs.dify.ai/getting-started/install-self-hosted/environments)找到可用环境变量的完整列表。
-#### 使用 Helm Chart 部署
+#### 使用 Helm Chart 或 Kubernetes 资源清单(YAML)部署
-使用 [Helm Chart](https://helm.sh/) 版本或者 YAML 文件,可以在 Kubernetes 上部署 Dify。
+使用 [Helm Chart](https://helm.sh/) 版本或者 Kubernetes 资源清单(YAML),可以在 Kubernetes 上部署 Dify。
- [Helm Chart by @LeoQuote](https://github.com/douban/charts/tree/master/charts/dify)
- [Helm Chart by @BorisPolonsky](https://github.com/BorisPolonsky/dify-helm)
@@ -204,6 +204,10 @@ docker compose up -d
- [YAML 文件 by @Winson-030](https://github.com/Winson-030/dify-kubernetes)
- [YAML file by @wyy-holding](https://github.com/wyy-holding/dify-k8s)
+- [🚀 NEW! YAML 文件 (支持 Dify v1.6.0) by @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
+
+
+
#### 使用 Terraform 部署
使用 [terraform](https://www.terraform.io/) 一键将 Dify 部署到云平台
@@ -221,6 +225,15 @@ docker compose up -d
##### AWS
- [AWS CDK by @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
+#### 使用 阿里云计算巢 部署
+
+使用 [阿里云计算巢](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88) 将 Dify 一键部署到 阿里云
+
+#### 使用 阿里云数据管理DMS 部署
+
+使用 [阿里云数据管理DMS](https://help.aliyun.com/zh/dms/dify-in-invitational-preview) 将 Dify 一键部署到 阿里云
+
+
## Star History
[](https://star-history.com/#langgenius/dify&Date)
diff --git a/README_DE.md b/README_DE.md
index f6023a3935..fce52c34c2 100644
--- a/README_DE.md
+++ b/README_DE.md
@@ -203,6 +203,7 @@ Falls Sie eine hochverfügbare Konfiguration einrichten möchten, gibt es von de
- [Helm Chart by @magicsong](https://github.com/magicsong/ai-charts)
- [YAML file by @Winson-030](https://github.com/Winson-030/dify-kubernetes)
- [YAML file by @wyy-holding](https://github.com/wyy-holding/dify-k8s)
+- [🚀 NEW! YAML files (Supports Dify v1.6.0) by @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
#### Terraform für die Bereitstellung verwenden
@@ -221,6 +222,15 @@ Bereitstellung von Dify auf AWS mit [CDK](https://aws.amazon.com/cdk/)
##### AWS
- [AWS CDK by @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
+#### Alibaba Cloud
+
+[Alibaba Cloud Computing Nest](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
+
+#### Alibaba Cloud Data Management
+
+Ein-Klick-Bereitstellung von Dify in der Alibaba Cloud mit [Alibaba Cloud Data Management](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/)
+
+
## Contributing
Falls Sie Code beitragen möchten, lesen Sie bitte unseren [Contribution Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md). Gleichzeitig bitten wir Sie, Dify zu unterstützen, indem Sie es in den sozialen Medien teilen und auf Veranstaltungen und Konferenzen präsentieren.
diff --git a/README_ES.md b/README_ES.md
index 12f2ce8c11..6fd6dfcee8 100644
--- a/README_ES.md
+++ b/README_ES.md
@@ -203,6 +203,7 @@ Si desea configurar una configuración de alta disponibilidad, la comunidad prop
- [Gráfico Helm por @magicsong](https://github.com/magicsong/ai-charts)
- [Ficheros YAML por @Winson-030](https://github.com/Winson-030/dify-kubernetes)
- [Ficheros YAML por @wyy-holding](https://github.com/wyy-holding/dify-k8s)
+- [🚀 ¡NUEVO! Archivos YAML (compatible con Dify v1.6.0) por @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
#### Uso de Terraform para el despliegue
@@ -221,6 +222,15 @@ Despliegue Dify en AWS usando [CDK](https://aws.amazon.com/cdk/)
##### AWS
- [AWS CDK por @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
+#### Alibaba Cloud
+
+[Alibaba Cloud Computing Nest](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
+
+#### Alibaba Cloud Data Management
+
+Despliega Dify en Alibaba Cloud con un solo clic con [Alibaba Cloud Data Management](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/)
+
+
## Contribuir
Para aquellos que deseen contribuir con código, consulten nuestra [Guía de contribución](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md).
diff --git a/README_FR.md b/README_FR.md
index b106615b31..b2209fb495 100644
--- a/README_FR.md
+++ b/README_FR.md
@@ -201,6 +201,7 @@ Si vous souhaitez configurer une configuration haute disponibilité, la communau
- [Helm Chart par @magicsong](https://github.com/magicsong/ai-charts)
- [Fichier YAML par @Winson-030](https://github.com/Winson-030/dify-kubernetes)
- [Fichier YAML par @wyy-holding](https://github.com/wyy-holding/dify-k8s)
+- [🚀 NOUVEAU ! Fichiers YAML (compatible avec Dify v1.6.0) par @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
#### Utilisation de Terraform pour le déploiement
@@ -219,6 +220,15 @@ Déployez Dify sur AWS en utilisant [CDK](https://aws.amazon.com/cdk/)
##### AWS
- [AWS CDK par @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
+#### Alibaba Cloud
+
+[Alibaba Cloud Computing Nest](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
+
+#### Alibaba Cloud Data Management
+
+Déployez Dify en un clic sur Alibaba Cloud avec [Alibaba Cloud Data Management](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/)
+
+
## Contribuer
Pour ceux qui souhaitent contribuer du code, consultez notre [Guide de contribution](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md).
diff --git a/README_JA.md b/README_JA.md
index 26703f3958..c658225f90 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -155,7 +155,7 @@ DifyはオープンソースのLLMアプリケーション開発プラットフ
[こちら](https://dify.ai)のDify Cloudサービスを利用して、セットアップ不要で試すことができます。サンドボックスプランには、200回のGPT-4呼び出しが無料で含まれています。
- **Dify Community Editionのセルフホスティング**
-この[スタートガイド](#quick-start)を使用して、ローカル環境でDifyを簡単に実行できます。
+この[スタートガイド](#クイックスタート)を使用して、ローカル環境でDifyを簡単に実行できます。
詳しくは[ドキュメント](https://docs.dify.ai)をご覧ください。
- **企業/組織向けのDify**
@@ -202,6 +202,7 @@ docker compose up -d
- [Helm Chart by @magicsong](https://github.com/magicsong/ai-charts)
- [YAML file by @Winson-030](https://github.com/Winson-030/dify-kubernetes)
- [YAML file by @wyy-holding](https://github.com/wyy-holding/dify-k8s)
+- [🚀 新着!YAML ファイル(Dify v1.6.0 対応)by @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
#### Terraformを使用したデプロイ
@@ -220,6 +221,13 @@ docker compose up -d
##### AWS
- [@KevinZhaoによるAWS CDK](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
+#### Alibaba Cloud
+[Alibaba Cloud Computing Nest](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
+
+#### Alibaba Cloud Data Management
+[Alibaba Cloud Data Management](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/) を利用して、DifyをAlibaba Cloudへワンクリックでデプロイできます
+
+
## 貢献
コードに貢献したい方は、[Contribution Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md)を参照してください。
diff --git a/README_KL.md b/README_KL.md
index ea91baa5aa..bfafcc7407 100644
--- a/README_KL.md
+++ b/README_KL.md
@@ -201,6 +201,7 @@ If you'd like to configure a highly-available setup, there are community-contrib
- [Helm Chart by @magicsong](https://github.com/magicsong/ai-charts)
- [YAML file by @Winson-030](https://github.com/Winson-030/dify-kubernetes)
- [YAML file by @wyy-holding](https://github.com/wyy-holding/dify-k8s)
+- [🚀 NEW! YAML files (Supports Dify v1.6.0) by @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
#### Terraform atorlugu pilersitsineq
@@ -219,6 +220,15 @@ wa'logh nIqHom neH ghun deployment toy'wI' [CDK](https://aws.amazon.com/cdk/) lo
##### AWS
- [AWS CDK qachlot @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
+#### Alibaba Cloud
+
+[Alibaba Cloud Computing Nest](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
+
+#### Alibaba Cloud Data Management
+
+[Alibaba Cloud Data Management](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/)
+
+
## Contributing
For those who'd like to contribute code, see our [Contribution Guide](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md).
diff --git a/README_KR.md b/README_KR.md
index 89301e8b2c..282117e776 100644
--- a/README_KR.md
+++ b/README_KR.md
@@ -195,6 +195,7 @@ Dify를 Kubernetes에 배포하고 프리미엄 스케일링 설정을 구성했
- [Helm Chart by @magicsong](https://github.com/magicsong/ai-charts)
- [YAML file by @Winson-030](https://github.com/Winson-030/dify-kubernetes)
- [YAML file by @wyy-holding](https://github.com/wyy-holding/dify-k8s)
+- [🚀 NEW! YAML files (Supports Dify v1.6.0) by @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
#### Terraform을 사용한 배포
@@ -213,6 +214,15 @@ Dify를 Kubernetes에 배포하고 프리미엄 스케일링 설정을 구성했
##### AWS
- [KevinZhao의 AWS CDK](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
+#### Alibaba Cloud
+
+[Alibaba Cloud Computing Nest](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
+
+#### Alibaba Cloud Data Management
+
+[Alibaba Cloud Data Management](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/)를 통해 원클릭으로 Dify를 Alibaba Cloud에 배포할 수 있습니다
+
+
## 기여
코드에 기여하고 싶은 분들은 [기여 가이드](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md)를 참조하세요.
diff --git a/README_PT.md b/README_PT.md
index 157772d528..576f6b48f7 100644
--- a/README_PT.md
+++ b/README_PT.md
@@ -200,6 +200,7 @@ Se deseja configurar uma instalação de alta disponibilidade, há [Helm Charts]
- [Helm Chart de @magicsong](https://github.com/magicsong/ai-charts)
- [Arquivo YAML por @Winson-030](https://github.com/Winson-030/dify-kubernetes)
- [Arquivo YAML por @wyy-holding](https://github.com/wyy-holding/dify-k8s)
+- [🚀 NOVO! Arquivos YAML (Compatível com Dify v1.6.0) por @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
#### Usando o Terraform para Implantação
@@ -218,6 +219,15 @@ Implante o Dify na AWS usando [CDK](https://aws.amazon.com/cdk/)
##### AWS
- [AWS CDK por @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
+#### Alibaba Cloud
+
+[Alibaba Cloud Computing Nest](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
+
+#### Alibaba Cloud Data Management
+
+Implante o Dify na Alibaba Cloud com um clique usando o [Alibaba Cloud Data Management](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/)
+
+
## Contribuindo
Para aqueles que desejam contribuir com código, veja nosso [Guia de Contribuição](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md).
diff --git a/README_SI.md b/README_SI.md
index 14de1ea792..7ded001d86 100644
--- a/README_SI.md
+++ b/README_SI.md
@@ -201,6 +201,7 @@ Star Dify on GitHub and be instantly notified of new releases.
- [Helm Chart by @BorisPolonsky](https://github.com/BorisPolonsky/dify-helm)
- [YAML file by @Winson-030](https://github.com/Winson-030/dify-kubernetes)
- [YAML file by @wyy-holding](https://github.com/wyy-holding/dify-k8s)
+- [🚀 NEW! YAML files (Supports Dify v1.6.0) by @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
#### Uporaba Terraform za uvajanje
@@ -219,6 +220,15 @@ Uvedite Dify v AWS z uporabo [CDK](https://aws.amazon.com/cdk/)
##### AWS
- [AWS CDK by @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
+#### Alibaba Cloud
+
+[Alibaba Cloud Computing Nest](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
+
+#### Alibaba Cloud Data Management
+
+Z enim klikom namestite Dify na Alibaba Cloud z [Alibaba Cloud Data Management](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/)
+
+
## Prispevam
Za tiste, ki bi radi prispevali kodo, si oglejte naš vodnik za prispevke . Hkrati vas prosimo, da podprete Dify tako, da ga delite na družbenih medijih ter na dogodkih in konferencah.
diff --git a/README_TR.md b/README_TR.md
index 563a05af3c..6e94e54fa0 100644
--- a/README_TR.md
+++ b/README_TR.md
@@ -194,6 +194,7 @@ Yüksek kullanılabilirliğe sahip bir kurulum yapılandırmak isterseniz, Dify'
- [@BorisPolonsky tarafından Helm Chart](https://github.com/BorisPolonsky/dify-helm)
- [@Winson-030 tarafından YAML dosyası](https://github.com/Winson-030/dify-kubernetes)
- [@wyy-holding tarafından YAML dosyası](https://github.com/wyy-holding/dify-k8s)
+- [🚀 YENİ! YAML dosyaları (Dify v1.6.0 destekli) @Zhoneym tarafından](https://github.com/Zhoneym/DifyAI-Kubernetes)
#### Dağıtım için Terraform Kullanımı
@@ -212,6 +213,15 @@ Dify'ı bulut platformuna tek tıklamayla dağıtın [terraform](https://www.ter
##### AWS
- [AWS CDK tarafından @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
+#### Alibaba Cloud
+
+[Alibaba Cloud Computing Nest](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
+
+#### Alibaba Cloud Data Management
+
+[Alibaba Cloud Data Management](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/) kullanarak Dify'ı tek tıkla Alibaba Cloud'a dağıtın
+
+
## Katkıda Bulunma
Kod katkısında bulunmak isteyenler için [Katkı Kılavuzumuza](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) bakabilirsiniz.
diff --git a/README_TW.md b/README_TW.md
index f4a76ac109..6e3e22b5c1 100644
--- a/README_TW.md
+++ b/README_TW.md
@@ -197,12 +197,13 @@ Dify 的所有功能都提供相應的 API,因此您可以輕鬆地將 Dify
如果您需要自定義配置,請參考我們的 [.env.example](docker/.env.example) 文件中的註釋,並在您的 `.env` 文件中更新相應的值。此外,根據您特定的部署環境和需求,您可能需要調整 `docker-compose.yaml` 文件本身,例如更改映像版本、端口映射或卷掛載。進行任何更改後,請重新運行 `docker-compose up -d`。您可以在[這裡](https://docs.dify.ai/getting-started/install-self-hosted/environments)找到可用環境變數的完整列表。
-如果您想配置高可用性設置,社區貢獻的 [Helm Charts](https://helm.sh/) 和 YAML 文件允許在 Kubernetes 上部署 Dify。
+如果您想配置高可用性設置,社區貢獻的 [Helm Charts](https://helm.sh/) 和 Kubernetes 資源清單(YAML)允許在 Kubernetes 上部署 Dify。
- [由 @LeoQuote 提供的 Helm Chart](https://github.com/douban/charts/tree/master/charts/dify)
- [由 @BorisPolonsky 提供的 Helm Chart](https://github.com/BorisPolonsky/dify-helm)
- [由 @Winson-030 提供的 YAML 文件](https://github.com/Winson-030/dify-kubernetes)
- [由 @wyy-holding 提供的 YAML 文件](https://github.com/wyy-holding/dify-k8s)
+- [🚀 NEW! YAML 檔案(支援 Dify v1.6.0)by @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
### 使用 Terraform 進行部署
@@ -224,6 +225,15 @@ Dify 的所有功能都提供相應的 API,因此您可以輕鬆地將 Dify
- [由 @KevinZhao 提供的 AWS CDK](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
+#### 使用 阿里云计算巢進行部署
+
+[阿里云](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
+
+#### 使用 阿里雲數據管理DMS 進行部署
+
+透過 [阿里雲數據管理DMS](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/),一鍵將 Dify 部署至阿里雲
+
+
## 貢獻
對於想要貢獻程式碼的開發者,請參閱我們的[貢獻指南](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md)。
diff --git a/README_VI.md b/README_VI.md
index 4e1e05cbf3..51314e6de5 100644
--- a/README_VI.md
+++ b/README_VI.md
@@ -196,6 +196,7 @@ Nếu bạn muốn cấu hình một cài đặt có độ sẵn sàng cao, có
- [Helm Chart bởi @BorisPolonsky](https://github.com/BorisPolonsky/dify-helm)
- [Tệp YAML bởi @Winson-030](https://github.com/Winson-030/dify-kubernetes)
- [Tệp YAML bởi @wyy-holding](https://github.com/wyy-holding/dify-k8s)
+- [🚀 MỚI! Tệp YAML (Hỗ trợ Dify v1.6.0) bởi @Zhoneym](https://github.com/Zhoneym/DifyAI-Kubernetes)
#### Sử dụng Terraform để Triển khai
@@ -214,6 +215,16 @@ Triển khai Dify trên AWS bằng [CDK](https://aws.amazon.com/cdk/)
##### AWS
- [AWS CDK bởi @KevinZhao](https://github.com/aws-samples/solution-for-deploying-dify-on-aws)
+
+#### Alibaba Cloud
+
+[Alibaba Cloud Computing Nest](https://computenest.console.aliyun.com/service/instance/create/default?type=user&ServiceName=Dify%E7%A4%BE%E5%8C%BA%E7%89%88)
+
+#### Alibaba Cloud Data Management
+
+Triển khai Dify lên Alibaba Cloud chỉ với một cú nhấp chuột bằng [Alibaba Cloud Data Management](https://www.alibabacloud.com/help/en/dms/dify-in-invitational-preview/)
+
+
## Đóng góp
Đối với những người muốn đóng góp mã, xem [Hướng dẫn Đóng góp](https://github.com/langgenius/dify/blob/main/CONTRIBUTING.md) của chúng tôi.
diff --git a/api/.env.example b/api/.env.example
index 7878308588..eab017a624 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -17,6 +17,11 @@ APP_WEB_URL=http://127.0.0.1:3000
# Files URL
FILES_URL=http://127.0.0.1:5001
+# INTERNAL_FILES_URL is used for plugin daemon communication within Docker network.
+# Set this to the internal Docker service URL for proper plugin file access.
+# Example: INTERNAL_FILES_URL=http://api:5001
+INTERNAL_FILES_URL=http://127.0.0.1:5001
+
# The time in seconds after the signature is rejected
FILES_ACCESS_TIMEOUT=300
@@ -137,7 +142,7 @@ WEB_API_CORS_ALLOW_ORIGINS=http://127.0.0.1:3000,*
CONSOLE_CORS_ALLOW_ORIGINS=http://127.0.0.1:3000,*
# Vector database configuration
-# support: weaviate, qdrant, milvus, myscale, relyt, pgvecto_rs, pgvector, pgvector, chroma, opensearch, tidb_vector, couchbase, vikingdb, upstash, lindorm, oceanbase, opengauss, tablestore
+# support: weaviate, qdrant, milvus, myscale, relyt, pgvecto_rs, pgvector, pgvector, chroma, opensearch, tidb_vector, couchbase, vikingdb, upstash, lindorm, oceanbase, opengauss, tablestore, matrixone
VECTOR_STORE=weaviate
# Weaviate configuration
@@ -294,6 +299,13 @@ VIKINGDB_SCHEMA=http
VIKINGDB_CONNECTION_TIMEOUT=30
VIKINGDB_SOCKET_TIMEOUT=30
+# Matrixone configration
+MATRIXONE_HOST=127.0.0.1
+MATRIXONE_PORT=6001
+MATRIXONE_USER=dump
+MATRIXONE_PASSWORD=111
+MATRIXONE_DATABASE=dify
+
# Lindorm configuration
LINDORM_URL=http://ld-*******************-proxy-search-pub.lindorm.aliyuncs.com:30070
LINDORM_USERNAME=admin
@@ -332,9 +344,11 @@ PROMPT_GENERATION_MAX_TOKENS=512
CODE_GENERATION_MAX_TOKENS=1024
PLUGIN_BASED_TOKEN_COUNTING_ENABLED=false
-# Mail configuration, support: resend, smtp
+# Mail configuration, support: resend, smtp, sendgrid
MAIL_TYPE=
+# If using SendGrid, use the 'from' field for authentication if necessary.
MAIL_DEFAULT_SEND_FROM=no-reply
+# resend configuration
RESEND_API_KEY=
RESEND_API_URL=https://api.resend.com
# smtp configuration
@@ -344,7 +358,8 @@ SMTP_USERNAME=123
SMTP_PASSWORD=abc
SMTP_USE_TLS=true
SMTP_OPPORTUNISTIC_TLS=false
-
+# Sendgid configuration
+SENDGRID_API_KEY=
# Sentry configuration
SENTRY_DSN=
@@ -434,6 +449,19 @@ MAX_VARIABLE_SIZE=204800
# hybrid: Save new data to object storage, read from both object storage and RDBMS
WORKFLOW_NODE_EXECUTION_STORAGE=rdbms
+# Repository configuration
+# Core workflow execution repository implementation
+CORE_WORKFLOW_EXECUTION_REPOSITORY=core.repositories.sqlalchemy_workflow_execution_repository.SQLAlchemyWorkflowExecutionRepository
+
+# Core workflow node execution repository implementation
+CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY=core.repositories.sqlalchemy_workflow_node_execution_repository.SQLAlchemyWorkflowNodeExecutionRepository
+
+# API workflow node execution repository implementation
+API_WORKFLOW_NODE_EXECUTION_REPOSITORY=repositories.sqlalchemy_api_workflow_node_execution_repository.DifyAPISQLAlchemyWorkflowNodeExecutionRepository
+
+# API workflow run repository implementation
+API_WORKFLOW_RUN_REPOSITORY=repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository
+
# App configuration
APP_MAX_EXECUTION_TIME=1200
APP_MAX_ACTIVE_REQUESTS=0
diff --git a/api/.ruff.toml b/api/.ruff.toml
index facb0d5419..0169613bf8 100644
--- a/api/.ruff.toml
+++ b/api/.ruff.toml
@@ -1,6 +1,4 @@
-exclude = [
- "migrations/*",
-]
+exclude = ["migrations/*"]
line-length = 120
[format]
@@ -9,14 +7,14 @@ quote-style = "double"
[lint]
preview = false
select = [
- "B", # flake8-bugbear rules
- "C4", # flake8-comprehensions
- "E", # pycodestyle E rules
- "F", # pyflakes rules
- "FURB", # refurb rules
- "I", # isort rules
- "N", # pep8-naming
- "PT", # flake8-pytest-style rules
+ "B", # flake8-bugbear rules
+ "C4", # flake8-comprehensions
+ "E", # pycodestyle E rules
+ "F", # pyflakes rules
+ "FURB", # refurb rules
+ "I", # isort rules
+ "N", # pep8-naming
+ "PT", # flake8-pytest-style rules
"PLC0208", # iteration-over-set
"PLC0414", # useless-import-alias
"PLE0604", # invalid-all-object
@@ -24,19 +22,19 @@ select = [
"PLR0402", # manual-from-import
"PLR1711", # useless-return
"PLR1714", # repeated-equality-comparison
- "RUF013", # implicit-optional
- "RUF019", # unnecessary-key-check
- "RUF100", # unused-noqa
- "RUF101", # redirected-noqa
- "RUF200", # invalid-pyproject-toml
- "RUF022", # unsorted-dunder-all
- "S506", # unsafe-yaml-load
- "SIM", # flake8-simplify rules
- "TRY400", # error-instead-of-exception
- "TRY401", # verbose-log-message
- "UP", # pyupgrade rules
- "W191", # tab-indentation
- "W605", # invalid-escape-sequence
+ "RUF013", # implicit-optional
+ "RUF019", # unnecessary-key-check
+ "RUF100", # unused-noqa
+ "RUF101", # redirected-noqa
+ "RUF200", # invalid-pyproject-toml
+ "RUF022", # unsorted-dunder-all
+ "S506", # unsafe-yaml-load
+ "SIM", # flake8-simplify rules
+ "TRY400", # error-instead-of-exception
+ "TRY401", # verbose-log-message
+ "UP", # pyupgrade rules
+ "W191", # tab-indentation
+ "W605", # invalid-escape-sequence
# security related linting rules
# RCE proctection (sort of)
"S102", # exec-builtin, disallow use of `exec`
@@ -47,36 +45,37 @@ select = [
]
ignore = [
- "E402", # module-import-not-at-top-of-file
- "E711", # none-comparison
- "E712", # true-false-comparison
- "E721", # type-comparison
- "E722", # bare-except
- "F821", # undefined-name
- "F841", # unused-variable
+ "E402", # module-import-not-at-top-of-file
+ "E711", # none-comparison
+ "E712", # true-false-comparison
+ "E721", # type-comparison
+ "E722", # bare-except
+ "F821", # undefined-name
+ "F841", # unused-variable
"FURB113", # repeated-append
"FURB152", # math-constant
- "UP007", # non-pep604-annotation
- "UP032", # f-string
- "UP045", # non-pep604-annotation-optional
- "B005", # strip-with-multi-characters
- "B006", # mutable-argument-default
- "B007", # unused-loop-control-variable
- "B026", # star-arg-unpacking-after-keyword-arg
- "B903", # class-as-data-structure
- "B904", # raise-without-from-inside-except
- "B905", # zip-without-explicit-strict
- "N806", # non-lowercase-variable-in-function
- "N815", # mixed-case-variable-in-class-scope
- "PT011", # pytest-raises-too-broad
- "SIM102", # collapsible-if
- "SIM103", # needless-bool
- "SIM105", # suppressible-exception
- "SIM107", # return-in-try-except-finally
- "SIM108", # if-else-block-instead-of-if-exp
- "SIM113", # enumerate-for-loop
- "SIM117", # multiple-with-statements
- "SIM210", # if-expr-with-true-false
+ "UP007", # non-pep604-annotation
+ "UP032", # f-string
+ "UP045", # non-pep604-annotation-optional
+ "B005", # strip-with-multi-characters
+ "B006", # mutable-argument-default
+ "B007", # unused-loop-control-variable
+ "B026", # star-arg-unpacking-after-keyword-arg
+ "B903", # class-as-data-structure
+ "B904", # raise-without-from-inside-except
+ "B905", # zip-without-explicit-strict
+ "N806", # non-lowercase-variable-in-function
+ "N815", # mixed-case-variable-in-class-scope
+ "PT011", # pytest-raises-too-broad
+ "SIM102", # collapsible-if
+ "SIM103", # needless-bool
+ "SIM105", # suppressible-exception
+ "SIM107", # return-in-try-except-finally
+ "SIM108", # if-else-block-instead-of-if-exp
+ "SIM113", # enumerate-for-loop
+ "SIM117", # multiple-with-statements
+ "SIM210", # if-expr-with-true-false
+ "UP038", # deprecated and not recommended by Ruff, https://docs.astral.sh/ruff/rules/non-pep604-isinstance/
]
[lint.per-file-ignores]
diff --git a/api/commands.py b/api/commands.py
index 6262bfde6b..86769847c1 100644
--- a/api/commands.py
+++ b/api/commands.py
@@ -27,7 +27,7 @@ from models.dataset import Dataset, DatasetCollectionBinding, DatasetMetadata, D
from models.dataset import Document as DatasetDocument
from models.model import Account, App, AppAnnotationSetting, AppMode, Conversation, MessageAnnotation
from models.provider import Provider, ProviderModel
-from services.account_service import RegisterService, TenantService
+from services.account_service import AccountService, RegisterService, TenantService
from services.clear_free_plan_tenant_expired_logs import ClearFreePlanTenantExpiredLogs
from services.plugin.data_migration import PluginDataMigration
from services.plugin.plugin_migration import PluginMigration
@@ -68,6 +68,7 @@ def reset_password(email, new_password, password_confirm):
account.password = base64_password_hashed
account.password_salt = base64_salt
db.session.commit()
+ AccountService.reset_login_error_rate_limit(email)
click.echo(click.style("Password reset successfully.", fg="green"))
@@ -280,6 +281,7 @@ def migrate_knowledge_vector_database():
VectorType.ELASTICSEARCH,
VectorType.OPENGAUSS,
VectorType.TABLESTORE,
+ VectorType.MATRIXONE,
}
lower_collection_vector_types = {
VectorType.ANALYTICDB,
diff --git a/api/configs/app_config.py b/api/configs/app_config.py
index 3a3ad35ee7..20f8c40427 100644
--- a/api/configs/app_config.py
+++ b/api/configs/app_config.py
@@ -1,8 +1,11 @@
import logging
+from pathlib import Path
from typing import Any
from pydantic.fields import FieldInfo
-from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
+from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
+
+from libs.file_utils import search_file_upwards
from .deploy import DeploymentConfig
from .enterprise import EnterpriseFeatureConfig
@@ -99,4 +102,12 @@ class DifyConfig(
RemoteSettingsSourceFactory(settings_cls),
dotenv_settings,
file_secret_settings,
+ TomlConfigSettingsSource(
+ settings_cls=settings_cls,
+ toml_file=search_file_upwards(
+ base_dir_path=Path(__file__).parent,
+ target_file_name="pyproject.toml",
+ max_search_parent_depth=2,
+ ),
+ ),
)
diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py
index a3da5c1b49..f6a8b037ca 100644
--- a/api/configs/feature/__init__.py
+++ b/api/configs/feature/__init__.py
@@ -237,6 +237,13 @@ class FileAccessConfig(BaseSettings):
default="",
)
+ INTERNAL_FILES_URL: str = Field(
+ description="Internal base URL for file access within Docker network,"
+ " used for plugin daemon and internal service communication."
+ " Falls back to FILES_URL if not specified.",
+ default="",
+ )
+
FILES_ACCESS_TIMEOUT: int = Field(
description="Expiration time in seconds for file access URLs",
default=300,
@@ -530,6 +537,33 @@ class WorkflowNodeExecutionConfig(BaseSettings):
)
+class RepositoryConfig(BaseSettings):
+ """
+ Configuration for repository implementations
+ """
+
+ CORE_WORKFLOW_EXECUTION_REPOSITORY: str = Field(
+ description="Repository implementation for WorkflowExecution. Specify as a module path",
+ default="core.repositories.sqlalchemy_workflow_execution_repository.SQLAlchemyWorkflowExecutionRepository",
+ )
+
+ CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY: str = Field(
+ description="Repository implementation for WorkflowNodeExecution. Specify as a module path",
+ default="core.repositories.sqlalchemy_workflow_node_execution_repository.SQLAlchemyWorkflowNodeExecutionRepository",
+ )
+
+ API_WORKFLOW_NODE_EXECUTION_REPOSITORY: str = Field(
+ description="Service-layer repository implementation for WorkflowNodeExecutionModel operations. "
+ "Specify as a module path",
+ default="repositories.sqlalchemy_api_workflow_node_execution_repository.DifyAPISQLAlchemyWorkflowNodeExecutionRepository",
+ )
+
+ API_WORKFLOW_RUN_REPOSITORY: str = Field(
+ description="Service-layer repository implementation for WorkflowRun operations. Specify as a module path",
+ default="repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository",
+ )
+
+
class AuthConfig(BaseSettings):
"""
Configuration for authentication and OAuth
@@ -609,7 +643,7 @@ class MailConfig(BaseSettings):
"""
MAIL_TYPE: Optional[str] = Field(
- description="Email service provider type ('smtp' or 'resend'), default to None.",
+ description="Email service provider type ('smtp' or 'resend' or 'sendGrid), default to None.",
default=None,
)
@@ -663,6 +697,11 @@ class MailConfig(BaseSettings):
default=50,
)
+ SENDGRID_API_KEY: Optional[str] = Field(
+ description="API key for SendGrid service",
+ default=None,
+ )
+
class RagEtlConfig(BaseSettings):
"""
@@ -891,6 +930,7 @@ class FeatureConfig(
MultiModalTransferConfig,
PositionConfig,
RagEtlConfig,
+ RepositoryConfig,
SecurityConfig,
ToolConfig,
UpdateConfig,
diff --git a/api/configs/middleware/__init__.py b/api/configs/middleware/__init__.py
index 2dcf1710b0..0c0c06dd46 100644
--- a/api/configs/middleware/__init__.py
+++ b/api/configs/middleware/__init__.py
@@ -24,6 +24,7 @@ from .vdb.couchbase_config import CouchbaseConfig
from .vdb.elasticsearch_config import ElasticsearchConfig
from .vdb.huawei_cloud_config import HuaweiCloudConfig
from .vdb.lindorm_config import LindormConfig
+from .vdb.matrixone_config import MatrixoneConfig
from .vdb.milvus_config import MilvusConfig
from .vdb.myscale_config import MyScaleConfig
from .vdb.oceanbase_config import OceanBaseVectorConfig
@@ -161,6 +162,11 @@ class DatabaseConfig(BaseSettings):
default=3600,
)
+ SQLALCHEMY_POOL_USE_LIFO: bool = Field(
+ description="If True, SQLAlchemy will use last-in-first-out way to retrieve connections from pool.",
+ default=False,
+ )
+
SQLALCHEMY_POOL_PRE_PING: bool = Field(
description="If True, enables connection pool pre-ping feature to check connections.",
default=False,
@@ -198,6 +204,7 @@ class DatabaseConfig(BaseSettings):
"pool_recycle": self.SQLALCHEMY_POOL_RECYCLE,
"pool_pre_ping": self.SQLALCHEMY_POOL_PRE_PING,
"connect_args": connect_args,
+ "pool_use_lifo": self.SQLALCHEMY_POOL_USE_LIFO,
}
@@ -222,6 +229,10 @@ class CeleryConfig(DatabaseConfig):
default=None,
)
+ CELERY_SENTINEL_PASSWORD: Optional[str] = Field(
+ description="Password of the Redis Sentinel master.",
+ default=None,
+ )
CELERY_SENTINEL_SOCKET_TIMEOUT: Optional[PositiveFloat] = Field(
description="Timeout for Redis Sentinel socket operations in seconds.",
default=0.1,
@@ -323,5 +334,6 @@ class MiddlewareConfig(
OpenGaussConfig,
TableStoreConfig,
DatasetQueueMonitorConfig,
+ MatrixoneConfig,
):
pass
diff --git a/api/configs/middleware/vdb/matrixone_config.py b/api/configs/middleware/vdb/matrixone_config.py
new file mode 100644
index 0000000000..9400612d8e
--- /dev/null
+++ b/api/configs/middleware/vdb/matrixone_config.py
@@ -0,0 +1,14 @@
+from pydantic import BaseModel, Field
+
+
+class MatrixoneConfig(BaseModel):
+ """Matrixone vector database configuration."""
+
+ MATRIXONE_HOST: str = Field(default="localhost", description="Host address of the Matrixone server")
+ MATRIXONE_PORT: int = Field(default=6001, description="Port number of the Matrixone server")
+ MATRIXONE_USER: str = Field(default="dump", description="Username for authenticating with Matrixone")
+ MATRIXONE_PASSWORD: str = Field(default="111", description="Password for authenticating with Matrixone")
+ MATRIXONE_DATABASE: str = Field(default="dify", description="Name of the Matrixone database to connect to")
+ MATRIXONE_METRIC: str = Field(
+ default="l2", description="Distance metric type for vector similarity search (cosine or l2)"
+ )
diff --git a/api/configs/packaging/__init__.py b/api/configs/packaging/__init__.py
index 5f209736a0..f511e20e6b 100644
--- a/api/configs/packaging/__init__.py
+++ b/api/configs/packaging/__init__.py
@@ -1,17 +1,13 @@
from pydantic import Field
-from pydantic_settings import BaseSettings
+from configs.packaging.pyproject import PyProjectConfig, PyProjectTomlConfig
-class PackagingInfo(BaseSettings):
+
+class PackagingInfo(PyProjectTomlConfig):
"""
Packaging build information
"""
- CURRENT_VERSION: str = Field(
- description="Dify version",
- default="1.4.2",
- )
-
COMMIT_SHA: str = Field(
description="SHA-1 checksum of the git commit used to build the app",
default="",
diff --git a/api/configs/packaging/pyproject.py b/api/configs/packaging/pyproject.py
new file mode 100644
index 0000000000..90b1ecba06
--- /dev/null
+++ b/api/configs/packaging/pyproject.py
@@ -0,0 +1,17 @@
+from pydantic import BaseModel, Field
+from pydantic_settings import BaseSettings
+
+
+class PyProjectConfig(BaseModel):
+ version: str = Field(description="Dify version", default="")
+
+
+class PyProjectTomlConfig(BaseSettings):
+ """
+ configs in api/pyproject.toml
+ """
+
+ project: PyProjectConfig = Field(
+ description="configs in the project section of pyproject.toml",
+ default=PyProjectConfig(),
+ )
diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py
index a974c63e35..e25f92399c 100644
--- a/api/controllers/console/__init__.py
+++ b/api/controllers/console/__init__.py
@@ -56,6 +56,7 @@ from .app import (
conversation,
conversation_variables,
generator,
+ mcp_server,
message,
model_config,
ops_trace,
@@ -63,6 +64,7 @@ from .app import (
statistic,
workflow,
workflow_app_log,
+ workflow_draft_variable,
workflow_run,
workflow_statistic,
)
diff --git a/api/controllers/console/admin.py b/api/controllers/console/admin.py
index 8cb7ad9f5b..f5257fae79 100644
--- a/api/controllers/console/admin.py
+++ b/api/controllers/console/admin.py
@@ -56,8 +56,7 @@ class InsertExploreAppListApi(Resource):
parser.add_argument("position", type=int, required=True, nullable=False, location="json")
args = parser.parse_args()
- with Session(db.engine) as session:
- app = session.execute(select(App).filter(App.id == args["app_id"])).scalar_one_or_none()
+ app = db.session.execute(select(App).filter(App.id == args["app_id"])).scalar_one_or_none()
if not app:
raise NotFound(f"App '{args['app_id']}' is not found")
@@ -78,38 +77,38 @@ class InsertExploreAppListApi(Resource):
select(RecommendedApp).filter(RecommendedApp.app_id == args["app_id"])
).scalar_one_or_none()
- if not recommended_app:
- recommended_app = RecommendedApp(
- app_id=app.id,
- description=desc,
- copyright=copy_right,
- privacy_policy=privacy_policy,
- custom_disclaimer=custom_disclaimer,
- language=args["language"],
- category=args["category"],
- position=args["position"],
- )
-
- db.session.add(recommended_app)
-
- app.is_public = True
- db.session.commit()
-
- return {"result": "success"}, 201
- else:
- recommended_app.description = desc
- recommended_app.copyright = copy_right
- recommended_app.privacy_policy = privacy_policy
- recommended_app.custom_disclaimer = custom_disclaimer
- recommended_app.language = args["language"]
- recommended_app.category = args["category"]
- recommended_app.position = args["position"]
+ if not recommended_app:
+ recommended_app = RecommendedApp(
+ app_id=app.id,
+ description=desc,
+ copyright=copy_right,
+ privacy_policy=privacy_policy,
+ custom_disclaimer=custom_disclaimer,
+ language=args["language"],
+ category=args["category"],
+ position=args["position"],
+ )
+
+ db.session.add(recommended_app)
+
+ app.is_public = True
+ db.session.commit()
+
+ return {"result": "success"}, 201
+ else:
+ recommended_app.description = desc
+ recommended_app.copyright = copy_right
+ recommended_app.privacy_policy = privacy_policy
+ recommended_app.custom_disclaimer = custom_disclaimer
+ recommended_app.language = args["language"]
+ recommended_app.category = args["category"]
+ recommended_app.position = args["position"]
- app.is_public = True
+ app.is_public = True
- db.session.commit()
+ db.session.commit()
- return {"result": "success"}, 200
+ return {"result": "success"}, 200
class InsertExploreAppApi(Resource):
diff --git a/api/controllers/console/app/annotation.py b/api/controllers/console/app/annotation.py
index 14fd4679a1..2b48afd550 100644
--- a/api/controllers/console/app/annotation.py
+++ b/api/controllers/console/app/annotation.py
@@ -208,7 +208,7 @@ class AnnotationBatchImportApi(Resource):
if len(request.files) > 1:
raise TooManyFilesError()
# check file type
- if not file.filename or not file.filename.endswith(".csv"):
+ if not file.filename or not file.filename.lower().endswith(".csv"):
raise ValueError("Invalid file type. Only CSV files are allowed")
return AppAnnotationService.batch_import_app_annotations(app_id, file)
diff --git a/api/controllers/console/app/app_import.py b/api/controllers/console/app/app_import.py
index 5dc6515ce0..9ffb94e9f9 100644
--- a/api/controllers/console/app/app_import.py
+++ b/api/controllers/console/app/app_import.py
@@ -17,6 +17,8 @@ from libs.login import login_required
from models import Account
from models.model import App
from services.app_dsl_service import AppDslService, ImportStatus
+from services.enterprise.enterprise_service import EnterpriseService
+from services.feature_service import FeatureService
class AppImportApi(Resource):
@@ -60,7 +62,9 @@ class AppImportApi(Resource):
app_id=args.get("app_id"),
)
session.commit()
-
+ if result.app_id and FeatureService.get_system_features().webapp_auth.enabled:
+ # update web app setting as private
+ EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private")
# Return appropriate status code based on result
status = result.status
if status == ImportStatus.FAILED.value:
diff --git a/api/controllers/console/app/audio.py b/api/controllers/console/app/audio.py
index 5f2def8d8e..665cf1aede 100644
--- a/api/controllers/console/app/audio.py
+++ b/api/controllers/console/app/audio.py
@@ -90,23 +90,11 @@ class ChatMessageTextApi(Resource):
message_id = args.get("message_id", None)
text = args.get("text", None)
- if (
- app_model.mode in {AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value}
- and app_model.workflow
- and app_model.workflow.features_dict
- ):
- text_to_speech = app_model.workflow.features_dict.get("text_to_speech")
- if text_to_speech is None:
- raise ValueError("TTS is not enabled")
- voice = args.get("voice") or text_to_speech.get("voice")
- else:
- try:
- if app_model.app_model_config is None:
- raise ValueError("AppModelConfig not found")
- voice = args.get("voice") or app_model.app_model_config.text_to_speech_dict.get("voice")
- except Exception:
- voice = None
- response = AudioService.transcript_tts(app_model=app_model, text=text, message_id=message_id, voice=voice)
+ voice = args.get("voice", None)
+
+ response = AudioService.transcript_tts(
+ app_model=app_model, text=text, voice=voice, message_id=message_id, is_draft=True
+ )
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logging.exception("App model config broken.")
diff --git a/api/controllers/console/app/mcp_server.py b/api/controllers/console/app/mcp_server.py
new file mode 100644
index 0000000000..0f53860f56
--- /dev/null
+++ b/api/controllers/console/app/mcp_server.py
@@ -0,0 +1,107 @@
+import json
+from enum import StrEnum
+
+from flask_login import current_user
+from flask_restful import Resource, marshal_with, reqparse
+from werkzeug.exceptions import NotFound
+
+from controllers.console import api
+from controllers.console.app.wraps import get_app_model
+from controllers.console.wraps import account_initialization_required, setup_required
+from extensions.ext_database import db
+from fields.app_fields import app_server_fields
+from libs.login import login_required
+from models.model import AppMCPServer
+
+
+class AppMCPServerStatus(StrEnum):
+ ACTIVE = "active"
+ INACTIVE = "inactive"
+
+
+class AppMCPServerController(Resource):
+ @setup_required
+ @login_required
+ @account_initialization_required
+ @get_app_model
+ @marshal_with(app_server_fields)
+ def get(self, app_model):
+ server = db.session.query(AppMCPServer).filter(AppMCPServer.app_id == app_model.id).first()
+ return server
+
+ @setup_required
+ @login_required
+ @account_initialization_required
+ @get_app_model
+ @marshal_with(app_server_fields)
+ def post(self, app_model):
+ # The role of the current user in the ta table must be editor, admin, or owner
+ if not current_user.is_editor:
+ raise NotFound()
+ parser = reqparse.RequestParser()
+ parser.add_argument("description", type=str, required=True, location="json")
+ parser.add_argument("parameters", type=dict, required=True, location="json")
+ args = parser.parse_args()
+ server = AppMCPServer(
+ name=app_model.name,
+ description=args["description"],
+ parameters=json.dumps(args["parameters"], ensure_ascii=False),
+ status=AppMCPServerStatus.ACTIVE,
+ app_id=app_model.id,
+ tenant_id=current_user.current_tenant_id,
+ server_code=AppMCPServer.generate_server_code(16),
+ )
+ db.session.add(server)
+ db.session.commit()
+ return server
+
+ @setup_required
+ @login_required
+ @account_initialization_required
+ @get_app_model
+ @marshal_with(app_server_fields)
+ def put(self, app_model):
+ if not current_user.is_editor:
+ raise NotFound()
+ parser = reqparse.RequestParser()
+ parser.add_argument("id", type=str, required=True, location="json")
+ parser.add_argument("description", type=str, required=True, location="json")
+ parser.add_argument("parameters", type=dict, required=True, location="json")
+ parser.add_argument("status", type=str, required=False, location="json")
+ args = parser.parse_args()
+ server = db.session.query(AppMCPServer).filter(AppMCPServer.id == args["id"]).first()
+ if not server:
+ raise NotFound()
+ server.description = args["description"]
+ server.parameters = json.dumps(args["parameters"], ensure_ascii=False)
+ if args["status"]:
+ if args["status"] not in [status.value for status in AppMCPServerStatus]:
+ raise ValueError("Invalid status")
+ server.status = args["status"]
+ db.session.commit()
+ return server
+
+
+class AppMCPServerRefreshController(Resource):
+ @setup_required
+ @login_required
+ @account_initialization_required
+ @marshal_with(app_server_fields)
+ def get(self, server_id):
+ if not current_user.is_editor:
+ raise NotFound()
+ server = (
+ db.session.query(AppMCPServer)
+ .filter(AppMCPServer.id == server_id)
+ .filter(AppMCPServer.tenant_id == current_user.current_tenant_id)
+ .first()
+ )
+ if not server:
+ raise NotFound()
+ server.server_code = AppMCPServer.generate_server_code(16)
+ db.session.commit()
+ return server
+
+
+api.add_resource(AppMCPServerController, "/apps//server")
+api.add_resource(AppMCPServerRefreshController, "/apps//server/refresh")
diff --git a/api/controllers/console/app/statistic.py b/api/controllers/console/app/statistic.py
index 86aed77412..32b64d10c5 100644
--- a/api/controllers/console/app/statistic.py
+++ b/api/controllers/console/app/statistic.py
@@ -2,6 +2,7 @@ from datetime import datetime
from decimal import Decimal
import pytz
+import sqlalchemy as sa
from flask import jsonify
from flask_login import current_user
from flask_restful import Resource, reqparse
@@ -9,10 +10,11 @@ from flask_restful import Resource, reqparse
from controllers.console import api
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
+from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
from libs.helper import DatetimeString
from libs.login import login_required
-from models.model import AppMode
+from models import AppMode, Message
class DailyMessageStatistic(Resource):
@@ -85,46 +87,41 @@ class DailyConversationStatistic(Resource):
parser.add_argument("end", type=DatetimeString("%Y-%m-%d %H:%M"), location="args")
args = parser.parse_args()
- sql_query = """SELECT
- DATE(DATE_TRUNC('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz )) AS date,
- COUNT(DISTINCT messages.conversation_id) AS conversation_count
-FROM
- messages
-WHERE
- app_id = :app_id"""
- arg_dict = {"tz": account.timezone, "app_id": app_model.id}
-
timezone = pytz.timezone(account.timezone)
utc_timezone = pytz.utc
+ stmt = (
+ sa.select(
+ sa.func.date(
+ sa.func.date_trunc("day", sa.text("created_at AT TIME ZONE 'UTC' AT TIME ZONE :tz"))
+ ).label("date"),
+ sa.func.count(sa.distinct(Message.conversation_id)).label("conversation_count"),
+ )
+ .select_from(Message)
+ .where(Message.app_id == app_model.id, Message.invoke_from != InvokeFrom.DEBUGGER.value)
+ )
+
if args["start"]:
start_datetime = datetime.strptime(args["start"], "%Y-%m-%d %H:%M")
start_datetime = start_datetime.replace(second=0)
-
start_datetime_timezone = timezone.localize(start_datetime)
start_datetime_utc = start_datetime_timezone.astimezone(utc_timezone)
-
- sql_query += " AND created_at >= :start"
- arg_dict["start"] = start_datetime_utc
+ stmt = stmt.where(Message.created_at >= start_datetime_utc)
if args["end"]:
end_datetime = datetime.strptime(args["end"], "%Y-%m-%d %H:%M")
end_datetime = end_datetime.replace(second=0)
-
end_datetime_timezone = timezone.localize(end_datetime)
end_datetime_utc = end_datetime_timezone.astimezone(utc_timezone)
+ stmt = stmt.where(Message.created_at < end_datetime_utc)
- sql_query += " AND created_at < :end"
- arg_dict["end"] = end_datetime_utc
-
- sql_query += " GROUP BY date ORDER BY date"
+ stmt = stmt.group_by("date").order_by("date")
response_data = []
-
with db.engine.begin() as conn:
- rs = conn.execute(db.text(sql_query), arg_dict)
- for i in rs:
- response_data.append({"date": str(i.date), "conversation_count": i.conversation_count})
+ rs = conn.execute(stmt, {"tz": account.timezone})
+ for row in rs:
+ response_data.append({"date": str(row.date), "conversation_count": row.conversation_count})
return jsonify({"data": response_data})
diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py
index cbbdd324ba..a9f088a276 100644
--- a/api/controllers/console/app/workflow.py
+++ b/api/controllers/console/app/workflow.py
@@ -1,5 +1,6 @@
import json
import logging
+from collections.abc import Sequence
from typing import cast
from flask import abort, request
@@ -18,10 +19,12 @@ from controllers.console.app.error import (
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
+from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
+from core.file.models import File
from extensions.ext_database import db
-from factories import variable_factory
+from factories import file_factory, variable_factory
from fields.workflow_fields import workflow_fields, workflow_pagination_fields
from fields.workflow_run_fields import workflow_run_node_execution_fields
from libs import helper
@@ -30,6 +33,7 @@ from libs.login import current_user, login_required
from models import App
from models.account import Account
from models.model import AppMode
+from models.workflow import Workflow
from services.app_generate_service import AppGenerateService
from services.errors.app import WorkflowHashNotEqualError
from services.errors.llm import InvokeRateLimitError
@@ -38,6 +42,24 @@ from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseE
logger = logging.getLogger(__name__)
+# TODO(QuantumGhost): Refactor existing node run API to handle file parameter parsing
+# at the controller level rather than in the workflow logic. This would improve separation
+# of concerns and make the code more maintainable.
+def _parse_file(workflow: Workflow, files: list[dict] | None = None) -> Sequence[File]:
+ files = files or []
+
+ file_extra_config = FileUploadConfigManager.convert(workflow.features_dict, is_vision=False)
+ file_objs: Sequence[File] = []
+ if file_extra_config is None:
+ return file_objs
+ file_objs = file_factory.build_from_mappings(
+ mappings=files,
+ tenant_id=workflow.tenant_id,
+ config=file_extra_config,
+ )
+ return file_objs
+
+
class DraftWorkflowApi(Resource):
@setup_required
@login_required
@@ -402,15 +424,30 @@ class DraftWorkflowNodeRunApi(Resource):
parser = reqparse.RequestParser()
parser.add_argument("inputs", type=dict, required=True, nullable=False, location="json")
+ parser.add_argument("query", type=str, required=False, location="json", default="")
+ parser.add_argument("files", type=list, location="json", default=[])
args = parser.parse_args()
- inputs = args.get("inputs")
- if inputs == None:
+ user_inputs = args.get("inputs")
+ if user_inputs is None:
raise ValueError("missing inputs")
+ workflow_srv = WorkflowService()
+ # fetch draft workflow by app_model
+ draft_workflow = workflow_srv.get_draft_workflow(app_model=app_model)
+ if not draft_workflow:
+ raise ValueError("Workflow not initialized")
+ files = _parse_file(draft_workflow, args.get("files"))
workflow_service = WorkflowService()
+
workflow_node_execution = workflow_service.run_draft_workflow_node(
- app_model=app_model, node_id=node_id, user_inputs=inputs, account=current_user
+ app_model=app_model,
+ draft_workflow=draft_workflow,
+ node_id=node_id,
+ user_inputs=user_inputs,
+ account=current_user,
+ query=args.get("query", ""),
+ files=files,
)
return workflow_node_execution
@@ -731,6 +768,27 @@ class WorkflowByIdApi(Resource):
return None, 204
+class DraftWorkflowNodeLastRunApi(Resource):
+ @setup_required
+ @login_required
+ @account_initialization_required
+ @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
+ @marshal_with(workflow_run_node_execution_fields)
+ def get(self, app_model: App, node_id: str):
+ srv = WorkflowService()
+ workflow = srv.get_draft_workflow(app_model)
+ if not workflow:
+ raise NotFound("Workflow not found")
+ node_exec = srv.get_node_last_run(
+ app_model=app_model,
+ workflow=workflow,
+ node_id=node_id,
+ )
+ if node_exec is None:
+ raise NotFound("last run not found")
+ return node_exec
+
+
api.add_resource(
DraftWorkflowApi,
"/apps//workflows/draft",
@@ -795,3 +853,7 @@ api.add_resource(
WorkflowByIdApi,
"/apps//workflows/",
)
+api.add_resource(
+ DraftWorkflowNodeLastRunApi,
+ "/apps//workflows/draft/nodes//last-run",
+)
diff --git a/api/controllers/console/app/workflow_app_log.py b/api/controllers/console/app/workflow_app_log.py
index b9579e2120..310146a5e7 100644
--- a/api/controllers/console/app/workflow_app_log.py
+++ b/api/controllers/console/app/workflow_app_log.py
@@ -34,6 +34,20 @@ class WorkflowAppLogApi(Resource):
parser.add_argument(
"created_at__after", type=str, location="args", help="Filter logs created after this timestamp"
)
+ parser.add_argument(
+ "created_by_end_user_session_id",
+ type=str,
+ location="args",
+ required=False,
+ default=None,
+ )
+ parser.add_argument(
+ "created_by_account",
+ type=str,
+ location="args",
+ required=False,
+ default=None,
+ )
parser.add_argument("page", type=int_range(1, 99999), default=1, location="args")
parser.add_argument("limit", type=int_range(1, 100), default=20, location="args")
args = parser.parse_args()
@@ -57,6 +71,8 @@ class WorkflowAppLogApi(Resource):
created_at_after=args.created_at__after,
page=args.page,
limit=args.limit,
+ created_by_end_user_session_id=args.created_by_end_user_session_id,
+ created_by_account=args.created_by_account,
)
return workflow_app_log_pagination
diff --git a/api/controllers/console/app/workflow_draft_variable.py b/api/controllers/console/app/workflow_draft_variable.py
new file mode 100644
index 0000000000..00d6fa3cbf
--- /dev/null
+++ b/api/controllers/console/app/workflow_draft_variable.py
@@ -0,0 +1,421 @@
+import logging
+from typing import Any, NoReturn
+
+from flask import Response
+from flask_restful import Resource, fields, inputs, marshal, marshal_with, reqparse
+from sqlalchemy.orm import Session
+from werkzeug.exceptions import Forbidden
+
+from controllers.console import api
+from controllers.console.app.error import (
+ DraftWorkflowNotExist,
+)
+from controllers.console.app.wraps import get_app_model
+from controllers.console.wraps import account_initialization_required, setup_required
+from controllers.web.error import InvalidArgumentError, NotFoundError
+from core.variables.segment_group import SegmentGroup
+from core.variables.segments import ArrayFileSegment, FileSegment, Segment
+from core.variables.types import SegmentType
+from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
+from factories.file_factory import build_from_mapping, build_from_mappings
+from factories.variable_factory import build_segment_with_type
+from libs.login import current_user, login_required
+from models import App, AppMode, db
+from models.workflow import WorkflowDraftVariable
+from services.workflow_draft_variable_service import WorkflowDraftVariableList, WorkflowDraftVariableService
+from services.workflow_service import WorkflowService
+
+logger = logging.getLogger(__name__)
+
+
+def _convert_values_to_json_serializable_object(value: Segment) -> Any:
+ if isinstance(value, FileSegment):
+ return value.value.model_dump()
+ elif isinstance(value, ArrayFileSegment):
+ return [i.model_dump() for i in value.value]
+ elif isinstance(value, SegmentGroup):
+ return [_convert_values_to_json_serializable_object(i) for i in value.value]
+ else:
+ return value.value
+
+
+def _serialize_var_value(variable: WorkflowDraftVariable) -> Any:
+ value = variable.get_value()
+ # create a copy of the value to avoid affecting the model cache.
+ value = value.model_copy(deep=True)
+ # Refresh the url signature before returning it to client.
+ if isinstance(value, FileSegment):
+ file = value.value
+ file.remote_url = file.generate_url()
+ elif isinstance(value, ArrayFileSegment):
+ files = value.value
+ for file in files:
+ file.remote_url = file.generate_url()
+ return _convert_values_to_json_serializable_object(value)
+
+
+def _create_pagination_parser():
+ parser = reqparse.RequestParser()
+ parser.add_argument(
+ "page",
+ type=inputs.int_range(1, 100_000),
+ required=False,
+ default=1,
+ location="args",
+ help="the page of data requested",
+ )
+ parser.add_argument("limit", type=inputs.int_range(1, 100), required=False, default=20, location="args")
+ return parser
+
+
+_WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS = {
+ "id": fields.String,
+ "type": fields.String(attribute=lambda model: model.get_variable_type()),
+ "name": fields.String,
+ "description": fields.String,
+ "selector": fields.List(fields.String, attribute=lambda model: model.get_selector()),
+ "value_type": fields.String,
+ "edited": fields.Boolean(attribute=lambda model: model.edited),
+ "visible": fields.Boolean,
+}
+
+_WORKFLOW_DRAFT_VARIABLE_FIELDS = dict(
+ _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS,
+ value=fields.Raw(attribute=_serialize_var_value),
+)
+
+_WORKFLOW_DRAFT_ENV_VARIABLE_FIELDS = {
+ "id": fields.String,
+ "type": fields.String(attribute=lambda _: "env"),
+ "name": fields.String,
+ "description": fields.String,
+ "selector": fields.List(fields.String, attribute=lambda model: model.get_selector()),
+ "value_type": fields.String,
+ "edited": fields.Boolean(attribute=lambda model: model.edited),
+ "visible": fields.Boolean,
+}
+
+_WORKFLOW_DRAFT_ENV_VARIABLE_LIST_FIELDS = {
+ "items": fields.List(fields.Nested(_WORKFLOW_DRAFT_ENV_VARIABLE_FIELDS)),
+}
+
+
+def _get_items(var_list: WorkflowDraftVariableList) -> list[WorkflowDraftVariable]:
+ return var_list.variables
+
+
+_WORKFLOW_DRAFT_VARIABLE_LIST_WITHOUT_VALUE_FIELDS = {
+ "items": fields.List(fields.Nested(_WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS), attribute=_get_items),
+ "total": fields.Raw(),
+}
+
+_WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS = {
+ "items": fields.List(fields.Nested(_WORKFLOW_DRAFT_VARIABLE_FIELDS), attribute=_get_items),
+}
+
+
+def _api_prerequisite(f):
+ """Common prerequisites for all draft workflow variable APIs.
+
+ It ensures the following conditions are satisfied:
+
+ - Dify has been property setup.
+ - The request user has logged in and initialized.
+ - The requested app is a workflow or a chat flow.
+ - The request user has the edit permission for the app.
+ """
+
+ @setup_required
+ @login_required
+ @account_initialization_required
+ @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
+ def wrapper(*args, **kwargs):
+ if not current_user.is_editor:
+ raise Forbidden()
+ return f(*args, **kwargs)
+
+ return wrapper
+
+
+class WorkflowVariableCollectionApi(Resource):
+ @_api_prerequisite
+ @marshal_with(_WORKFLOW_DRAFT_VARIABLE_LIST_WITHOUT_VALUE_FIELDS)
+ def get(self, app_model: App):
+ """
+ Get draft workflow
+ """
+ parser = _create_pagination_parser()
+ args = parser.parse_args()
+
+ # fetch draft workflow by app_model
+ workflow_service = WorkflowService()
+ workflow_exist = workflow_service.is_workflow_exist(app_model=app_model)
+ if not workflow_exist:
+ raise DraftWorkflowNotExist()
+
+ # fetch draft workflow by app_model
+ with Session(bind=db.engine, expire_on_commit=False) as session:
+ draft_var_srv = WorkflowDraftVariableService(
+ session=session,
+ )
+ workflow_vars = draft_var_srv.list_variables_without_values(
+ app_id=app_model.id,
+ page=args.page,
+ limit=args.limit,
+ )
+
+ return workflow_vars
+
+ @_api_prerequisite
+ def delete(self, app_model: App):
+ draft_var_srv = WorkflowDraftVariableService(
+ session=db.session(),
+ )
+ draft_var_srv.delete_workflow_variables(app_model.id)
+ db.session.commit()
+ return Response("", 204)
+
+
+def validate_node_id(node_id: str) -> NoReturn | None:
+ if node_id in [
+ CONVERSATION_VARIABLE_NODE_ID,
+ SYSTEM_VARIABLE_NODE_ID,
+ ]:
+ # NOTE(QuantumGhost): While we store the system and conversation variables as node variables
+ # with specific `node_id` in database, we still want to make the API separated. By disallowing
+ # accessing system and conversation variables in `WorkflowDraftNodeVariableListApi`,
+ # we mitigate the risk that user of the API depending on the implementation detail of the API.
+ #
+ # ref: [Hyrum's Law](https://www.hyrumslaw.com/)
+
+ raise InvalidArgumentError(
+ f"invalid node_id, please use correspond api for conversation and system variables, node_id={node_id}",
+ )
+ return None
+
+
+class NodeVariableCollectionApi(Resource):
+ @_api_prerequisite
+ @marshal_with(_WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS)
+ def get(self, app_model: App, node_id: str):
+ validate_node_id(node_id)
+ with Session(bind=db.engine, expire_on_commit=False) as session:
+ draft_var_srv = WorkflowDraftVariableService(
+ session=session,
+ )
+ node_vars = draft_var_srv.list_node_variables(app_model.id, node_id)
+
+ return node_vars
+
+ @_api_prerequisite
+ def delete(self, app_model: App, node_id: str):
+ validate_node_id(node_id)
+ srv = WorkflowDraftVariableService(db.session())
+ srv.delete_node_variables(app_model.id, node_id)
+ db.session.commit()
+ return Response("", 204)
+
+
+class VariableApi(Resource):
+ _PATCH_NAME_FIELD = "name"
+ _PATCH_VALUE_FIELD = "value"
+
+ @_api_prerequisite
+ @marshal_with(_WORKFLOW_DRAFT_VARIABLE_FIELDS)
+ def get(self, app_model: App, variable_id: str):
+ draft_var_srv = WorkflowDraftVariableService(
+ session=db.session(),
+ )
+ variable = draft_var_srv.get_variable(variable_id=variable_id)
+ if variable is None:
+ raise NotFoundError(description=f"variable not found, id={variable_id}")
+ if variable.app_id != app_model.id:
+ raise NotFoundError(description=f"variable not found, id={variable_id}")
+ return variable
+
+ @_api_prerequisite
+ @marshal_with(_WORKFLOW_DRAFT_VARIABLE_FIELDS)
+ def patch(self, app_model: App, variable_id: str):
+ # Request payload for file types:
+ #
+ # Local File:
+ #
+ # {
+ # "type": "image",
+ # "transfer_method": "local_file",
+ # "url": "",
+ # "upload_file_id": "daded54f-72c7-4f8e-9d18-9b0abdd9f190"
+ # }
+ #
+ # Remote File:
+ #
+ #
+ # {
+ # "type": "image",
+ # "transfer_method": "remote_url",
+ # "url": "http://127.0.0.1:5001/files/1602650a-4fe4-423c-85a2-af76c083e3c4/file-preview?timestamp=1750041099&nonce=...&sign=...=",
+ # "upload_file_id": "1602650a-4fe4-423c-85a2-af76c083e3c4"
+ # }
+
+ parser = reqparse.RequestParser()
+ parser.add_argument(self._PATCH_NAME_FIELD, type=str, required=False, nullable=True, location="json")
+ # Parse 'value' field as-is to maintain its original data structure
+ parser.add_argument(self._PATCH_VALUE_FIELD, type=lambda x: x, required=False, nullable=True, location="json")
+
+ draft_var_srv = WorkflowDraftVariableService(
+ session=db.session(),
+ )
+ args = parser.parse_args(strict=True)
+
+ variable = draft_var_srv.get_variable(variable_id=variable_id)
+ if variable is None:
+ raise NotFoundError(description=f"variable not found, id={variable_id}")
+ if variable.app_id != app_model.id:
+ raise NotFoundError(description=f"variable not found, id={variable_id}")
+
+ new_name = args.get(self._PATCH_NAME_FIELD, None)
+ raw_value = args.get(self._PATCH_VALUE_FIELD, None)
+ if new_name is None and raw_value is None:
+ return variable
+
+ new_value = None
+ if raw_value is not None:
+ if variable.value_type == SegmentType.FILE:
+ if not isinstance(raw_value, dict):
+ raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}")
+ raw_value = build_from_mapping(mapping=raw_value, tenant_id=app_model.tenant_id)
+ elif variable.value_type == SegmentType.ARRAY_FILE:
+ if not isinstance(raw_value, list):
+ raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}")
+ if len(raw_value) > 0 and not isinstance(raw_value[0], dict):
+ raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}")
+ raw_value = build_from_mappings(mappings=raw_value, tenant_id=app_model.tenant_id)
+ new_value = build_segment_with_type(variable.value_type, raw_value)
+ draft_var_srv.update_variable(variable, name=new_name, value=new_value)
+ db.session.commit()
+ return variable
+
+ @_api_prerequisite
+ def delete(self, app_model: App, variable_id: str):
+ draft_var_srv = WorkflowDraftVariableService(
+ session=db.session(),
+ )
+ variable = draft_var_srv.get_variable(variable_id=variable_id)
+ if variable is None:
+ raise NotFoundError(description=f"variable not found, id={variable_id}")
+ if variable.app_id != app_model.id:
+ raise NotFoundError(description=f"variable not found, id={variable_id}")
+ draft_var_srv.delete_variable(variable)
+ db.session.commit()
+ return Response("", 204)
+
+
+class VariableResetApi(Resource):
+ @_api_prerequisite
+ def put(self, app_model: App, variable_id: str):
+ draft_var_srv = WorkflowDraftVariableService(
+ session=db.session(),
+ )
+
+ workflow_srv = WorkflowService()
+ draft_workflow = workflow_srv.get_draft_workflow(app_model)
+ if draft_workflow is None:
+ raise NotFoundError(
+ f"Draft workflow not found, app_id={app_model.id}",
+ )
+ variable = draft_var_srv.get_variable(variable_id=variable_id)
+ if variable is None:
+ raise NotFoundError(description=f"variable not found, id={variable_id}")
+ if variable.app_id != app_model.id:
+ raise NotFoundError(description=f"variable not found, id={variable_id}")
+
+ resetted = draft_var_srv.reset_variable(draft_workflow, variable)
+ db.session.commit()
+ if resetted is None:
+ return Response("", 204)
+ else:
+ return marshal(resetted, _WORKFLOW_DRAFT_VARIABLE_FIELDS)
+
+
+def _get_variable_list(app_model: App, node_id) -> WorkflowDraftVariableList:
+ with Session(bind=db.engine, expire_on_commit=False) as session:
+ draft_var_srv = WorkflowDraftVariableService(
+ session=session,
+ )
+ if node_id == CONVERSATION_VARIABLE_NODE_ID:
+ draft_vars = draft_var_srv.list_conversation_variables(app_model.id)
+ elif node_id == SYSTEM_VARIABLE_NODE_ID:
+ draft_vars = draft_var_srv.list_system_variables(app_model.id)
+ else:
+ draft_vars = draft_var_srv.list_node_variables(app_id=app_model.id, node_id=node_id)
+ return draft_vars
+
+
+class ConversationVariableCollectionApi(Resource):
+ @_api_prerequisite
+ @marshal_with(_WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS)
+ def get(self, app_model: App):
+ # NOTE(QuantumGhost): Prefill conversation variables into the draft variables table
+ # so their IDs can be returned to the caller.
+ workflow_srv = WorkflowService()
+ draft_workflow = workflow_srv.get_draft_workflow(app_model)
+ if draft_workflow is None:
+ raise NotFoundError(description=f"draft workflow not found, id={app_model.id}")
+ draft_var_srv = WorkflowDraftVariableService(db.session())
+ draft_var_srv.prefill_conversation_variable_default_values(draft_workflow)
+ db.session.commit()
+ return _get_variable_list(app_model, CONVERSATION_VARIABLE_NODE_ID)
+
+
+class SystemVariableCollectionApi(Resource):
+ @_api_prerequisite
+ @marshal_with(_WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS)
+ def get(self, app_model: App):
+ return _get_variable_list(app_model, SYSTEM_VARIABLE_NODE_ID)
+
+
+class EnvironmentVariableCollectionApi(Resource):
+ @_api_prerequisite
+ def get(self, app_model: App):
+ """
+ Get draft workflow
+ """
+ # fetch draft workflow by app_model
+ workflow_service = WorkflowService()
+ workflow = workflow_service.get_draft_workflow(app_model=app_model)
+ if workflow is None:
+ raise DraftWorkflowNotExist()
+
+ env_vars = workflow.environment_variables
+ env_vars_list = []
+ for v in env_vars:
+ env_vars_list.append(
+ {
+ "id": v.id,
+ "type": "env",
+ "name": v.name,
+ "description": v.description,
+ "selector": v.selector,
+ "value_type": v.value_type.value,
+ "value": v.value,
+ # Do not track edited for env vars.
+ "edited": False,
+ "visible": True,
+ "editable": True,
+ }
+ )
+
+ return {"items": env_vars_list}
+
+
+api.add_resource(
+ WorkflowVariableCollectionApi,
+ "/apps//workflows/draft/variables",
+)
+api.add_resource(NodeVariableCollectionApi, "/apps//workflows/draft/nodes//variables")
+api.add_resource(VariableApi, "/apps//workflows/draft/variables/")
+api.add_resource(VariableResetApi, "/apps//workflows/draft/variables//reset")
+
+api.add_resource(ConversationVariableCollectionApi, "/apps//workflows/draft/conversation-variables")
+api.add_resource(SystemVariableCollectionApi, "/apps//workflows/draft/system-variables")
+api.add_resource(EnvironmentVariableCollectionApi, "/apps//workflows/draft/environment-variables")
diff --git a/api/controllers/console/app/wraps.py b/api/controllers/console/app/wraps.py
index 9ad8c15847..3322350e25 100644
--- a/api/controllers/console/app/wraps.py
+++ b/api/controllers/console/app/wraps.py
@@ -8,6 +8,15 @@ from libs.login import current_user
from models import App, AppMode
+def _load_app_model(app_id: str) -> Optional[App]:
+ app_model = (
+ db.session.query(App)
+ .filter(App.id == app_id, App.tenant_id == current_user.current_tenant_id, App.status == "normal")
+ .first()
+ )
+ return app_model
+
+
def get_app_model(view: Optional[Callable] = None, *, mode: Union[AppMode, list[AppMode], None] = None):
def decorator(view_func):
@wraps(view_func)
@@ -20,18 +29,12 @@ def get_app_model(view: Optional[Callable] = None, *, mode: Union[AppMode, list[
del kwargs["app_id"]
- app_model = (
- db.session.query(App)
- .filter(App.id == app_id, App.tenant_id == current_user.current_tenant_id, App.status == "normal")
- .first()
- )
+ app_model = _load_app_model(app_id)
if not app_model:
raise AppNotFoundError()
app_mode = AppMode.value_of(app_model.mode)
- if app_mode == AppMode.CHANNEL:
- raise AppNotFoundError()
if mode is not None:
if isinstance(mode, list):
diff --git a/api/controllers/console/auth/data_source_oauth.py b/api/controllers/console/auth/data_source_oauth.py
index 1049f864c3..4c9697cc32 100644
--- a/api/controllers/console/auth/data_source_oauth.py
+++ b/api/controllers/console/auth/data_source_oauth.py
@@ -41,7 +41,7 @@ class OAuthDataSource(Resource):
if not internal_secret:
return ({"error": "Internal secret is not set"},)
oauth_provider.save_internal_access_token(internal_secret)
- return {"data": ""}
+ return {"data": "internal"}
else:
auth_url = oauth_provider.get_authorization_url()
return {"data": auth_url}, 200
diff --git a/api/controllers/console/datasets/datasets.py b/api/controllers/console/datasets/datasets.py
index e68273afa6..1611214cb3 100644
--- a/api/controllers/console/datasets/datasets.py
+++ b/api/controllers/console/datasets/datasets.py
@@ -686,6 +686,7 @@ class DatasetRetrievalSettingApi(Resource):
| VectorType.TABLESTORE
| VectorType.HUAWEI_CLOUD
| VectorType.TENCENT
+ | VectorType.MATRIXONE
):
return {
"retrieval_method": [
@@ -733,6 +734,7 @@ class DatasetRetrievalSettingMockApi(Resource):
| VectorType.TABLESTORE
| VectorType.TENCENT
| VectorType.HUAWEI_CLOUD
+ | VectorType.MATRIXONE
):
return {
"retrieval_method": [
diff --git a/api/controllers/console/datasets/datasets_document.py b/api/controllers/console/datasets/datasets_document.py
index f7c04102a9..b2fcf3ce7b 100644
--- a/api/controllers/console/datasets/datasets_document.py
+++ b/api/controllers/console/datasets/datasets_document.py
@@ -5,7 +5,7 @@ from typing import cast
from flask import request
from flask_login import current_user
-from flask_restful import Resource, fields, marshal, marshal_with, reqparse
+from flask_restful import Resource, marshal, marshal_with, reqparse
from sqlalchemy import asc, desc, select
from werkzeug.exceptions import Forbidden, NotFound
@@ -43,7 +43,6 @@ from core.model_runtime.errors.invoke import InvokeAuthorizationError
from core.plugin.impl.exc import PluginDaemonClientSideError
from core.rag.extractor.entity.extract_setting import ExtractSetting
from extensions.ext_database import db
-from extensions.ext_redis import redis_client
from fields.document_fields import (
dataset_and_document_fields,
document_fields,
@@ -54,8 +53,6 @@ from libs.login import login_required
from models import Dataset, DatasetProcessRule, Document, DocumentSegment, UploadFile
from services.dataset_service import DatasetService, DocumentService
from services.entities.knowledge_entities.knowledge_entities import KnowledgeConfig
-from tasks.add_document_to_index_task import add_document_to_index_task
-from tasks.remove_document_from_index_task import remove_document_from_index_task
class DocumentResource(Resource):
@@ -242,12 +239,10 @@ class DatasetDocumentListApi(Resource):
return response
- documents_and_batch_fields = {"documents": fields.List(fields.Nested(document_fields)), "batch": fields.String}
-
@setup_required
@login_required
@account_initialization_required
- @marshal_with(documents_and_batch_fields)
+ @marshal_with(dataset_and_document_fields)
@cloud_edition_billing_resource_check("vector_space")
@cloud_edition_billing_rate_limit_check("knowledge")
def post(self, dataset_id):
@@ -293,6 +288,8 @@ class DatasetDocumentListApi(Resource):
try:
documents, batch = DocumentService.save_document_with_dataset_id(dataset, knowledge_config, current_user)
+ dataset = DatasetService.get_dataset(dataset_id)
+
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@@ -300,7 +297,7 @@ class DatasetDocumentListApi(Resource):
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
- return {"documents": documents, "batch": batch}
+ return {"dataset": dataset, "documents": documents, "batch": batch}
@setup_required
@login_required
@@ -862,77 +859,16 @@ class DocumentStatusApi(DocumentResource):
DatasetService.check_dataset_permission(dataset, current_user)
document_ids = request.args.getlist("document_id")
- for document_id in document_ids:
- document = self.get_document(dataset_id, document_id)
-
- indexing_cache_key = "document_{}_indexing".format(document.id)
- cache_result = redis_client.get(indexing_cache_key)
- if cache_result is not None:
- raise InvalidActionError(f"Document:{document.name} is being indexed, please try again later")
-
- if action == "enable":
- if document.enabled:
- continue
- document.enabled = True
- document.disabled_at = None
- document.disabled_by = None
- document.updated_at = datetime.now(UTC).replace(tzinfo=None)
- db.session.commit()
-
- # Set cache to prevent indexing the same document multiple times
- redis_client.setex(indexing_cache_key, 600, 1)
-
- add_document_to_index_task.delay(document_id)
-
- elif action == "disable":
- if not document.completed_at or document.indexing_status != "completed":
- raise InvalidActionError(f"Document: {document.name} is not completed.")
- if not document.enabled:
- continue
-
- document.enabled = False
- document.disabled_at = datetime.now(UTC).replace(tzinfo=None)
- document.disabled_by = current_user.id
- document.updated_at = datetime.now(UTC).replace(tzinfo=None)
- db.session.commit()
-
- # Set cache to prevent indexing the same document multiple times
- redis_client.setex(indexing_cache_key, 600, 1)
-
- remove_document_from_index_task.delay(document_id)
-
- elif action == "archive":
- if document.archived:
- continue
-
- document.archived = True
- document.archived_at = datetime.now(UTC).replace(tzinfo=None)
- document.archived_by = current_user.id
- document.updated_at = datetime.now(UTC).replace(tzinfo=None)
- db.session.commit()
-
- if document.enabled:
- # Set cache to prevent indexing the same document multiple times
- redis_client.setex(indexing_cache_key, 600, 1)
-
- remove_document_from_index_task.delay(document_id)
-
- elif action == "un_archive":
- if not document.archived:
- continue
- document.archived = False
- document.archived_at = None
- document.archived_by = None
- document.updated_at = datetime.now(UTC).replace(tzinfo=None)
- db.session.commit()
-
- # Set cache to prevent indexing the same document multiple times
- redis_client.setex(indexing_cache_key, 600, 1)
-
- add_document_to_index_task.delay(document_id)
- else:
- raise InvalidActionError()
+ try:
+ DocumentService.batch_update_document_status(dataset, document_ids, action, current_user)
+ except services.errors.document.DocumentIndexingError as e:
+ raise InvalidActionError(str(e))
+ except ValueError as e:
+ raise InvalidActionError(str(e))
+ except NotFound as e:
+ raise NotFound(str(e))
+
return {"result": "success"}, 200
diff --git a/api/controllers/console/datasets/datasets_segments.py b/api/controllers/console/datasets/datasets_segments.py
index bc37907a30..48142dbe73 100644
--- a/api/controllers/console/datasets/datasets_segments.py
+++ b/api/controllers/console/datasets/datasets_segments.py
@@ -374,7 +374,7 @@ class DatasetDocumentSegmentBatchImportApi(Resource):
if len(request.files) > 1:
raise TooManyFilesError()
# check file type
- if not file.filename or not file.filename.endswith(".csv"):
+ if not file.filename or not file.filename.lower().endswith(".csv"):
raise ValueError("Invalid file type. Only CSV files are allowed")
try:
diff --git a/api/controllers/console/explore/audio.py b/api/controllers/console/explore/audio.py
index 54bc590677..d564a00a76 100644
--- a/api/controllers/console/explore/audio.py
+++ b/api/controllers/console/explore/audio.py
@@ -18,7 +18,6 @@ from controllers.console.app.error import (
from controllers.console.explore.wraps import InstalledAppResource
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from core.model_runtime.errors.invoke import InvokeError
-from models.model import AppMode
from services.audio_service import AudioService
from services.errors.audio import (
AudioTooLargeServiceError,
@@ -79,19 +78,9 @@ class ChatTextApi(InstalledAppResource):
message_id = args.get("message_id", None)
text = args.get("text", None)
- if (
- app_model.mode in {AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value}
- and app_model.workflow
- and app_model.workflow.features_dict
- ):
- text_to_speech = app_model.workflow.features_dict.get("text_to_speech")
- voice = args.get("voice") or text_to_speech.get("voice")
- else:
- try:
- voice = args.get("voice") or app_model.app_model_config.text_to_speech_dict.get("voice")
- except Exception:
- voice = None
- response = AudioService.transcript_tts(app_model=app_model, message_id=message_id, voice=voice, text=text)
+ voice = args.get("voice", None)
+
+ response = AudioService.transcript_tts(app_model=app_model, text=text, voice=voice, message_id=message_id)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logging.exception("App model config broken.")
diff --git a/api/controllers/console/version.py b/api/controllers/console/version.py
index 7dea8e554e..447cc358f8 100644
--- a/api/controllers/console/version.py
+++ b/api/controllers/console/version.py
@@ -18,7 +18,7 @@ class VersionApi(Resource):
check_update_url = dify_config.CHECK_UPDATE_URL
result = {
- "version": dify_config.CURRENT_VERSION,
+ "version": dify_config.project.version,
"release_date": "",
"release_notes": "",
"can_auto_update": False,
diff --git a/api/controllers/console/workspace/load_balancing_config.py b/api/controllers/console/workspace/load_balancing_config.py
index ba74e2c074..b4eb5e246b 100644
--- a/api/controllers/console/workspace/load_balancing_config.py
+++ b/api/controllers/console/workspace/load_balancing_config.py
@@ -15,7 +15,7 @@ class LoadBalancingCredentialsValidateApi(Resource):
@login_required
@account_initialization_required
def post(self, provider: str):
- if not TenantAccountRole.is_privileged_role(current_user.current_tenant.current_role):
+ if not TenantAccountRole.is_privileged_role(current_user.current_role):
raise Forbidden()
tenant_id = current_user.current_tenant_id
@@ -64,7 +64,7 @@ class LoadBalancingConfigCredentialsValidateApi(Resource):
@login_required
@account_initialization_required
def post(self, provider: str, config_id: str):
- if not TenantAccountRole.is_privileged_role(current_user.current_tenant.current_role):
+ if not TenantAccountRole.is_privileged_role(current_user.current_role):
raise Forbidden()
tenant_id = current_user.current_tenant_id
diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py
index db49da7840..48225ac90d 100644
--- a/api/controllers/console/workspace/members.py
+++ b/api/controllers/console/workspace/members.py
@@ -85,6 +85,7 @@ class MemberInviteEmailApi(Resource):
return {
"result": "success",
"invitation_results": invitation_results,
+ "tenant_id": str(current_user.current_tenant.id),
}, 201
@@ -110,7 +111,7 @@ class MemberCancelInviteApi(Resource):
except Exception as e:
raise ValueError(str(e))
- return {"result": "success"}, 204
+ return {"result": "success", "tenant_id": str(current_user.current_tenant.id)}, 200
class MemberUpdateRoleApi(Resource):
diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py
index 9bddbb4b4b..c0a4734828 100644
--- a/api/controllers/console/workspace/plugin.py
+++ b/api/controllers/console/workspace/plugin.py
@@ -13,6 +13,7 @@ from core.model_runtime.utils.encoders import jsonable_encoder
from core.plugin.impl.exc import PluginDaemonClientSideError
from libs.login import login_required
from models.account import TenantPluginPermission
+from services.plugin.plugin_parameter_service import PluginParameterService
from services.plugin.plugin_permission_service import PluginPermissionService
from services.plugin.plugin_service import PluginService
@@ -497,6 +498,42 @@ class PluginFetchPermissionApi(Resource):
)
+class PluginFetchDynamicSelectOptionsApi(Resource):
+ @setup_required
+ @login_required
+ @account_initialization_required
+ def get(self):
+ # check if the user is admin or owner
+ if not current_user.is_admin_or_owner:
+ raise Forbidden()
+
+ tenant_id = current_user.current_tenant_id
+ user_id = current_user.id
+
+ parser = reqparse.RequestParser()
+ parser.add_argument("plugin_id", type=str, required=True, location="args")
+ parser.add_argument("provider", type=str, required=True, location="args")
+ parser.add_argument("action", type=str, required=True, location="args")
+ parser.add_argument("parameter", type=str, required=True, location="args")
+ parser.add_argument("provider_type", type=str, required=True, location="args")
+ args = parser.parse_args()
+
+ try:
+ options = PluginParameterService.get_dynamic_select_options(
+ tenant_id,
+ user_id,
+ args["plugin_id"],
+ args["provider"],
+ args["action"],
+ args["parameter"],
+ args["provider_type"],
+ )
+ except PluginDaemonClientSideError as e:
+ raise ValueError(e)
+
+ return jsonable_encoder({"options": options})
+
+
api.add_resource(PluginDebuggingKeyApi, "/workspaces/current/plugin/debugging-key")
api.add_resource(PluginListApi, "/workspaces/current/plugin/list")
api.add_resource(PluginListLatestVersionsApi, "/workspaces/current/plugin/list/latest-versions")
@@ -521,3 +558,5 @@ api.add_resource(PluginFetchMarketplacePkgApi, "/workspaces/current/plugin/marke
api.add_resource(PluginChangePermissionApi, "/workspaces/current/plugin/permission/change")
api.add_resource(PluginFetchPermissionApi, "/workspaces/current/plugin/permission/fetch")
+
+api.add_resource(PluginFetchDynamicSelectOptionsApi, "/workspaces/current/plugin/parameters/dynamic-options")
diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py
index 2b1379bfb2..df50871a38 100644
--- a/api/controllers/console/workspace/tool_providers.py
+++ b/api/controllers/console/workspace/tool_providers.py
@@ -1,6 +1,7 @@
import io
+from urllib.parse import urlparse
-from flask import send_file
+from flask import redirect, send_file
from flask_login import current_user
from flask_restful import Resource, reqparse
from sqlalchemy.orm import Session
@@ -9,17 +10,34 @@ from werkzeug.exceptions import Forbidden
from configs import dify_config
from controllers.console import api
from controllers.console.wraps import account_initialization_required, enterprise_license_required, setup_required
+from core.mcp.auth.auth_flow import auth, handle_callback
+from core.mcp.auth.auth_provider import OAuthClientProvider
+from core.mcp.error import MCPAuthError, MCPError
+from core.mcp.mcp_client import MCPClient
from core.model_runtime.utils.encoders import jsonable_encoder
from extensions.ext_database import db
from libs.helper import alphanumeric, uuid_value
from libs.login import login_required
from services.tools.api_tools_manage_service import ApiToolManageService
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
+from services.tools.mcp_tools_mange_service import MCPToolManageService
from services.tools.tool_labels_service import ToolLabelsService
from services.tools.tools_manage_service import ToolCommonService
+from services.tools.tools_transform_service import ToolTransformService
from services.tools.workflow_tools_manage_service import WorkflowToolManageService
+def is_valid_url(url: str) -> bool:
+ if not url:
+ return False
+
+ try:
+ parsed = urlparse(url)
+ return all([parsed.scheme, parsed.netloc]) and parsed.scheme in ["http", "https"]
+ except Exception:
+ return False
+
+
class ToolProviderListApi(Resource):
@setup_required
@login_required
@@ -34,7 +52,7 @@ class ToolProviderListApi(Resource):
req.add_argument(
"type",
type=str,
- choices=["builtin", "model", "api", "workflow"],
+ choices=["builtin", "model", "api", "workflow", "mcp"],
required=False,
nullable=True,
location="args",
@@ -613,6 +631,166 @@ class ToolLabelsApi(Resource):
return jsonable_encoder(ToolLabelsService.list_tool_labels())
+class ToolProviderMCPApi(Resource):
+ @setup_required
+ @login_required
+ @account_initialization_required
+ def post(self):
+ parser = reqparse.RequestParser()
+ parser.add_argument("server_url", type=str, required=True, nullable=False, location="json")
+ parser.add_argument("name", type=str, required=True, nullable=False, location="json")
+ parser.add_argument("icon", type=str, required=True, nullable=False, location="json")
+ parser.add_argument("icon_type", type=str, required=True, nullable=False, location="json")
+ parser.add_argument("icon_background", type=str, required=False, nullable=True, location="json", default="")
+ parser.add_argument("server_identifier", type=str, required=True, nullable=False, location="json")
+ args = parser.parse_args()
+ user = current_user
+ if not is_valid_url(args["server_url"]):
+ raise ValueError("Server URL is not valid.")
+ return jsonable_encoder(
+ MCPToolManageService.create_mcp_provider(
+ tenant_id=user.current_tenant_id,
+ server_url=args["server_url"],
+ name=args["name"],
+ icon=args["icon"],
+ icon_type=args["icon_type"],
+ icon_background=args["icon_background"],
+ user_id=user.id,
+ server_identifier=args["server_identifier"],
+ )
+ )
+
+ @setup_required
+ @login_required
+ @account_initialization_required
+ def put(self):
+ parser = reqparse.RequestParser()
+ parser.add_argument("server_url", type=str, required=True, nullable=False, location="json")
+ parser.add_argument("name", type=str, required=True, nullable=False, location="json")
+ parser.add_argument("icon", type=str, required=True, nullable=False, location="json")
+ parser.add_argument("icon_type", type=str, required=True, nullable=False, location="json")
+ parser.add_argument("icon_background", type=str, required=False, nullable=True, location="json")
+ parser.add_argument("provider_id", type=str, required=True, nullable=False, location="json")
+ parser.add_argument("server_identifier", type=str, required=True, nullable=False, location="json")
+ args = parser.parse_args()
+ if not is_valid_url(args["server_url"]):
+ if "[__HIDDEN__]" in args["server_url"]:
+ pass
+ else:
+ raise ValueError("Server URL is not valid.")
+ MCPToolManageService.update_mcp_provider(
+ tenant_id=current_user.current_tenant_id,
+ provider_id=args["provider_id"],
+ server_url=args["server_url"],
+ name=args["name"],
+ icon=args["icon"],
+ icon_type=args["icon_type"],
+ icon_background=args["icon_background"],
+ server_identifier=args["server_identifier"],
+ )
+ return {"result": "success"}
+
+ @setup_required
+ @login_required
+ @account_initialization_required
+ def delete(self):
+ parser = reqparse.RequestParser()
+ parser.add_argument("provider_id", type=str, required=True, nullable=False, location="json")
+ args = parser.parse_args()
+ MCPToolManageService.delete_mcp_tool(tenant_id=current_user.current_tenant_id, provider_id=args["provider_id"])
+ return {"result": "success"}
+
+
+class ToolMCPAuthApi(Resource):
+ @setup_required
+ @login_required
+ @account_initialization_required
+ def post(self):
+ parser = reqparse.RequestParser()
+ parser.add_argument("provider_id", type=str, required=True, nullable=False, location="json")
+ parser.add_argument("authorization_code", type=str, required=False, nullable=True, location="json")
+ args = parser.parse_args()
+ provider_id = args["provider_id"]
+ tenant_id = current_user.current_tenant_id
+ provider = MCPToolManageService.get_mcp_provider_by_provider_id(provider_id, tenant_id)
+ if not provider:
+ raise ValueError("provider not found")
+ try:
+ with MCPClient(
+ provider.decrypted_server_url,
+ provider_id,
+ tenant_id,
+ authed=False,
+ authorization_code=args["authorization_code"],
+ for_list=True,
+ ):
+ MCPToolManageService.update_mcp_provider_credentials(
+ mcp_provider=provider,
+ credentials=provider.decrypted_credentials,
+ authed=True,
+ )
+ return {"result": "success"}
+
+ except MCPAuthError:
+ auth_provider = OAuthClientProvider(provider_id, tenant_id, for_list=True)
+ return auth(auth_provider, provider.decrypted_server_url, args["authorization_code"])
+ except MCPError as e:
+ MCPToolManageService.update_mcp_provider_credentials(
+ mcp_provider=provider,
+ credentials={},
+ authed=False,
+ )
+ raise ValueError(f"Failed to connect to MCP server: {e}") from e
+
+
+class ToolMCPDetailApi(Resource):
+ @setup_required
+ @login_required
+ @account_initialization_required
+ def get(self, provider_id):
+ user = current_user
+ provider = MCPToolManageService.get_mcp_provider_by_provider_id(provider_id, user.current_tenant_id)
+ return jsonable_encoder(ToolTransformService.mcp_provider_to_user_provider(provider, for_list=True))
+
+
+class ToolMCPListAllApi(Resource):
+ @setup_required
+ @login_required
+ @account_initialization_required
+ def get(self):
+ user = current_user
+ tenant_id = user.current_tenant_id
+
+ tools = MCPToolManageService.retrieve_mcp_tools(tenant_id=tenant_id)
+
+ return [tool.to_dict() for tool in tools]
+
+
+class ToolMCPUpdateApi(Resource):
+ @setup_required
+ @login_required
+ @account_initialization_required
+ def get(self, provider_id):
+ tenant_id = current_user.current_tenant_id
+ tools = MCPToolManageService.list_mcp_tool_from_remote_server(
+ tenant_id=tenant_id,
+ provider_id=provider_id,
+ )
+ return jsonable_encoder(tools)
+
+
+class ToolMCPCallbackApi(Resource):
+ def get(self):
+ parser = reqparse.RequestParser()
+ parser.add_argument("code", type=str, required=True, nullable=False, location="args")
+ parser.add_argument("state", type=str, required=True, nullable=False, location="args")
+ args = parser.parse_args()
+ state_key = args["state"]
+ authorization_code = args["code"]
+ handle_callback(state_key, authorization_code)
+ return redirect(f"{dify_config.CONSOLE_WEB_URL}/oauth-callback")
+
+
# tool provider
api.add_resource(ToolProviderListApi, "/workspaces/current/tool-providers")
@@ -647,8 +825,15 @@ api.add_resource(ToolWorkflowProviderDeleteApi, "/workspaces/current/tool-provid
api.add_resource(ToolWorkflowProviderGetApi, "/workspaces/current/tool-provider/workflow/get")
api.add_resource(ToolWorkflowProviderListToolApi, "/workspaces/current/tool-provider/workflow/tools")
+# mcp tool provider
+api.add_resource(ToolMCPDetailApi, "/workspaces/current/tool-provider/mcp/tools/")
+api.add_resource(ToolProviderMCPApi, "/workspaces/current/tool-provider/mcp")
+api.add_resource(ToolMCPUpdateApi, "/workspaces/current/tool-provider/mcp/update/")
+api.add_resource(ToolMCPAuthApi, "/workspaces/current/tool-provider/mcp/auth")
+api.add_resource(ToolMCPCallbackApi, "/mcp/oauth/callback")
+
api.add_resource(ToolBuiltinListApi, "/workspaces/current/tools/builtin")
api.add_resource(ToolApiListApi, "/workspaces/current/tools/api")
+api.add_resource(ToolMCPListAllApi, "/workspaces/current/tools/mcp")
api.add_resource(ToolWorkflowListApi, "/workspaces/current/tools/workflow")
-
api.add_resource(ToolLabelsApi, "/workspaces/current/tool-labels")
diff --git a/api/controllers/files/upload.py b/api/controllers/files/upload.py
index f1a15793c7..15f93d2774 100644
--- a/api/controllers/files/upload.py
+++ b/api/controllers/files/upload.py
@@ -87,7 +87,5 @@ class PluginUploadFileApi(Resource):
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
- return tool_file, 201
-
api.add_resource(PluginUploadFileApi, "/files/upload/for-plugin")
diff --git a/api/controllers/inner_api/plugin/plugin.py b/api/controllers/inner_api/plugin/plugin.py
index 41063b35a5..327e9ce834 100644
--- a/api/controllers/inner_api/plugin/plugin.py
+++ b/api/controllers/inner_api/plugin/plugin.py
@@ -17,6 +17,7 @@ from core.plugin.entities.request import (
RequestInvokeApp,
RequestInvokeEncrypt,
RequestInvokeLLM,
+ RequestInvokeLLMWithStructuredOutput,
RequestInvokeModeration,
RequestInvokeParameterExtractorNode,
RequestInvokeQuestionClassifierNode,
@@ -47,6 +48,21 @@ class PluginInvokeLLMApi(Resource):
return length_prefixed_response(0xF, generator())
+class PluginInvokeLLMWithStructuredOutputApi(Resource):
+ @setup_required
+ @plugin_inner_api_only
+ @get_user_tenant
+ @plugin_data(payload_type=RequestInvokeLLMWithStructuredOutput)
+ def post(self, user_model: Account | EndUser, tenant_model: Tenant, payload: RequestInvokeLLMWithStructuredOutput):
+ def generator():
+ response = PluginModelBackwardsInvocation.invoke_llm_with_structured_output(
+ user_model.id, tenant_model, payload
+ )
+ return PluginModelBackwardsInvocation.convert_to_event_stream(response)
+
+ return length_prefixed_response(0xF, generator())
+
+
class PluginInvokeTextEmbeddingApi(Resource):
@setup_required
@plugin_inner_api_only
@@ -291,6 +307,7 @@ class PluginFetchAppInfoApi(Resource):
api.add_resource(PluginInvokeLLMApi, "/invoke/llm")
+api.add_resource(PluginInvokeLLMWithStructuredOutputApi, "/invoke/llm/structured-output")
api.add_resource(PluginInvokeTextEmbeddingApi, "/invoke/text-embedding")
api.add_resource(PluginInvokeRerankApi, "/invoke/rerank")
api.add_resource(PluginInvokeTTSApi, "/invoke/tts")
diff --git a/api/controllers/inner_api/workspace/workspace.py b/api/controllers/inner_api/workspace/workspace.py
index a2fc2d4675..77568b75f1 100644
--- a/api/controllers/inner_api/workspace/workspace.py
+++ b/api/controllers/inner_api/workspace/workspace.py
@@ -29,7 +29,19 @@ class EnterpriseWorkspace(Resource):
tenant_was_created.send(tenant)
- return {"message": "enterprise workspace created."}
+ resp = {
+ "id": tenant.id,
+ "name": tenant.name,
+ "plan": tenant.plan,
+ "status": tenant.status,
+ "created_at": tenant.created_at.isoformat() + "Z" if tenant.created_at else None,
+ "updated_at": tenant.updated_at.isoformat() + "Z" if tenant.updated_at else None,
+ }
+
+ return {
+ "message": "enterprise workspace created.",
+ "tenant": resp,
+ }
class EnterpriseWorkspaceNoOwnerEmail(Resource):
diff --git a/api/controllers/mcp/__init__.py b/api/controllers/mcp/__init__.py
new file mode 100644
index 0000000000..1b3e0a5621
--- /dev/null
+++ b/api/controllers/mcp/__init__.py
@@ -0,0 +1,8 @@
+from flask import Blueprint
+
+from libs.external_api import ExternalApi
+
+bp = Blueprint("mcp", __name__, url_prefix="/mcp")
+api = ExternalApi(bp)
+
+from . import mcp
diff --git a/api/controllers/mcp/mcp.py b/api/controllers/mcp/mcp.py
new file mode 100644
index 0000000000..ead728bfb0
--- /dev/null
+++ b/api/controllers/mcp/mcp.py
@@ -0,0 +1,104 @@
+from flask_restful import Resource, reqparse
+from pydantic import ValidationError
+
+from controllers.console.app.mcp_server import AppMCPServerStatus
+from controllers.mcp import api
+from core.app.app_config.entities import VariableEntity
+from core.mcp import types
+from core.mcp.server.streamable_http import MCPServerStreamableHTTPRequestHandler
+from core.mcp.types import ClientNotification, ClientRequest
+from core.mcp.utils import create_mcp_error_response
+from extensions.ext_database import db
+from libs import helper
+from models.model import App, AppMCPServer, AppMode
+
+
+class MCPAppApi(Resource):
+ def post(self, server_code):
+ def int_or_str(value):
+ if isinstance(value, (int, str)):
+ return value
+ else:
+ return None
+
+ parser = reqparse.RequestParser()
+ parser.add_argument("jsonrpc", type=str, required=True, location="json")
+ parser.add_argument("method", type=str, required=True, location="json")
+ parser.add_argument("params", type=dict, required=False, location="json")
+ parser.add_argument("id", type=int_or_str, required=False, location="json")
+ args = parser.parse_args()
+
+ request_id = args.get("id")
+
+ server = db.session.query(AppMCPServer).filter(AppMCPServer.server_code == server_code).first()
+ if not server:
+ return helper.compact_generate_response(
+ create_mcp_error_response(request_id, types.INVALID_REQUEST, "Server Not Found")
+ )
+
+ if server.status != AppMCPServerStatus.ACTIVE:
+ return helper.compact_generate_response(
+ create_mcp_error_response(request_id, types.INVALID_REQUEST, "Server is not active")
+ )
+
+ app = db.session.query(App).filter(App.id == server.app_id).first()
+ if not app:
+ return helper.compact_generate_response(
+ create_mcp_error_response(request_id, types.INVALID_REQUEST, "App Not Found")
+ )
+
+ if app.mode in {AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value}:
+ workflow = app.workflow
+ if workflow is None:
+ return helper.compact_generate_response(
+ create_mcp_error_response(request_id, types.INVALID_REQUEST, "App is unavailable")
+ )
+
+ user_input_form = workflow.user_input_form(to_old_structure=True)
+ else:
+ app_model_config = app.app_model_config
+ if app_model_config is None:
+ return helper.compact_generate_response(
+ create_mcp_error_response(request_id, types.INVALID_REQUEST, "App is unavailable")
+ )
+
+ features_dict = app_model_config.to_dict()
+ user_input_form = features_dict.get("user_input_form", [])
+ converted_user_input_form: list[VariableEntity] = []
+ try:
+ for item in user_input_form:
+ variable_type = item.get("type", "") or list(item.keys())[0]
+ variable = item[variable_type]
+ converted_user_input_form.append(
+ VariableEntity(
+ type=variable_type,
+ variable=variable.get("variable"),
+ description=variable.get("description") or "",
+ label=variable.get("label"),
+ required=variable.get("required", False),
+ max_length=variable.get("max_length"),
+ options=variable.get("options") or [],
+ )
+ )
+ except ValidationError as e:
+ return helper.compact_generate_response(
+ create_mcp_error_response(request_id, types.INVALID_PARAMS, f"Invalid user_input_form: {str(e)}")
+ )
+
+ try:
+ request: ClientRequest | ClientNotification = ClientRequest.model_validate(args)
+ except ValidationError as e:
+ try:
+ notification = ClientNotification.model_validate(args)
+ request = notification
+ except ValidationError as e:
+ return helper.compact_generate_response(
+ create_mcp_error_response(request_id, types.INVALID_PARAMS, f"Invalid MCP request: {str(e)}")
+ )
+
+ mcp_server_handler = MCPServerStreamableHTTPRequestHandler(app, request, converted_user_input_form)
+ response = mcp_server_handler.handle()
+ return helper.compact_generate_response(response)
+
+
+api.add_resource(MCPAppApi, "/server//mcp")
diff --git a/api/controllers/service_api/app/app.py b/api/controllers/service_api/app/app.py
index 2c03aba33d..89222d5e83 100644
--- a/api/controllers/service_api/app/app.py
+++ b/api/controllers/service_api/app/app.py
@@ -47,7 +47,13 @@ class AppInfoApi(Resource):
def get(self, app_model: App):
"""Get app information"""
tags = [tag.name for tag in app_model.tags]
- return {"name": app_model.name, "description": app_model.description, "tags": tags, "mode": app_model.mode}
+ return {
+ "name": app_model.name,
+ "description": app_model.description,
+ "tags": tags,
+ "mode": app_model.mode,
+ "author_name": app_model.author_name,
+ }
api.add_resource(AppParameterApi, "/parameters")
diff --git a/api/controllers/service_api/app/audio.py b/api/controllers/service_api/app/audio.py
index 2682c2e7f1..848863cf1b 100644
--- a/api/controllers/service_api/app/audio.py
+++ b/api/controllers/service_api/app/audio.py
@@ -20,7 +20,7 @@ from controllers.service_api.app.error import (
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from core.model_runtime.errors.invoke import InvokeError
-from models.model import App, AppMode, EndUser
+from models.model import App, EndUser
from services.audio_service import AudioService
from services.errors.audio import (
AudioTooLargeServiceError,
@@ -78,20 +78,9 @@ class TextApi(Resource):
message_id = args.get("message_id", None)
text = args.get("text", None)
- if (
- app_model.mode in {AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value}
- and app_model.workflow
- and app_model.workflow.features_dict
- ):
- text_to_speech = app_model.workflow.features_dict.get("text_to_speech", {})
- voice = args.get("voice") or text_to_speech.get("voice")
- else:
- try:
- voice = args.get("voice") or app_model.app_model_config.text_to_speech_dict.get("voice")
- except Exception:
- voice = None
+ voice = args.get("voice", None)
response = AudioService.transcript_tts(
- app_model=app_model, message_id=message_id, end_user=end_user.external_user_id, voice=voice, text=text
+ app_model=app_model, text=text, voice=voice, end_user=end_user.external_user_id, message_id=message_id
)
return response
diff --git a/api/controllers/service_api/app/workflow.py b/api/controllers/service_api/app/workflow.py
index df52b49424..ac2ebf2b09 100644
--- a/api/controllers/service_api/app/workflow.py
+++ b/api/controllers/service_api/app/workflow.py
@@ -3,7 +3,7 @@ import logging
from dateutil.parser import isoparse
from flask_restful import Resource, fields, marshal_with, reqparse
from flask_restful.inputs import int_range
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import InternalServerError
from controllers.service_api import api
@@ -30,7 +30,7 @@ from fields.workflow_app_log_fields import workflow_app_log_pagination_fields
from libs import helper
from libs.helper import TimestampField
from models.model import App, AppMode, EndUser
-from models.workflow import WorkflowRun
+from repositories.factory import DifyAPIRepositoryFactory
from services.app_generate_service import AppGenerateService
from services.errors.llm import InvokeRateLimitError
from services.workflow_app_service import WorkflowAppService
@@ -63,7 +63,15 @@ class WorkflowRunDetailApi(Resource):
if app_mode not in [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]:
raise NotWorkflowAppError()
- workflow_run = db.session.query(WorkflowRun).filter(WorkflowRun.id == workflow_run_id).first()
+ # Use repository to get workflow run
+ session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
+ workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker)
+
+ workflow_run = workflow_run_repo.get_workflow_run_by_id(
+ tenant_id=app_model.tenant_id,
+ app_id=app_model.id,
+ run_id=workflow_run_id,
+ )
return workflow_run
@@ -135,6 +143,20 @@ class WorkflowAppLogApi(Resource):
parser.add_argument("status", type=str, choices=["succeeded", "failed", "stopped"], location="args")
parser.add_argument("created_at__before", type=str, location="args")
parser.add_argument("created_at__after", type=str, location="args")
+ parser.add_argument(
+ "created_by_end_user_session_id",
+ type=str,
+ location="args",
+ required=False,
+ default=None,
+ )
+ parser.add_argument(
+ "created_by_account",
+ type=str,
+ location="args",
+ required=False,
+ default=None,
+ )
parser.add_argument("page", type=int_range(1, 99999), default=1, location="args")
parser.add_argument("limit", type=int_range(1, 100), default=20, location="args")
args = parser.parse_args()
@@ -158,6 +180,8 @@ class WorkflowAppLogApi(Resource):
created_at_after=args.created_at__after,
page=args.page,
limit=args.limit,
+ created_by_end_user_session_id=args.created_by_end_user_session_id,
+ created_by_account=args.created_by_account,
)
return workflow_app_log_pagination
diff --git a/api/controllers/service_api/dataset/dataset.py b/api/controllers/service_api/dataset/dataset.py
index 27e8dd3fa6..a499719fc3 100644
--- a/api/controllers/service_api/dataset/dataset.py
+++ b/api/controllers/service_api/dataset/dataset.py
@@ -4,8 +4,12 @@ from werkzeug.exceptions import Forbidden, NotFound
import services.dataset_service
from controllers.service_api import api
-from controllers.service_api.dataset.error import DatasetInUseError, DatasetNameDuplicateError
-from controllers.service_api.wraps import DatasetApiResource, validate_dataset_token
+from controllers.service_api.dataset.error import DatasetInUseError, DatasetNameDuplicateError, InvalidActionError
+from controllers.service_api.wraps import (
+ DatasetApiResource,
+ cloud_edition_billing_rate_limit_check,
+ validate_dataset_token,
+)
from core.model_runtime.entities.model_entities import ModelType
from core.plugin.entities.plugin import ModelProviderID
from core.provider_manager import ProviderManager
@@ -13,7 +17,7 @@ from fields.dataset_fields import dataset_detail_fields
from fields.tag_fields import tag_fields
from libs.login import current_user
from models.dataset import Dataset, DatasetPermissionEnum
-from services.dataset_service import DatasetPermissionService, DatasetService
+from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService
from services.entities.knowledge_entities.knowledge_entities import RetrievalModel
from services.tag_service import TagService
@@ -70,6 +74,7 @@ class DatasetListApi(DatasetApiResource):
response = {"data": data, "has_more": len(datasets) == limit, "limit": limit, "total": total, "page": page}
return response, 200
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id):
"""Resource for creating datasets."""
parser = reqparse.RequestParser()
@@ -128,6 +133,22 @@ class DatasetListApi(DatasetApiResource):
parser.add_argument("embedding_model_provider", type=str, required=False, nullable=True, location="json")
args = parser.parse_args()
+
+ if args.get("embedding_model_provider"):
+ DatasetService.check_embedding_model_setting(
+ tenant_id, args.get("embedding_model_provider"), args.get("embedding_model")
+ )
+ if (
+ args.get("retrieval_model")
+ and args.get("retrieval_model").get("reranking_model")
+ and args.get("retrieval_model").get("reranking_model").get("reranking_provider_name")
+ ):
+ DatasetService.check_reranking_model_setting(
+ tenant_id,
+ args.get("retrieval_model").get("reranking_model").get("reranking_provider_name"),
+ args.get("retrieval_model").get("reranking_model").get("reranking_model_name"),
+ )
+
try:
dataset = DatasetService.create_empty_dataset(
tenant_id=tenant_id,
@@ -193,6 +214,7 @@ class DatasetApi(DatasetApiResource):
return data, 200
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def patch(self, _, dataset_id):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str)
@@ -259,10 +281,20 @@ class DatasetApi(DatasetApiResource):
data = request.get_json()
# check embedding model setting
- if data.get("indexing_technique") == "high_quality":
+ if data.get("indexing_technique") == "high_quality" or data.get("embedding_model_provider"):
DatasetService.check_embedding_model_setting(
dataset.tenant_id, data.get("embedding_model_provider"), data.get("embedding_model")
)
+ if (
+ data.get("retrieval_model")
+ and data.get("retrieval_model").get("reranking_model")
+ and data.get("retrieval_model").get("reranking_model").get("reranking_provider_name")
+ ):
+ DatasetService.check_reranking_model_setting(
+ dataset.tenant_id,
+ data.get("retrieval_model").get("reranking_model").get("reranking_provider_name"),
+ data.get("retrieval_model").get("reranking_model").get("reranking_model_name"),
+ )
# The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
DatasetPermissionService.check_permission(
@@ -293,6 +325,7 @@ class DatasetApi(DatasetApiResource):
return result_data, 200
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def delete(self, _, dataset_id):
"""
Deletes a dataset given its ID.
@@ -322,6 +355,56 @@ class DatasetApi(DatasetApiResource):
raise DatasetInUseError()
+class DocumentStatusApi(DatasetApiResource):
+ """Resource for batch document status operations."""
+
+ def patch(self, tenant_id, dataset_id, action):
+ """
+ Batch update document status.
+
+ Args:
+ tenant_id: tenant id
+ dataset_id: dataset id
+ action: action to perform (enable, disable, archive, un_archive)
+
+ Returns:
+ dict: A dictionary with a key 'result' and a value 'success'
+ int: HTTP status code 200 indicating that the operation was successful.
+
+ Raises:
+ NotFound: If the dataset with the given ID does not exist.
+ Forbidden: If the user does not have permission.
+ InvalidActionError: If the action is invalid or cannot be performed.
+ """
+ dataset_id_str = str(dataset_id)
+ dataset = DatasetService.get_dataset(dataset_id_str)
+
+ if dataset is None:
+ raise NotFound("Dataset not found.")
+
+ # Check user's permission
+ try:
+ DatasetService.check_dataset_permission(dataset, current_user)
+ except services.errors.account.NoPermissionError as e:
+ raise Forbidden(str(e))
+
+ # Check dataset model setting
+ DatasetService.check_dataset_model_setting(dataset)
+
+ # Get document IDs from request body
+ data = request.get_json()
+ document_ids = data.get("document_ids", [])
+
+ try:
+ DocumentService.batch_update_document_status(dataset, document_ids, action, current_user)
+ except services.errors.document.DocumentIndexingError as e:
+ raise InvalidActionError(str(e))
+ except ValueError as e:
+ raise InvalidActionError(str(e))
+
+ return {"result": "success"}, 200
+
+
class DatasetTagsApi(DatasetApiResource):
@validate_dataset_token
@marshal_with(tag_fields)
@@ -450,6 +533,7 @@ class DatasetTagsBindingStatusApi(DatasetApiResource):
api.add_resource(DatasetListApi, "/datasets")
api.add_resource(DatasetApi, "/datasets/")
+api.add_resource(DocumentStatusApi, "/datasets//documents/status/")
api.add_resource(DatasetTagsApi, "/datasets/tags")
api.add_resource(DatasetTagBindingApi, "/datasets/tags/binding")
api.add_resource(DatasetTagUnbindingApi, "/datasets/tags/unbinding")
diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py
index ab7ab4dcf0..d571b21a0a 100644
--- a/api/controllers/service_api/dataset/document.py
+++ b/api/controllers/service_api/dataset/document.py
@@ -3,7 +3,7 @@ import json
from flask import request
from flask_restful import marshal, reqparse
from sqlalchemy import desc, select
-from werkzeug.exceptions import NotFound
+from werkzeug.exceptions import Forbidden, NotFound
import services
from controllers.common.errors import FilenameNotExistsError
@@ -18,14 +18,19 @@ from controllers.service_api.app.error import (
from controllers.service_api.dataset.error import (
ArchivedDocumentImmutableError,
DocumentIndexingError,
+ InvalidMetadataError,
+)
+from controllers.service_api.wraps import (
+ DatasetApiResource,
+ cloud_edition_billing_rate_limit_check,
+ cloud_edition_billing_resource_check,
)
-from controllers.service_api.wraps import DatasetApiResource, cloud_edition_billing_resource_check
from core.errors.error import ProviderTokenNotInitError
from extensions.ext_database import db
from fields.document_fields import document_fields, document_status_fields
from libs.login import current_user
from models.dataset import Dataset, Document, DocumentSegment
-from services.dataset_service import DocumentService
+from services.dataset_service import DatasetService, DocumentService
from services.entities.knowledge_entities.knowledge_entities import KnowledgeConfig
from services.file_service import FileService
@@ -35,6 +40,7 @@ class DocumentAddByTextApi(DatasetApiResource):
@cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_resource_check("documents", "dataset")
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id):
"""Create document by text."""
parser = reqparse.RequestParser()
@@ -54,6 +60,7 @@ class DocumentAddByTextApi(DatasetApiResource):
parser.add_argument("embedding_model_provider", type=str, required=False, nullable=True, location="json")
args = parser.parse_args()
+
dataset_id = str(dataset_id)
tenant_id = str(tenant_id)
dataset = db.session.query(Dataset).filter(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
@@ -69,6 +76,21 @@ class DocumentAddByTextApi(DatasetApiResource):
if text is None or name is None:
raise ValueError("Both 'text' and 'name' must be non-null values.")
+ if args.get("embedding_model_provider"):
+ DatasetService.check_embedding_model_setting(
+ tenant_id, args.get("embedding_model_provider"), args.get("embedding_model")
+ )
+ if (
+ args.get("retrieval_model")
+ and args.get("retrieval_model").get("reranking_model")
+ and args.get("retrieval_model").get("reranking_model").get("reranking_provider_name")
+ ):
+ DatasetService.check_reranking_model_setting(
+ tenant_id,
+ args.get("retrieval_model").get("reranking_model").get("reranking_provider_name"),
+ args.get("retrieval_model").get("reranking_model").get("reranking_model_name"),
+ )
+
upload_file = FileService.upload_text(text=str(text), text_name=str(name))
data_source = {
"type": "upload_file",
@@ -99,6 +121,7 @@ class DocumentUpdateByTextApi(DatasetApiResource):
"""Resource for update documents."""
@cloud_edition_billing_resource_check("vector_space", "dataset")
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id, document_id):
"""Update document by text."""
parser = reqparse.RequestParser()
@@ -118,6 +141,17 @@ class DocumentUpdateByTextApi(DatasetApiResource):
if not dataset:
raise ValueError("Dataset does not exist.")
+ if (
+ args.get("retrieval_model")
+ and args.get("retrieval_model").get("reranking_model")
+ and args.get("retrieval_model").get("reranking_model").get("reranking_provider_name")
+ ):
+ DatasetService.check_reranking_model_setting(
+ tenant_id,
+ args.get("retrieval_model").get("reranking_model").get("reranking_provider_name"),
+ args.get("retrieval_model").get("reranking_model").get("reranking_model_name"),
+ )
+
# indexing_technique is already set in dataset since this is an update
args["indexing_technique"] = dataset.indexing_technique
@@ -158,6 +192,7 @@ class DocumentAddByFileApi(DatasetApiResource):
@cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_resource_check("documents", "dataset")
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id):
"""Create document by upload file."""
args = {}
@@ -176,11 +211,29 @@ class DocumentAddByFileApi(DatasetApiResource):
if not dataset:
raise ValueError("Dataset does not exist.")
+ if dataset.provider == "external":
+ raise ValueError("External datasets are not supported.")
+
indexing_technique = args.get("indexing_technique") or dataset.indexing_technique
if not indexing_technique:
raise ValueError("indexing_technique is required.")
args["indexing_technique"] = indexing_technique
+ if "embedding_model_provider" in args:
+ DatasetService.check_embedding_model_setting(
+ tenant_id, args["embedding_model_provider"], args["embedding_model"]
+ )
+ if (
+ "retrieval_model" in args
+ and args["retrieval_model"].get("reranking_model")
+ and args["retrieval_model"].get("reranking_model").get("reranking_provider_name")
+ ):
+ DatasetService.check_reranking_model_setting(
+ tenant_id,
+ args["retrieval_model"].get("reranking_model").get("reranking_provider_name"),
+ args["retrieval_model"].get("reranking_model").get("reranking_model_name"),
+ )
+
# save file info
file = request.files["file"]
# check file
@@ -232,6 +285,7 @@ class DocumentUpdateByFileApi(DatasetApiResource):
"""Resource for update documents."""
@cloud_edition_billing_resource_check("vector_space", "dataset")
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id, document_id):
"""Update document by upload file."""
args = {}
@@ -250,6 +304,9 @@ class DocumentUpdateByFileApi(DatasetApiResource):
if not dataset:
raise ValueError("Dataset does not exist.")
+ if dataset.provider == "external":
+ raise ValueError("External datasets are not supported.")
+
# indexing_technique is already set in dataset since this is an update
args["indexing_technique"] = dataset.indexing_technique
@@ -302,6 +359,7 @@ class DocumentUpdateByFileApi(DatasetApiResource):
class DocumentDeleteApi(DatasetApiResource):
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def delete(self, tenant_id, dataset_id, document_id):
"""Delete document."""
document_id = str(document_id)
@@ -415,6 +473,101 @@ class DocumentIndexingStatusApi(DatasetApiResource):
return data
+class DocumentDetailApi(DatasetApiResource):
+ METADATA_CHOICES = {"all", "only", "without"}
+
+ def get(self, tenant_id, dataset_id, document_id):
+ dataset_id = str(dataset_id)
+ document_id = str(document_id)
+
+ dataset = self.get_dataset(dataset_id, tenant_id)
+
+ document = DocumentService.get_document(dataset.id, document_id)
+
+ if not document:
+ raise NotFound("Document not found.")
+
+ if document.tenant_id != str(tenant_id):
+ raise Forbidden("No permission.")
+
+ metadata = request.args.get("metadata", "all")
+ if metadata not in self.METADATA_CHOICES:
+ raise InvalidMetadataError(f"Invalid metadata value: {metadata}")
+
+ if metadata == "only":
+ response = {"id": document.id, "doc_type": document.doc_type, "doc_metadata": document.doc_metadata_details}
+ elif metadata == "without":
+ dataset_process_rules = DatasetService.get_process_rules(dataset_id)
+ document_process_rules = document.dataset_process_rule.to_dict()
+ data_source_info = document.data_source_detail_dict
+ response = {
+ "id": document.id,
+ "position": document.position,
+ "data_source_type": document.data_source_type,
+ "data_source_info": data_source_info,
+ "dataset_process_rule_id": document.dataset_process_rule_id,
+ "dataset_process_rule": dataset_process_rules,
+ "document_process_rule": document_process_rules,
+ "name": document.name,
+ "created_from": document.created_from,
+ "created_by": document.created_by,
+ "created_at": document.created_at.timestamp(),
+ "tokens": document.tokens,
+ "indexing_status": document.indexing_status,
+ "completed_at": int(document.completed_at.timestamp()) if document.completed_at else None,
+ "updated_at": int(document.updated_at.timestamp()) if document.updated_at else None,
+ "indexing_latency": document.indexing_latency,
+ "error": document.error,
+ "enabled": document.enabled,
+ "disabled_at": int(document.disabled_at.timestamp()) if document.disabled_at else None,
+ "disabled_by": document.disabled_by,
+ "archived": document.archived,
+ "segment_count": document.segment_count,
+ "average_segment_length": document.average_segment_length,
+ "hit_count": document.hit_count,
+ "display_status": document.display_status,
+ "doc_form": document.doc_form,
+ "doc_language": document.doc_language,
+ }
+ else:
+ dataset_process_rules = DatasetService.get_process_rules(dataset_id)
+ document_process_rules = document.dataset_process_rule.to_dict()
+ data_source_info = document.data_source_detail_dict
+ response = {
+ "id": document.id,
+ "position": document.position,
+ "data_source_type": document.data_source_type,
+ "data_source_info": data_source_info,
+ "dataset_process_rule_id": document.dataset_process_rule_id,
+ "dataset_process_rule": dataset_process_rules,
+ "document_process_rule": document_process_rules,
+ "name": document.name,
+ "created_from": document.created_from,
+ "created_by": document.created_by,
+ "created_at": document.created_at.timestamp(),
+ "tokens": document.tokens,
+ "indexing_status": document.indexing_status,
+ "completed_at": int(document.completed_at.timestamp()) if document.completed_at else None,
+ "updated_at": int(document.updated_at.timestamp()) if document.updated_at else None,
+ "indexing_latency": document.indexing_latency,
+ "error": document.error,
+ "enabled": document.enabled,
+ "disabled_at": int(document.disabled_at.timestamp()) if document.disabled_at else None,
+ "disabled_by": document.disabled_by,
+ "archived": document.archived,
+ "doc_type": document.doc_type,
+ "doc_metadata": document.doc_metadata_details,
+ "segment_count": document.segment_count,
+ "average_segment_length": document.average_segment_length,
+ "hit_count": document.hit_count,
+ "display_status": document.display_status,
+ "doc_form": document.doc_form,
+ "doc_language": document.doc_language,
+ }
+
+ return response
+
+
api.add_resource(
DocumentAddByTextApi,
"/datasets//document/create_by_text",
@@ -438,3 +591,4 @@ api.add_resource(
api.add_resource(DocumentDeleteApi, "/datasets//documents/")
api.add_resource(DocumentListApi, "/datasets//documents")
api.add_resource(DocumentIndexingStatusApi, "/datasets//documents//indexing-status")
+api.add_resource(DocumentDetailApi, "/datasets//documents/")
diff --git a/api/controllers/service_api/dataset/hit_testing.py b/api/controllers/service_api/dataset/hit_testing.py
index 465f71bf03..52e9bca5da 100644
--- a/api/controllers/service_api/dataset/hit_testing.py
+++ b/api/controllers/service_api/dataset/hit_testing.py
@@ -1,9 +1,10 @@
from controllers.console.datasets.hit_testing_base import DatasetsHitTestingBase
from controllers.service_api import api
-from controllers.service_api.wraps import DatasetApiResource
+from controllers.service_api.wraps import DatasetApiResource, cloud_edition_billing_rate_limit_check
class HitTestingApi(DatasetApiResource, DatasetsHitTestingBase):
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id):
dataset_id_str = str(dataset_id)
diff --git a/api/controllers/service_api/dataset/metadata.py b/api/controllers/service_api/dataset/metadata.py
index 35582feea0..1968696ee5 100644
--- a/api/controllers/service_api/dataset/metadata.py
+++ b/api/controllers/service_api/dataset/metadata.py
@@ -3,7 +3,7 @@ from flask_restful import marshal, reqparse
from werkzeug.exceptions import NotFound
from controllers.service_api import api
-from controllers.service_api.wraps import DatasetApiResource
+from controllers.service_api.wraps import DatasetApiResource, cloud_edition_billing_rate_limit_check
from fields.dataset_fields import dataset_metadata_fields
from services.dataset_service import DatasetService
from services.entities.knowledge_entities.knowledge_entities import (
@@ -14,6 +14,7 @@ from services.metadata_service import MetadataService
class DatasetMetadataCreateServiceApi(DatasetApiResource):
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id):
parser = reqparse.RequestParser()
parser.add_argument("type", type=str, required=True, nullable=True, location="json")
@@ -39,6 +40,7 @@ class DatasetMetadataCreateServiceApi(DatasetApiResource):
class DatasetMetadataServiceApi(DatasetApiResource):
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def patch(self, tenant_id, dataset_id, metadata_id):
parser = reqparse.RequestParser()
parser.add_argument("name", type=str, required=True, nullable=True, location="json")
@@ -54,6 +56,7 @@ class DatasetMetadataServiceApi(DatasetApiResource):
metadata = MetadataService.update_metadata_name(dataset_id_str, metadata_id_str, args.get("name"))
return marshal(metadata, dataset_metadata_fields), 200
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def delete(self, tenant_id, dataset_id, metadata_id):
dataset_id_str = str(dataset_id)
metadata_id_str = str(metadata_id)
@@ -73,6 +76,7 @@ class DatasetMetadataBuiltInFieldServiceApi(DatasetApiResource):
class DatasetMetadataBuiltInFieldActionServiceApi(DatasetApiResource):
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id, action):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str)
@@ -88,6 +92,7 @@ class DatasetMetadataBuiltInFieldActionServiceApi(DatasetApiResource):
class DocumentMetadataEditServiceApi(DatasetApiResource):
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id):
dataset_id_str = str(dataset_id)
dataset = DatasetService.get_dataset(dataset_id_str)
diff --git a/api/controllers/service_api/dataset/segment.py b/api/controllers/service_api/dataset/segment.py
index 337752275a..403b7f0a0c 100644
--- a/api/controllers/service_api/dataset/segment.py
+++ b/api/controllers/service_api/dataset/segment.py
@@ -8,6 +8,7 @@ from controllers.service_api.app.error import ProviderNotInitializeError
from controllers.service_api.wraps import (
DatasetApiResource,
cloud_edition_billing_knowledge_limit_check,
+ cloud_edition_billing_rate_limit_check,
cloud_edition_billing_resource_check,
)
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
@@ -35,6 +36,7 @@ class SegmentApi(DatasetApiResource):
@cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_knowledge_limit_check("add_segment", "dataset")
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id, document_id):
"""Create single segment."""
# check dataset
@@ -139,6 +141,7 @@ class SegmentApi(DatasetApiResource):
class DatasetSegmentApi(DatasetApiResource):
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def delete(self, tenant_id, dataset_id, document_id, segment_id):
# check dataset
dataset_id = str(dataset_id)
@@ -162,6 +165,7 @@ class DatasetSegmentApi(DatasetApiResource):
return 204
@cloud_edition_billing_resource_check("vector_space", "dataset")
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id, document_id, segment_id):
# check dataset
dataset_id = str(dataset_id)
@@ -236,6 +240,7 @@ class ChildChunkApi(DatasetApiResource):
@cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_knowledge_limit_check("add_segment", "dataset")
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id, document_id, segment_id):
"""Create child chunk."""
# check dataset
@@ -332,6 +337,7 @@ class DatasetChildChunkApi(DatasetApiResource):
"""Resource for updating child chunks."""
@cloud_edition_billing_knowledge_limit_check("add_segment", "dataset")
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def delete(self, tenant_id, dataset_id, document_id, segment_id, child_chunk_id):
"""Delete child chunk."""
# check dataset
@@ -370,6 +376,7 @@ class DatasetChildChunkApi(DatasetApiResource):
@cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_knowledge_limit_check("add_segment", "dataset")
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def patch(self, tenant_id, dataset_id, document_id, segment_id, child_chunk_id):
"""Update child chunk."""
# check dataset
diff --git a/api/controllers/service_api/index.py b/api/controllers/service_api/index.py
index d24c4597e2..9bb5df4c4e 100644
--- a/api/controllers/service_api/index.py
+++ b/api/controllers/service_api/index.py
@@ -9,7 +9,7 @@ class IndexApi(Resource):
return {
"welcome": "Dify OpenAPI",
"api_version": "v1",
- "server_version": dify_config.CURRENT_VERSION,
+ "server_version": dify_config.project.version,
}
diff --git a/api/controllers/service_api/wraps.py b/api/controllers/service_api/wraps.py
index d3316a5159..5b919a68d4 100644
--- a/api/controllers/service_api/wraps.py
+++ b/api/controllers/service_api/wraps.py
@@ -11,13 +11,13 @@ from flask_restful import Resource
from pydantic import BaseModel
from sqlalchemy import select, update
from sqlalchemy.orm import Session
-from werkzeug.exceptions import Forbidden, Unauthorized
+from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.login import _get_user
from models.account import Account, Tenant, TenantAccountJoin, TenantStatus
-from models.dataset import RateLimitLog
+from models.dataset import Dataset, RateLimitLog
from models.model import ApiToken, App, EndUser
from services.feature_service import FeatureService
@@ -317,3 +317,11 @@ def create_or_update_end_user_for_user_id(app_model: App, user_id: Optional[str]
class DatasetApiResource(Resource):
method_decorators = [validate_dataset_token]
+
+ def get_dataset(self, dataset_id: str, tenant_id: str) -> Dataset:
+ dataset = db.session.query(Dataset).filter(Dataset.id == dataset_id, Dataset.tenant_id == tenant_id).first()
+
+ if not dataset:
+ raise NotFound("Dataset not found.")
+
+ return dataset
diff --git a/api/controllers/web/audio.py b/api/controllers/web/audio.py
index 06d9ad7564..2919ca9af4 100644
--- a/api/controllers/web/audio.py
+++ b/api/controllers/web/audio.py
@@ -19,7 +19,7 @@ from controllers.web.error import (
from controllers.web.wraps import WebApiResource
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from core.model_runtime.errors.invoke import InvokeError
-from models.model import App, AppMode
+from models.model import App
from services.audio_service import AudioService
from services.errors.audio import (
AudioTooLargeServiceError,
@@ -77,21 +77,9 @@ class TextApi(WebApiResource):
message_id = args.get("message_id", None)
text = args.get("text", None)
- if (
- app_model.mode in {AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value}
- and app_model.workflow
- and app_model.workflow.features_dict
- ):
- text_to_speech = app_model.workflow.features_dict.get("text_to_speech", {})
- voice = args.get("voice") or text_to_speech.get("voice")
- else:
- try:
- voice = args.get("voice") or app_model.app_model_config.text_to_speech_dict.get("voice")
- except Exception:
- voice = None
-
+ voice = args.get("voice", None)
response = AudioService.transcript_tts(
- app_model=app_model, message_id=message_id, end_user=end_user.external_user_id, voice=voice, text=text
+ app_model=app_model, text=text, voice=voice, end_user=end_user.external_user_id, message_id=message_id
)
return response
diff --git a/api/controllers/web/error.py b/api/controllers/web/error.py
index 4371e679db..036e11d5c5 100644
--- a/api/controllers/web/error.py
+++ b/api/controllers/web/error.py
@@ -139,3 +139,13 @@ class InvokeRateLimitError(BaseHTTPException):
error_code = "rate_limit_error"
description = "Rate Limit Error"
code = 429
+
+
+class NotFoundError(BaseHTTPException):
+ error_code = "not_found"
+ code = 404
+
+
+class InvalidArgumentError(BaseHTTPException):
+ error_code = "invalid_param"
+ code = 400
diff --git a/api/controllers/web/passport.py b/api/controllers/web/passport.py
index 9d229185f3..10c3cdcf0e 100644
--- a/api/controllers/web/passport.py
+++ b/api/controllers/web/passport.py
@@ -163,7 +163,7 @@ def exchange_token_for_existing_web_user(app_code: str, enterprise_user_decoded:
)
db.session.add(end_user)
db.session.commit()
- exp_dt = datetime.now(UTC) + timedelta(hours=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES * 24)
+ exp_dt = datetime.now(UTC) + timedelta(minutes=dify_config.ACCESS_TOKEN_EXPIRE_MINUTES)
exp = int(exp_dt.timestamp())
payload = {
"iss": site.id,
diff --git a/api/core/agent/base_agent_runner.py b/api/core/agent/base_agent_runner.py
index 6998e4d29a..28bf4a9a23 100644
--- a/api/core/agent/base_agent_runner.py
+++ b/api/core/agent/base_agent_runner.py
@@ -3,6 +3,8 @@ import logging
import uuid
from typing import Optional, Union, cast
+from sqlalchemy import select
+
from core.agent.entities import AgentEntity, AgentToolEntity
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
from core.app.apps.agent_chat.app_config_manager import AgentChatAppConfig
@@ -161,10 +163,14 @@ class BaseAgentRunner(AppRunner):
if parameter.type == ToolParameter.ToolParameterType.SELECT:
enum = [option.value for option in parameter.options] if parameter.options else []
- message_tool.parameters["properties"][parameter.name] = {
- "type": parameter_type,
- "description": parameter.llm_description or "",
- }
+ message_tool.parameters["properties"][parameter.name] = (
+ {
+ "type": parameter_type,
+ "description": parameter.llm_description or "",
+ }
+ if parameter.input_schema is None
+ else parameter.input_schema
+ )
if len(enum) > 0:
message_tool.parameters["properties"][parameter.name]["enum"] = enum
@@ -254,10 +260,14 @@ class BaseAgentRunner(AppRunner):
if parameter.type == ToolParameter.ToolParameterType.SELECT:
enum = [option.value for option in parameter.options] if parameter.options else []
- prompt_tool.parameters["properties"][parameter.name] = {
- "type": parameter_type,
- "description": parameter.llm_description or "",
- }
+ prompt_tool.parameters["properties"][parameter.name] = (
+ {
+ "type": parameter_type,
+ "description": parameter.llm_description or "",
+ }
+ if parameter.input_schema is None
+ else parameter.input_schema
+ )
if len(enum) > 0:
prompt_tool.parameters["properties"][parameter.name]["enum"] = enum
@@ -409,12 +419,15 @@ class BaseAgentRunner(AppRunner):
if isinstance(prompt_message, SystemPromptMessage):
result.append(prompt_message)
- messages: list[Message] = (
- db.session.query(Message)
- .filter(
- Message.conversation_id == self.message.conversation_id,
+ messages = (
+ (
+ db.session.execute(
+ select(Message)
+ .where(Message.conversation_id == self.message.conversation_id)
+ .order_by(Message.created_at.desc())
+ )
)
- .order_by(Message.created_at.desc())
+ .scalars()
.all()
)
diff --git a/api/core/agent/plugin_entities.py b/api/core/agent/plugin_entities.py
index 9c722baa23..3b48288710 100644
--- a/api/core/agent/plugin_entities.py
+++ b/api/core/agent/plugin_entities.py
@@ -85,7 +85,7 @@ class AgentStrategyEntity(BaseModel):
description: I18nObject = Field(..., description="The description of the agent strategy")
output_schema: Optional[dict] = None
features: Optional[list[AgentFeature]] = None
-
+ meta_version: Optional[str] = None
# pydantic configs
model_config = ConfigDict(protected_namespaces=())
diff --git a/api/core/agent/strategy/plugin.py b/api/core/agent/strategy/plugin.py
index 79b074cf95..4cfcfbf86a 100644
--- a/api/core/agent/strategy/plugin.py
+++ b/api/core/agent/strategy/plugin.py
@@ -15,10 +15,12 @@ class PluginAgentStrategy(BaseAgentStrategy):
tenant_id: str
declaration: AgentStrategyEntity
+ meta_version: str | None = None
- def __init__(self, tenant_id: str, declaration: AgentStrategyEntity):
+ def __init__(self, tenant_id: str, declaration: AgentStrategyEntity, meta_version: str | None):
self.tenant_id = tenant_id
self.declaration = declaration
+ self.meta_version = meta_version
def get_parameters(self) -> Sequence[AgentStrategyParameter]:
return self.declaration.parameters
diff --git a/api/core/app/app_config/easy_ui_based_app/dataset/manager.py b/api/core/app/app_config/easy_ui_based_app/dataset/manager.py
index 20189053f4..a5492d70bd 100644
--- a/api/core/app/app_config/easy_ui_based_app/dataset/manager.py
+++ b/api/core/app/app_config/easy_ui_based_app/dataset/manager.py
@@ -138,14 +138,11 @@ class DatasetConfigManager:
if not config.get("dataset_configs"):
config["dataset_configs"] = {"retrieval_model": "single"}
- if not config["dataset_configs"].get("datasets"):
- config["dataset_configs"]["datasets"] = {"strategy": "router", "datasets": []}
-
if not isinstance(config["dataset_configs"], dict):
raise ValueError("dataset_configs must be of object type")
- if not isinstance(config["dataset_configs"], dict):
- raise ValueError("dataset_configs must be of object type")
+ if not config["dataset_configs"].get("datasets"):
+ config["dataset_configs"]["datasets"] = {"strategy": "router", "datasets": []}
need_manual_query_datasets = config.get("dataset_configs") and config["dataset_configs"].get(
"datasets", {}
diff --git a/api/core/app/app_config/entities.py b/api/core/app/app_config/entities.py
index 3f31b1c3d5..75bd2f677a 100644
--- a/api/core/app/app_config/entities.py
+++ b/api/core/app/app_config/entities.py
@@ -104,6 +104,7 @@ class VariableEntity(BaseModel):
Variable Entity.
"""
+ # `variable` records the name of the variable in user inputs.
variable: str
label: str
description: str = ""
diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py
index 8c85f91d7e..4b8f5ebe27 100644
--- a/api/core/app/apps/advanced_chat/app_generator.py
+++ b/api/core/app/apps/advanced_chat/app_generator.py
@@ -5,7 +5,7 @@ import uuid
from collections.abc import Generator, Mapping
from typing import Any, Literal, Optional, Union, overload
-from flask import Flask, copy_current_request_context, current_app, has_request_context
+from flask import Flask, current_app
from pydantic import ValidationError
from sqlalchemy.orm import sessionmaker
@@ -25,16 +25,23 @@ from core.app.entities.task_entities import ChatbotAppBlockingResponse, ChatbotA
from core.model_runtime.errors.invoke import InvokeAuthorizationError
from core.ops.ops_trace_manager import TraceQueueManager
from core.prompt.utils.get_thread_messages_length import get_thread_messages_length
-from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository
-from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
+from core.repositories import DifyCoreRepositoryFactory
+from core.workflow.repositories.draft_variable_repository import (
+ DraftVariableSaverFactory,
+)
from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository
from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository
+from core.workflow.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader
from extensions.ext_database import db
from factories import file_factory
+from libs.flask_utils import preserve_flask_contexts
from models import Account, App, Conversation, EndUser, Message, Workflow, WorkflowNodeExecutionTriggeredFrom
from models.enums import WorkflowRunTriggeredFrom
from services.conversation_service import ConversationService
-from services.errors.message import MessageNotExistsError
+from services.workflow_draft_variable_service import (
+ DraftVarLoader,
+ WorkflowDraftVariableService,
+)
logger = logging.getLogger(__name__)
@@ -115,6 +122,11 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
)
# parse files
+ # TODO(QuantumGhost): Move file parsing logic to the API controller layer
+ # for better separation of concerns.
+ #
+ # For implementation reference, see the `_parse_file` function and
+ # `DraftWorkflowNodeRunApi` class which handle this properly.
files = args["files"] if args.get("files") else []
file_extra_config = FileUploadConfigManager.convert(workflow.features_dict, is_vision=False)
if file_extra_config:
@@ -170,14 +182,14 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
workflow_triggered_from = WorkflowRunTriggeredFrom.DEBUGGING
else:
workflow_triggered_from = WorkflowRunTriggeredFrom.APP_RUN
- workflow_execution_repository = SQLAlchemyWorkflowExecutionRepository(
+ workflow_execution_repository = DifyCoreRepositoryFactory.create_workflow_execution_repository(
session_factory=session_factory,
user=user,
app_id=application_generate_entity.app_config.app_id,
triggered_from=workflow_triggered_from,
)
# Create workflow node execution repository
- workflow_node_execution_repository = SQLAlchemyWorkflowNodeExecutionRepository(
+ workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
session_factory=session_factory,
user=user,
app_id=application_generate_entity.app_config.app_id,
@@ -247,19 +259,26 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
# Create session factory
session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
# Create workflow execution(aka workflow run) repository
- workflow_execution_repository = SQLAlchemyWorkflowExecutionRepository(
+ workflow_execution_repository = DifyCoreRepositoryFactory.create_workflow_execution_repository(
session_factory=session_factory,
user=user,
app_id=application_generate_entity.app_config.app_id,
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
)
# Create workflow node execution repository
- workflow_node_execution_repository = SQLAlchemyWorkflowNodeExecutionRepository(
+ workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
session_factory=session_factory,
user=user,
app_id=application_generate_entity.app_config.app_id,
triggered_from=WorkflowNodeExecutionTriggeredFrom.SINGLE_STEP,
)
+ var_loader = DraftVarLoader(
+ engine=db.engine,
+ app_id=application_generate_entity.app_config.app_id,
+ tenant_id=application_generate_entity.app_config.tenant_id,
+ )
+ draft_var_srv = WorkflowDraftVariableService(db.session())
+ draft_var_srv.prefill_conversation_variable_default_values(workflow)
return self._generate(
workflow=workflow,
@@ -270,6 +289,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
workflow_node_execution_repository=workflow_node_execution_repository,
conversation=None,
stream=streaming,
+ variable_loader=var_loader,
)
def single_loop_generate(
@@ -322,19 +342,26 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
# Create session factory
session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
# Create workflow execution(aka workflow run) repository
- workflow_execution_repository = SQLAlchemyWorkflowExecutionRepository(
+ workflow_execution_repository = DifyCoreRepositoryFactory.create_workflow_execution_repository(
session_factory=session_factory,
user=user,
app_id=application_generate_entity.app_config.app_id,
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
)
# Create workflow node execution repository
- workflow_node_execution_repository = SQLAlchemyWorkflowNodeExecutionRepository(
+ workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
session_factory=session_factory,
user=user,
app_id=application_generate_entity.app_config.app_id,
triggered_from=WorkflowNodeExecutionTriggeredFrom.SINGLE_STEP,
)
+ var_loader = DraftVarLoader(
+ engine=db.engine,
+ app_id=application_generate_entity.app_config.app_id,
+ tenant_id=application_generate_entity.app_config.tenant_id,
+ )
+ draft_var_srv = WorkflowDraftVariableService(db.session())
+ draft_var_srv.prefill_conversation_variable_default_values(workflow)
return self._generate(
workflow=workflow,
@@ -345,6 +372,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
workflow_node_execution_repository=workflow_node_execution_repository,
conversation=None,
stream=streaming,
+ variable_loader=var_loader,
)
def _generate(
@@ -358,6 +386,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
conversation: Optional[Conversation] = None,
stream: bool = True,
+ variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER,
) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], Any, None]:
"""
Generate App response.
@@ -366,6 +395,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
:param user: account or end user
:param invoke_from: invoke from source
:param application_generate_entity: application generate entity
+ :param workflow_execution_repository: repository for workflow execution
:param workflow_node_execution_repository: repository for workflow node execution
:param conversation: conversation
:param stream: is stream
@@ -399,20 +429,18 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
# new thread with request context and contextvars
context = contextvars.copy_context()
- @copy_current_request_context
- def worker_with_context():
- # Run the worker within the copied context
- return context.run(
- self._generate_worker,
- flask_app=current_app._get_current_object(), # type: ignore
- application_generate_entity=application_generate_entity,
- queue_manager=queue_manager,
- conversation_id=conversation.id,
- message_id=message.id,
- context=context,
- )
-
- worker_thread = threading.Thread(target=worker_with_context)
+ worker_thread = threading.Thread(
+ target=self._generate_worker,
+ kwargs={
+ "flask_app": current_app._get_current_object(), # type: ignore
+ "application_generate_entity": application_generate_entity,
+ "queue_manager": queue_manager,
+ "conversation_id": conversation.id,
+ "message_id": message.id,
+ "context": context,
+ "variable_loader": variable_loader,
+ },
+ )
worker_thread.start()
@@ -427,6 +455,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
workflow_execution_repository=workflow_execution_repository,
workflow_node_execution_repository=workflow_node_execution_repository,
stream=stream,
+ draft_var_saver_factory=self._get_draft_var_saver_factory(invoke_from),
)
return AdvancedChatAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
@@ -439,6 +468,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
conversation_id: str,
message_id: str,
context: contextvars.Context,
+ variable_loader: VariableLoader,
) -> None:
"""
Generate worker in a new thread.
@@ -449,29 +479,12 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
:param message_id: message ID
:return:
"""
- for var, val in context.items():
- var.set(val)
-
- # FIXME(-LAN-): Save current user before entering new app context
- from flask import g
- saved_user = None
- if has_request_context() and hasattr(g, "_login_user"):
- saved_user = g._login_user
-
- with flask_app.app_context():
+ with preserve_flask_contexts(flask_app, context_vars=context):
try:
- # Restore user in new app context
- if saved_user is not None:
- from flask import g
-
- g._login_user = saved_user
-
# get conversation and message
conversation = self._get_conversation(conversation_id)
message = self._get_message(message_id)
- if message is None:
- raise MessageNotExistsError("Message not exists")
# chatbot app
runner = AdvancedChatAppRunner(
@@ -480,6 +493,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
conversation=conversation,
message=message,
dialogue_count=self._dialogue_count,
+ variable_loader=variable_loader,
)
runner.run()
@@ -513,6 +527,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
user: Union[Account, EndUser],
workflow_execution_repository: WorkflowExecutionRepository,
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
+ draft_var_saver_factory: DraftVariableSaverFactory,
stream: bool = False,
) -> Union[ChatbotAppBlockingResponse, Generator[ChatbotAppStreamResponse, None, None]]:
"""
@@ -539,6 +554,7 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
workflow_execution_repository=workflow_execution_repository,
workflow_node_execution_repository=workflow_node_execution_repository,
stream=stream,
+ draft_var_saver_factory=draft_var_saver_factory,
)
try:
diff --git a/api/core/app/apps/advanced_chat/app_runner.py b/api/core/app/apps/advanced_chat/app_runner.py
index d9b3833862..840a3c9d3b 100644
--- a/api/core/app/apps/advanced_chat/app_runner.py
+++ b/api/core/app/apps/advanced_chat/app_runner.py
@@ -19,6 +19,7 @@ from core.moderation.base import ModerationError
from core.workflow.callbacks import WorkflowCallback, WorkflowLoggingCallback
from core.workflow.entities.variable_pool import VariablePool
from core.workflow.enums import SystemVariableKey
+from core.workflow.variable_loader import VariableLoader
from core.workflow.workflow_entry import WorkflowEntry
from extensions.ext_database import db
from models.enums import UserFrom
@@ -40,14 +41,17 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
conversation: Conversation,
message: Message,
dialogue_count: int,
+ variable_loader: VariableLoader,
) -> None:
- super().__init__(queue_manager)
-
+ super().__init__(queue_manager, variable_loader)
self.application_generate_entity = application_generate_entity
self.conversation = conversation
self.message = message
self._dialogue_count = dialogue_count
+ def _get_app_id(self) -> str:
+ return self.application_generate_entity.app_config.app_id
+
def run(self) -> None:
app_config = self.application_generate_entity.app_config
app_config = cast(AdvancedChatAppConfig, app_config)
diff --git a/api/core/app/apps/advanced_chat/generate_task_pipeline.py b/api/core/app/apps/advanced_chat/generate_task_pipeline.py
index d8bcd84b51..c6df13f53e 100644
--- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py
+++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py
@@ -64,6 +64,7 @@ from core.workflow.entities.workflow_execution import WorkflowExecutionStatus, W
from core.workflow.enums import SystemVariableKey
from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState
from core.workflow.nodes import NodeType
+from core.workflow.repositories.draft_variable_repository import DraftVariableSaverFactory
from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository
from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository
from core.workflow.workflow_cycle_manager import CycleManagerWorkflowInfo, WorkflowCycleManager
@@ -94,6 +95,7 @@ class AdvancedChatAppGenerateTaskPipeline:
dialogue_count: int,
workflow_execution_repository: WorkflowExecutionRepository,
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
+ draft_var_saver_factory: DraftVariableSaverFactory,
) -> None:
self._base_task_pipeline = BasedGenerateTaskPipeline(
application_generate_entity=application_generate_entity,
@@ -153,6 +155,7 @@ class AdvancedChatAppGenerateTaskPipeline:
self._conversation_name_generate_thread: Thread | None = None
self._recorded_files: list[Mapping[str, Any]] = []
self._workflow_run_id: str = ""
+ self._draft_var_saver_factory = draft_var_saver_factory
def process(self) -> Union[ChatbotAppBlockingResponse, Generator[ChatbotAppStreamResponse, None, None]]:
"""
@@ -375,6 +378,7 @@ class AdvancedChatAppGenerateTaskPipeline:
workflow_node_execution=workflow_node_execution,
)
session.commit()
+ self._save_output_for_event(event, workflow_node_execution.id)
if node_finish_resp:
yield node_finish_resp
@@ -394,6 +398,8 @@ class AdvancedChatAppGenerateTaskPipeline:
task_id=self._application_generate_entity.task_id,
workflow_node_execution=workflow_node_execution,
)
+ if isinstance(event, QueueNodeExceptionEvent):
+ self._save_output_for_event(event, workflow_node_execution.id)
if node_finish_resp:
yield node_finish_resp
@@ -762,3 +768,15 @@ class AdvancedChatAppGenerateTaskPipeline:
if not message:
raise ValueError(f"Message not found: {self._message_id}")
return message
+
+ def _save_output_for_event(self, event: QueueNodeSucceededEvent | QueueNodeExceptionEvent, node_execution_id: str):
+ with Session(db.engine) as session, session.begin():
+ saver = self._draft_var_saver_factory(
+ session=session,
+ app_id=self._application_generate_entity.app_config.app_id,
+ node_id=event.node_id,
+ node_type=event.node_type,
+ node_execution_id=node_execution_id,
+ enclosing_node_id=event.in_loop_id or event.in_iteration_id,
+ )
+ saver.save(event.process_data, event.outputs)
diff --git a/api/core/app/apps/agent_chat/app_generator.py b/api/core/app/apps/agent_chat/app_generator.py
index 158196f24d..edea6199d3 100644
--- a/api/core/app/apps/agent_chat/app_generator.py
+++ b/api/core/app/apps/agent_chat/app_generator.py
@@ -5,7 +5,7 @@ import uuid
from collections.abc import Generator, Mapping
from typing import Any, Literal, Union, overload
-from flask import Flask, copy_current_request_context, current_app, has_request_context
+from flask import Flask, current_app
from pydantic import ValidationError
from configs import dify_config
@@ -23,9 +23,9 @@ from core.model_runtime.errors.invoke import InvokeAuthorizationError
from core.ops.ops_trace_manager import TraceQueueManager
from extensions.ext_database import db
from factories import file_factory
+from libs.flask_utils import preserve_flask_contexts
from models import Account, App, EndUser
from services.conversation_service import ConversationService
-from services.errors.message import MessageNotExistsError
logger = logging.getLogger(__name__)
@@ -123,6 +123,11 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
override_model_config_dict["retriever_resource"] = {"enabled": True}
# parse files
+ # TODO(QuantumGhost): Move file parsing logic to the API controller layer
+ # for better separation of concerns.
+ #
+ # For implementation reference, see the `_parse_file` function and
+ # `DraftWorkflowNodeRunApi` class which handle this properly.
files = args.get("files") or []
file_extra_config = FileUploadConfigManager.convert(override_model_config_dict or app_model_config.to_dict())
if file_extra_config:
@@ -182,20 +187,17 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
# new thread with request context and contextvars
context = contextvars.copy_context()
- @copy_current_request_context
- def worker_with_context():
- # Run the worker within the copied context
- return context.run(
- self._generate_worker,
- flask_app=current_app._get_current_object(), # type: ignore
- context=context,
- application_generate_entity=application_generate_entity,
- queue_manager=queue_manager,
- conversation_id=conversation.id,
- message_id=message.id,
- )
-
- worker_thread = threading.Thread(target=worker_with_context)
+ worker_thread = threading.Thread(
+ target=self._generate_worker,
+ kwargs={
+ "flask_app": current_app._get_current_object(), # type: ignore
+ "context": context,
+ "application_generate_entity": application_generate_entity,
+ "queue_manager": queue_manager,
+ "conversation_id": conversation.id,
+ "message_id": message.id,
+ },
+ )
worker_thread.start()
@@ -229,29 +231,12 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
:param message_id: message ID
:return:
"""
- for var, val in context.items():
- var.set(val)
-
- # FIXME(-LAN-): Save current user before entering new app context
- from flask import g
- saved_user = None
- if has_request_context() and hasattr(g, "_login_user"):
- saved_user = g._login_user
-
- with flask_app.app_context():
+ with preserve_flask_contexts(flask_app, context_vars=context):
try:
- # Restore user in new app context
- if saved_user is not None:
- from flask import g
-
- g._login_user = saved_user
-
# get conversation and message
conversation = self._get_conversation(conversation_id)
message = self._get_message(message_id)
- if message is None:
- raise MessageNotExistsError("Message not exists")
# chatbot app
runner = AgentChatAppRunner()
diff --git a/api/core/app/apps/base_app_generator.py b/api/core/app/apps/base_app_generator.py
index a83b75cc1a..beece1d77e 100644
--- a/api/core/app/apps/base_app_generator.py
+++ b/api/core/app/apps/base_app_generator.py
@@ -1,10 +1,20 @@
import json
from collections.abc import Generator, Mapping, Sequence
-from typing import TYPE_CHECKING, Any, Optional, Union
+from typing import TYPE_CHECKING, Any, Optional, Union, final
+
+from sqlalchemy.orm import Session
from core.app.app_config.entities import VariableEntityType
+from core.app.entities.app_invoke_entities import InvokeFrom
from core.file import File, FileUploadConfig
+from core.workflow.nodes.enums import NodeType
+from core.workflow.repositories.draft_variable_repository import (
+ DraftVariableSaver,
+ DraftVariableSaverFactory,
+ NoopDraftVariableSaver,
+)
from factories import file_factory
+from services.workflow_draft_variable_service import DraftVariableSaver as DraftVariableSaverImpl
if TYPE_CHECKING:
from core.app.app_config.entities import VariableEntity
@@ -159,3 +169,38 @@ class BaseAppGenerator:
yield f"event: {message}\n\n"
return gen()
+
+ @final
+ @staticmethod
+ def _get_draft_var_saver_factory(invoke_from: InvokeFrom) -> DraftVariableSaverFactory:
+ if invoke_from == InvokeFrom.DEBUGGER:
+
+ def draft_var_saver_factory(
+ session: Session,
+ app_id: str,
+ node_id: str,
+ node_type: NodeType,
+ node_execution_id: str,
+ enclosing_node_id: str | None = None,
+ ) -> DraftVariableSaver:
+ return DraftVariableSaverImpl(
+ session=session,
+ app_id=app_id,
+ node_id=node_id,
+ node_type=node_type,
+ node_execution_id=node_execution_id,
+ enclosing_node_id=enclosing_node_id,
+ )
+ else:
+
+ def draft_var_saver_factory(
+ session: Session,
+ app_id: str,
+ node_id: str,
+ node_type: NodeType,
+ node_execution_id: str,
+ enclosing_node_id: str | None = None,
+ ) -> DraftVariableSaver:
+ return NoopDraftVariableSaver()
+
+ return draft_var_saver_factory
diff --git a/api/core/app/apps/chat/app_generator.py b/api/core/app/apps/chat/app_generator.py
index a1329cb938..a28c106ce9 100644
--- a/api/core/app/apps/chat/app_generator.py
+++ b/api/core/app/apps/chat/app_generator.py
@@ -25,7 +25,6 @@ from factories import file_factory
from models.account import Account
from models.model import App, EndUser
from services.conversation_service import ConversationService
-from services.errors.message import MessageNotExistsError
logger = logging.getLogger(__name__)
@@ -115,6 +114,11 @@ class ChatAppGenerator(MessageBasedAppGenerator):
override_model_config_dict["retriever_resource"] = {"enabled": True}
# parse files
+ # TODO(QuantumGhost): Move file parsing logic to the API controller layer
+ # for better separation of concerns.
+ #
+ # For implementation reference, see the `_parse_file` function and
+ # `DraftWorkflowNodeRunApi` class which handle this properly.
files = args["files"] if args.get("files") else []
file_extra_config = FileUploadConfigManager.convert(override_model_config_dict or app_model_config.to_dict())
if file_extra_config:
@@ -219,8 +223,6 @@ class ChatAppGenerator(MessageBasedAppGenerator):
# get conversation and message
conversation = self._get_conversation(conversation_id)
message = self._get_message(message_id)
- if message is None:
- raise MessageNotExistsError("Message not exists")
# chatbot app
runner = ChatAppRunner()
diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py
index 6f524a5872..34a1da2227 100644
--- a/api/core/app/apps/common/workflow_response_converter.py
+++ b/api/core/app/apps/common/workflow_response_converter.py
@@ -44,10 +44,12 @@ from core.app.entities.task_entities import (
)
from core.file import FILE_MODEL_IDENTITY, File
from core.tools.tool_manager import ToolManager
+from core.variables.segments import ArrayFileSegment, FileSegment, Segment
from core.workflow.entities.workflow_execution import WorkflowExecution
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecution, WorkflowNodeExecutionStatus
from core.workflow.nodes import NodeType
from core.workflow.nodes.tool.entities import ToolNodeData
+from core.workflow.workflow_type_encoder import WorkflowRuntimeTypeConverter
from models import (
Account,
CreatorUserRole,
@@ -125,7 +127,7 @@ class WorkflowResponseConverter:
id=workflow_execution.id_,
workflow_id=workflow_execution.workflow_id,
status=workflow_execution.status,
- outputs=workflow_execution.outputs,
+ outputs=WorkflowRuntimeTypeConverter().to_json_encodable(workflow_execution.outputs),
error=workflow_execution.error_message,
elapsed_time=workflow_execution.elapsed_time,
total_tokens=workflow_execution.total_tokens,
@@ -202,6 +204,8 @@ class WorkflowResponseConverter:
if not workflow_node_execution.finished_at:
return None
+ json_converter = WorkflowRuntimeTypeConverter()
+
return NodeFinishStreamResponse(
task_id=task_id,
workflow_run_id=workflow_node_execution.workflow_execution_id,
@@ -214,7 +218,7 @@ class WorkflowResponseConverter:
predecessor_node_id=workflow_node_execution.predecessor_node_id,
inputs=workflow_node_execution.inputs,
process_data=workflow_node_execution.process_data,
- outputs=workflow_node_execution.outputs,
+ outputs=json_converter.to_json_encodable(workflow_node_execution.outputs),
status=workflow_node_execution.status,
error=workflow_node_execution.error,
elapsed_time=workflow_node_execution.elapsed_time,
@@ -245,6 +249,8 @@ class WorkflowResponseConverter:
if not workflow_node_execution.finished_at:
return None
+ json_converter = WorkflowRuntimeTypeConverter()
+
return NodeRetryStreamResponse(
task_id=task_id,
workflow_run_id=workflow_node_execution.workflow_execution_id,
@@ -257,7 +263,7 @@ class WorkflowResponseConverter:
predecessor_node_id=workflow_node_execution.predecessor_node_id,
inputs=workflow_node_execution.inputs,
process_data=workflow_node_execution.process_data,
- outputs=workflow_node_execution.outputs,
+ outputs=json_converter.to_json_encodable(workflow_node_execution.outputs),
status=workflow_node_execution.status,
error=workflow_node_execution.error,
elapsed_time=workflow_node_execution.elapsed_time,
@@ -376,6 +382,7 @@ class WorkflowResponseConverter:
workflow_execution_id: str,
event: QueueIterationCompletedEvent,
) -> IterationNodeCompletedStreamResponse:
+ json_converter = WorkflowRuntimeTypeConverter()
return IterationNodeCompletedStreamResponse(
task_id=task_id,
workflow_run_id=workflow_execution_id,
@@ -384,7 +391,7 @@ class WorkflowResponseConverter:
node_id=event.node_id,
node_type=event.node_type.value,
title=event.node_data.title,
- outputs=event.outputs,
+ outputs=json_converter.to_json_encodable(event.outputs),
created_at=int(time.time()),
extras={},
inputs=event.inputs or {},
@@ -463,7 +470,7 @@ class WorkflowResponseConverter:
node_id=event.node_id,
node_type=event.node_type.value,
title=event.node_data.title,
- outputs=event.outputs,
+ outputs=WorkflowRuntimeTypeConverter().to_json_encodable(event.outputs),
created_at=int(time.time()),
extras={},
inputs=event.inputs or {},
@@ -500,7 +507,8 @@ class WorkflowResponseConverter:
# Convert to tuple to match Sequence type
return tuple(flattened_files)
- def _fetch_files_from_variable_value(self, value: Union[dict, list]) -> Sequence[Mapping[str, Any]]:
+ @classmethod
+ def _fetch_files_from_variable_value(cls, value: Union[dict, list, Segment]) -> Sequence[Mapping[str, Any]]:
"""
Fetch files from variable value
:param value: variable value
@@ -509,20 +517,30 @@ class WorkflowResponseConverter:
if not value:
return []
- files = []
- if isinstance(value, list):
+ files: list[Mapping[str, Any]] = []
+ if isinstance(value, FileSegment):
+ files.append(value.value.to_dict())
+ elif isinstance(value, ArrayFileSegment):
+ files.extend([i.to_dict() for i in value.value])
+ elif isinstance(value, File):
+ files.append(value.to_dict())
+ elif isinstance(value, list):
for item in value:
- file = self._get_file_var_from_value(item)
+ file = cls._get_file_var_from_value(item)
if file:
files.append(file)
- elif isinstance(value, dict):
- file = self._get_file_var_from_value(value)
+ elif isinstance(
+ value,
+ dict,
+ ):
+ file = cls._get_file_var_from_value(value)
if file:
files.append(file)
return files
- def _get_file_var_from_value(self, value: Union[dict, list]) -> Mapping[str, Any] | None:
+ @classmethod
+ def _get_file_var_from_value(cls, value: Union[dict, list]) -> Mapping[str, Any] | None:
"""
Get file var from value
:param value: variable value
diff --git a/api/core/app/apps/completion/app_generator.py b/api/core/app/apps/completion/app_generator.py
index adcbaad3ec..966a6f1d66 100644
--- a/api/core/app/apps/completion/app_generator.py
+++ b/api/core/app/apps/completion/app_generator.py
@@ -101,6 +101,11 @@ class CompletionAppGenerator(MessageBasedAppGenerator):
)
# parse files
+ # TODO(QuantumGhost): Move file parsing logic to the API controller layer
+ # for better separation of concerns.
+ #
+ # For implementation reference, see the `_parse_file` function and
+ # `DraftWorkflowNodeRunApi` class which handle this properly.
files = args["files"] if args.get("files") else []
file_extra_config = FileUploadConfigManager.convert(override_model_config_dict or app_model_config.to_dict())
if file_extra_config:
@@ -196,8 +201,6 @@ class CompletionAppGenerator(MessageBasedAppGenerator):
try:
# get message
message = self._get_message(message_id)
- if message is None:
- raise MessageNotExistsError()
# chatbot app
runner = CompletionAppRunner()
diff --git a/api/core/app/apps/message_based_app_generator.py b/api/core/app/apps/message_based_app_generator.py
index 58b94f4d43..e84d59209d 100644
--- a/api/core/app/apps/message_based_app_generator.py
+++ b/api/core/app/apps/message_based_app_generator.py
@@ -29,6 +29,7 @@ from models.enums import CreatorUserRole
from models.model import App, AppMode, AppModelConfig, Conversation, EndUser, Message, MessageFile
from services.errors.app_model_config import AppModelConfigBrokenError
from services.errors.conversation import ConversationNotExistsError
+from services.errors.message import MessageNotExistsError
logger = logging.getLogger(__name__)
@@ -251,7 +252,7 @@ class MessageBasedAppGenerator(BaseAppGenerator):
return introduction or ""
- def _get_conversation(self, conversation_id: str):
+ def _get_conversation(self, conversation_id: str) -> Conversation:
"""
Get conversation by conversation id
:param conversation_id: conversation id
@@ -260,11 +261,11 @@ class MessageBasedAppGenerator(BaseAppGenerator):
conversation = db.session.query(Conversation).filter(Conversation.id == conversation_id).first()
if not conversation:
- raise ConversationNotExistsError()
+ raise ConversationNotExistsError("Conversation not exists")
return conversation
- def _get_message(self, message_id: str) -> Optional[Message]:
+ def _get_message(self, message_id: str) -> Message:
"""
Get message by message id
:param message_id: message id
@@ -272,4 +273,7 @@ class MessageBasedAppGenerator(BaseAppGenerator):
"""
message = db.session.query(Message).filter(Message.id == message_id).first()
+ if message is None:
+ raise MessageNotExistsError("Message not exists")
+
return message
diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py
index f4aec3479b..2f9632e97d 100644
--- a/api/core/app/apps/workflow/app_generator.py
+++ b/api/core/app/apps/workflow/app_generator.py
@@ -5,7 +5,7 @@ import uuid
from collections.abc import Generator, Mapping, Sequence
from typing import Any, Literal, Optional, Union, overload
-from flask import Flask, copy_current_request_context, current_app, has_request_context
+from flask import Flask, current_app
from pydantic import ValidationError
from sqlalchemy.orm import sessionmaker
@@ -23,14 +23,17 @@ from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerat
from core.app.entities.task_entities import WorkflowAppBlockingResponse, WorkflowAppStreamResponse
from core.model_runtime.errors.invoke import InvokeAuthorizationError
from core.ops.ops_trace_manager import TraceQueueManager
-from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository
-from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
+from core.repositories import DifyCoreRepositoryFactory
+from core.workflow.repositories.draft_variable_repository import DraftVariableSaverFactory
from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository
from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository
+from core.workflow.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader
from extensions.ext_database import db
from factories import file_factory
+from libs.flask_utils import preserve_flask_contexts
from models import Account, App, EndUser, Workflow, WorkflowNodeExecutionTriggeredFrom
from models.enums import WorkflowRunTriggeredFrom
+from services.workflow_draft_variable_service import DraftVarLoader, WorkflowDraftVariableService
logger = logging.getLogger(__name__)
@@ -93,6 +96,11 @@ class WorkflowAppGenerator(BaseAppGenerator):
files: Sequence[Mapping[str, Any]] = args.get("files") or []
# parse files
+ # TODO(QuantumGhost): Move file parsing logic to the API controller layer
+ # for better separation of concerns.
+ #
+ # For implementation reference, see the `_parse_file` function and
+ # `DraftWorkflowNodeRunApi` class which handle this properly.
file_extra_config = FileUploadConfigManager.convert(workflow.features_dict, is_vision=False)
system_files = file_factory.build_from_mappings(
mappings=files,
@@ -147,14 +155,14 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow_triggered_from = WorkflowRunTriggeredFrom.DEBUGGING
else:
workflow_triggered_from = WorkflowRunTriggeredFrom.APP_RUN
- workflow_execution_repository = SQLAlchemyWorkflowExecutionRepository(
+ workflow_execution_repository = DifyCoreRepositoryFactory.create_workflow_execution_repository(
session_factory=session_factory,
user=user,
app_id=application_generate_entity.app_config.app_id,
triggered_from=workflow_triggered_from,
)
# Create workflow node execution repository
- workflow_node_execution_repository = SQLAlchemyWorkflowNodeExecutionRepository(
+ workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
session_factory=session_factory,
user=user,
app_id=application_generate_entity.app_config.app_id,
@@ -185,6 +193,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
streaming: bool = True,
workflow_thread_pool_id: Optional[str] = None,
+ variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER,
) -> Union[Mapping[str, Any], Generator[str | Mapping[str, Any], None, None]]:
"""
Generate App response.
@@ -194,6 +203,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
:param user: account or end user
:param application_generate_entity: application generate entity
:param invoke_from: invoke from source
+ :param workflow_execution_repository: repository for workflow execution
:param workflow_node_execution_repository: repository for workflow node execution
:param streaming: is stream
:param workflow_thread_pool_id: workflow thread pool id
@@ -209,22 +219,27 @@ class WorkflowAppGenerator(BaseAppGenerator):
# new thread with request context and contextvars
context = contextvars.copy_context()
- @copy_current_request_context
- def worker_with_context():
- # Run the worker within the copied context
- return context.run(
- self._generate_worker,
- flask_app=current_app._get_current_object(), # type: ignore
- application_generate_entity=application_generate_entity,
- queue_manager=queue_manager,
- context=context,
- workflow_thread_pool_id=workflow_thread_pool_id,
- )
-
- worker_thread = threading.Thread(target=worker_with_context)
+ # release database connection, because the following new thread operations may take a long time
+ db.session.close()
+
+ worker_thread = threading.Thread(
+ target=self._generate_worker,
+ kwargs={
+ "flask_app": current_app._get_current_object(), # type: ignore
+ "application_generate_entity": application_generate_entity,
+ "queue_manager": queue_manager,
+ "context": context,
+ "workflow_thread_pool_id": workflow_thread_pool_id,
+ "variable_loader": variable_loader,
+ },
+ )
worker_thread.start()
+ draft_var_saver_factory = self._get_draft_var_saver_factory(
+ invoke_from,
+ )
+
# return response or stream generator
response = self._handle_response(
application_generate_entity=application_generate_entity,
@@ -233,6 +248,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
user=user,
workflow_execution_repository=workflow_execution_repository,
workflow_node_execution_repository=workflow_node_execution_repository,
+ draft_var_saver_factory=draft_var_saver_factory,
stream=streaming,
)
@@ -289,21 +305,26 @@ class WorkflowAppGenerator(BaseAppGenerator):
# Create session factory
session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
# Create workflow execution(aka workflow run) repository
- workflow_execution_repository = SQLAlchemyWorkflowExecutionRepository(
+ workflow_execution_repository = DifyCoreRepositoryFactory.create_workflow_execution_repository(
session_factory=session_factory,
user=user,
app_id=application_generate_entity.app_config.app_id,
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
)
# Create workflow node execution repository
- session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
-
- workflow_node_execution_repository = SQLAlchemyWorkflowNodeExecutionRepository(
+ workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
session_factory=session_factory,
user=user,
app_id=application_generate_entity.app_config.app_id,
triggered_from=WorkflowNodeExecutionTriggeredFrom.SINGLE_STEP,
)
+ draft_var_srv = WorkflowDraftVariableService(db.session())
+ draft_var_srv.prefill_conversation_variable_default_values(workflow)
+ var_loader = DraftVarLoader(
+ engine=db.engine,
+ app_id=application_generate_entity.app_config.app_id,
+ tenant_id=application_generate_entity.app_config.tenant_id,
+ )
return self._generate(
app_model=app_model,
@@ -314,6 +335,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow_execution_repository=workflow_execution_repository,
workflow_node_execution_repository=workflow_node_execution_repository,
streaming=streaming,
+ variable_loader=var_loader,
)
def single_loop_generate(
@@ -365,22 +387,26 @@ class WorkflowAppGenerator(BaseAppGenerator):
# Create session factory
session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
# Create workflow execution(aka workflow run) repository
- workflow_execution_repository = SQLAlchemyWorkflowExecutionRepository(
+ workflow_execution_repository = DifyCoreRepositoryFactory.create_workflow_execution_repository(
session_factory=session_factory,
user=user,
app_id=application_generate_entity.app_config.app_id,
triggered_from=WorkflowRunTriggeredFrom.DEBUGGING,
)
# Create workflow node execution repository
- session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
-
- workflow_node_execution_repository = SQLAlchemyWorkflowNodeExecutionRepository(
+ workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
session_factory=session_factory,
user=user,
app_id=application_generate_entity.app_config.app_id,
triggered_from=WorkflowNodeExecutionTriggeredFrom.SINGLE_STEP,
)
-
+ draft_var_srv = WorkflowDraftVariableService(db.session())
+ draft_var_srv.prefill_conversation_variable_default_values(workflow)
+ var_loader = DraftVarLoader(
+ engine=db.engine,
+ app_id=application_generate_entity.app_config.app_id,
+ tenant_id=application_generate_entity.app_config.tenant_id,
+ )
return self._generate(
app_model=app_model,
workflow=workflow,
@@ -390,6 +416,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
workflow_execution_repository=workflow_execution_repository,
workflow_node_execution_repository=workflow_node_execution_repository,
streaming=streaming,
+ variable_loader=var_loader,
)
def _generate_worker(
@@ -398,6 +425,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
application_generate_entity: WorkflowAppGenerateEntity,
queue_manager: AppQueueManager,
context: contextvars.Context,
+ variable_loader: VariableLoader,
workflow_thread_pool_id: Optional[str] = None,
) -> None:
"""
@@ -408,29 +436,15 @@ class WorkflowAppGenerator(BaseAppGenerator):
:param workflow_thread_pool_id: workflow thread pool id
:return:
"""
- for var, val in context.items():
- var.set(val)
- # FIXME(-LAN-): Save current user before entering new app context
- from flask import g
-
- saved_user = None
- if has_request_context() and hasattr(g, "_login_user"):
- saved_user = g._login_user
-
- with flask_app.app_context():
+ with preserve_flask_contexts(flask_app, context_vars=context):
try:
- # Restore user in new app context
- if saved_user is not None:
- from flask import g
-
- g._login_user = saved_user
-
# workflow app
runner = WorkflowAppRunner(
application_generate_entity=application_generate_entity,
queue_manager=queue_manager,
workflow_thread_pool_id=workflow_thread_pool_id,
+ variable_loader=variable_loader,
)
runner.run()
@@ -461,6 +475,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
user: Union[Account, EndUser],
workflow_execution_repository: WorkflowExecutionRepository,
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
+ draft_var_saver_factory: DraftVariableSaverFactory,
stream: bool = False,
) -> Union[WorkflowAppBlockingResponse, Generator[WorkflowAppStreamResponse, None, None]]:
"""
@@ -481,6 +496,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
user=user,
workflow_execution_repository=workflow_execution_repository,
workflow_node_execution_repository=workflow_node_execution_repository,
+ draft_var_saver_factory=draft_var_saver_factory,
stream=stream,
)
diff --git a/api/core/app/apps/workflow/app_runner.py b/api/core/app/apps/workflow/app_runner.py
index b59e34e222..07aeb57fa3 100644
--- a/api/core/app/apps/workflow/app_runner.py
+++ b/api/core/app/apps/workflow/app_runner.py
@@ -12,6 +12,7 @@ from core.app.entities.app_invoke_entities import (
from core.workflow.callbacks import WorkflowCallback, WorkflowLoggingCallback
from core.workflow.entities.variable_pool import VariablePool
from core.workflow.enums import SystemVariableKey
+from core.workflow.variable_loader import VariableLoader
from core.workflow.workflow_entry import WorkflowEntry
from extensions.ext_database import db
from models.enums import UserFrom
@@ -30,6 +31,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
self,
application_generate_entity: WorkflowAppGenerateEntity,
queue_manager: AppQueueManager,
+ variable_loader: VariableLoader,
workflow_thread_pool_id: Optional[str] = None,
) -> None:
"""
@@ -37,10 +39,13 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
:param queue_manager: application queue manager
:param workflow_thread_pool_id: workflow thread pool id
"""
+ super().__init__(queue_manager, variable_loader)
self.application_generate_entity = application_generate_entity
- self.queue_manager = queue_manager
self.workflow_thread_pool_id = workflow_thread_pool_id
+ def _get_app_id(self) -> str:
+ return self.application_generate_entity.app_config.app_id
+
def run(self) -> None:
"""
Run application
diff --git a/api/core/app/apps/workflow/generate_task_pipeline.py b/api/core/app/apps/workflow/generate_task_pipeline.py
index 1734dbb598..c6b326d8a4 100644
--- a/api/core/app/apps/workflow/generate_task_pipeline.py
+++ b/api/core/app/apps/workflow/generate_task_pipeline.py
@@ -3,7 +3,6 @@ import time
from collections.abc import Generator
from typing import Optional, Union
-from sqlalchemy import select
from sqlalchemy.orm import Session
from constants.tts_auto_play_timeout import TTS_AUTO_PLAY_TIMEOUT, TTS_AUTO_PLAY_YIELD_CPU_TIME
@@ -56,6 +55,7 @@ from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk
from core.ops.ops_trace_manager import TraceQueueManager
from core.workflow.entities.workflow_execution import WorkflowExecution, WorkflowExecutionStatus, WorkflowType
from core.workflow.enums import SystemVariableKey
+from core.workflow.repositories.draft_variable_repository import DraftVariableSaverFactory
from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository
from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository
from core.workflow.workflow_cycle_manager import CycleManagerWorkflowInfo, WorkflowCycleManager
@@ -67,7 +67,6 @@ from models.workflow import (
Workflow,
WorkflowAppLog,
WorkflowAppLogCreatedFrom,
- WorkflowRun,
)
logger = logging.getLogger(__name__)
@@ -87,6 +86,7 @@ class WorkflowAppGenerateTaskPipeline:
stream: bool,
workflow_execution_repository: WorkflowExecutionRepository,
workflow_node_execution_repository: WorkflowNodeExecutionRepository,
+ draft_var_saver_factory: DraftVariableSaverFactory,
) -> None:
self._base_task_pipeline = BasedGenerateTaskPipeline(
application_generate_entity=application_generate_entity,
@@ -131,6 +131,8 @@ class WorkflowAppGenerateTaskPipeline:
self._application_generate_entity = application_generate_entity
self._workflow_features_dict = workflow.features_dict
self._workflow_run_id = ""
+ self._invoke_from = queue_manager._invoke_from
+ self._draft_var_saver_factory = draft_var_saver_factory
def process(self) -> Union[WorkflowAppBlockingResponse, Generator[WorkflowAppStreamResponse, None, None]]:
"""
@@ -322,6 +324,8 @@ class WorkflowAppGenerateTaskPipeline:
workflow_node_execution=workflow_node_execution,
)
+ self._save_output_for_event(event, workflow_node_execution.id)
+
if node_success_response:
yield node_success_response
elif isinstance(
@@ -339,6 +343,8 @@ class WorkflowAppGenerateTaskPipeline:
task_id=self._application_generate_entity.task_id,
workflow_node_execution=workflow_node_execution,
)
+ if isinstance(event, QueueNodeExceptionEvent):
+ self._save_output_for_event(event, workflow_node_execution.id)
if node_failed_response:
yield node_failed_response
@@ -554,8 +560,6 @@ class WorkflowAppGenerateTaskPipeline:
tts_publisher.publish(None)
def _save_workflow_app_log(self, *, session: Session, workflow_execution: WorkflowExecution) -> None:
- workflow_run = session.scalar(select(WorkflowRun).where(WorkflowRun.id == workflow_execution.id_))
- assert workflow_run is not None
invoke_from = self._application_generate_entity.invoke_from
if invoke_from == InvokeFrom.SERVICE_API:
created_from = WorkflowAppLogCreatedFrom.SERVICE_API
@@ -568,10 +572,10 @@ class WorkflowAppGenerateTaskPipeline:
return
workflow_app_log = WorkflowAppLog()
- workflow_app_log.tenant_id = workflow_run.tenant_id
- workflow_app_log.app_id = workflow_run.app_id
- workflow_app_log.workflow_id = workflow_run.workflow_id
- workflow_app_log.workflow_run_id = workflow_run.id
+ workflow_app_log.tenant_id = self._application_generate_entity.app_config.tenant_id
+ workflow_app_log.app_id = self._application_generate_entity.app_config.app_id
+ workflow_app_log.workflow_id = workflow_execution.workflow_id
+ workflow_app_log.workflow_run_id = workflow_execution.id_
workflow_app_log.created_from = created_from.value
workflow_app_log.created_by_role = self._created_by_role
workflow_app_log.created_by = self._user_id
@@ -593,3 +597,15 @@ class WorkflowAppGenerateTaskPipeline:
)
return response
+
+ def _save_output_for_event(self, event: QueueNodeSucceededEvent | QueueNodeExceptionEvent, node_execution_id: str):
+ with Session(db.engine) as session, session.begin():
+ saver = self._draft_var_saver_factory(
+ session=session,
+ app_id=self._application_generate_entity.app_config.app_id,
+ node_id=event.node_id,
+ node_type=event.node_type,
+ node_execution_id=node_execution_id,
+ enclosing_node_id=event.in_loop_id or event.in_iteration_id,
+ )
+ saver.save(event.process_data, event.outputs)
diff --git a/api/core/app/apps/workflow_app_runner.py b/api/core/app/apps/workflow_app_runner.py
index facc24b4ca..17b9ac5827 100644
--- a/api/core/app/apps/workflow_app_runner.py
+++ b/api/core/app/apps/workflow_app_runner.py
@@ -62,6 +62,7 @@ from core.workflow.graph_engine.entities.event import (
from core.workflow.graph_engine.entities.graph import Graph
from core.workflow.nodes import NodeType
from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING
+from core.workflow.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_into_variable_pool
from core.workflow.workflow_entry import WorkflowEntry
from extensions.ext_database import db
from models.model import App
@@ -69,8 +70,12 @@ from models.workflow import Workflow
class WorkflowBasedAppRunner(AppRunner):
- def __init__(self, queue_manager: AppQueueManager):
+ def __init__(self, queue_manager: AppQueueManager, variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER) -> None:
self.queue_manager = queue_manager
+ self._variable_loader = variable_loader
+
+ def _get_app_id(self) -> str:
+ raise NotImplementedError("not implemented")
def _init_graph(self, graph_config: Mapping[str, Any]) -> Graph:
"""
@@ -173,6 +178,13 @@ class WorkflowBasedAppRunner(AppRunner):
except NotImplementedError:
variable_mapping = {}
+ load_into_variable_pool(
+ variable_loader=self._variable_loader,
+ variable_pool=variable_pool,
+ variable_mapping=variable_mapping,
+ user_inputs=user_inputs,
+ )
+
WorkflowEntry.mapping_user_inputs_to_variable_pool(
variable_mapping=variable_mapping,
user_inputs=user_inputs,
@@ -262,6 +274,12 @@ class WorkflowBasedAppRunner(AppRunner):
)
except NotImplementedError:
variable_mapping = {}
+ load_into_variable_pool(
+ self._variable_loader,
+ variable_pool=variable_pool,
+ variable_mapping=variable_mapping,
+ user_inputs=user_inputs,
+ )
WorkflowEntry.mapping_user_inputs_to_variable_pool(
variable_mapping=variable_mapping,
@@ -376,6 +394,7 @@ class WorkflowBasedAppRunner(AppRunner):
in_loop_id=event.in_loop_id,
)
)
+
elif isinstance(event, NodeRunFailedEvent):
self._publish_event(
QueueNodeFailedEvent(
@@ -438,6 +457,7 @@ class WorkflowBasedAppRunner(AppRunner):
in_loop_id=event.in_loop_id,
)
)
+
elif isinstance(event, NodeInIterationFailedEvent):
self._publish_event(
QueueNodeInIterationFailedEvent(
diff --git a/api/core/app/entities/app_invoke_entities.py b/api/core/app/entities/app_invoke_entities.py
index c0d99693b0..65ed267959 100644
--- a/api/core/app/entities/app_invoke_entities.py
+++ b/api/core/app/entities/app_invoke_entities.py
@@ -17,9 +17,24 @@ class InvokeFrom(Enum):
Invoke From.
"""
+ # SERVICE_API indicates that this invocation is from an API call to Dify app.
+ #
+ # Description of service api in Dify docs:
+ # https://docs.dify.ai/en/guides/application-publishing/developing-with-apis
SERVICE_API = "service-api"
+
+ # WEB_APP indicates that this invocation is from
+ # the web app of the workflow (or chatflow).
+ #
+ # Description of web app in Dify docs:
+ # https://docs.dify.ai/en/guides/application-publishing/launch-your-webapp-quickly/README
WEB_APP = "web-app"
+
+ # EXPLORE indicates that this invocation is from
+ # the workflow (or chatflow) explore page.
EXPLORE = "explore"
+ # DEBUGGER indicates that this invocation is from
+ # the workflow (or chatflow) edit page.
DEBUGGER = "debugger"
@classmethod
diff --git a/api/core/app/task_pipeline/based_generate_task_pipeline.py b/api/core/app/task_pipeline/based_generate_task_pipeline.py
index 5331c0cc94..3ed0c3352f 100644
--- a/api/core/app/task_pipeline/based_generate_task_pipeline.py
+++ b/api/core/app/task_pipeline/based_generate_task_pipeline.py
@@ -19,6 +19,7 @@ from core.app.entities.task_entities import (
from core.errors.error import QuotaExceededError
from core.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeError
from core.moderation.output_moderation import ModerationRule, OutputModeration
+from models.enums import MessageStatus
from models.model import Message
logger = logging.getLogger(__name__)
@@ -62,7 +63,7 @@ class BasedGenerateTaskPipeline:
return err
err_desc = self._error_to_desc(err)
- message.status = "error"
+ message.status = MessageStatus.ERROR
message.error = err_desc
return err
diff --git a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py
index d535e1f835..3c8c7bb5a2 100644
--- a/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py
+++ b/api/core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py
@@ -395,6 +395,7 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline):
message.provider_response_latency = time.perf_counter() - self._start_at
message.total_price = usage.total_price
message.currency = usage.currency
+ self._task_state.llm_result.usage.latency = message.provider_response_latency
message.message_metadata = self._task_state.metadata.model_dump_json()
if trace_manager:
diff --git a/api/core/entities/parameter_entities.py b/api/core/entities/parameter_entities.py
index 36800bc263..2fa347c204 100644
--- a/api/core/entities/parameter_entities.py
+++ b/api/core/entities/parameter_entities.py
@@ -15,7 +15,15 @@ class CommonParameterType(StrEnum):
MODEL_SELECTOR = "model-selector"
TOOLS_SELECTOR = "array[tools]"
+ # Dynamic select parameter
+ # Once you are not sure about the available options until authorization is done
+ # eg: Select a Slack channel from a Slack workspace
+ DYNAMIC_SELECT = "dynamic-select"
+
# TOOL_SELECTOR = "tool-selector"
+ # MCP object and array type parameters
+ ARRAY = "array"
+ OBJECT = "object"
class AppSelectorScope(StrEnum):
diff --git a/api/core/file/constants.py b/api/core/file/constants.py
index ce1d238e93..0665ed7e0d 100644
--- a/api/core/file/constants.py
+++ b/api/core/file/constants.py
@@ -1 +1,11 @@
+from typing import Any
+
+# TODO(QuantumGhost): Refactor variable type identification. Instead of directly
+# comparing `dify_model_identity` with constants throughout the codebase, extract
+# this logic into a dedicated function. This would encapsulate the implementation
+# details of how different variable types are identified.
FILE_MODEL_IDENTITY = "__dify__file__"
+
+
+def maybe_file_object(o: Any) -> bool:
+ return isinstance(o, dict) and o.get("dify_model_identity") == FILE_MODEL_IDENTITY
diff --git a/api/core/file/helpers.py b/api/core/file/helpers.py
index 73fabdb11b..335ad2266a 100644
--- a/api/core/file/helpers.py
+++ b/api/core/file/helpers.py
@@ -21,7 +21,9 @@ def get_signed_file_url(upload_file_id: str) -> str:
def get_signed_file_url_for_plugin(filename: str, mimetype: str, tenant_id: str, user_id: str) -> str:
- url = f"{dify_config.FILES_URL}/files/upload/for-plugin"
+ # Plugin access should use internal URL for Docker network communication
+ base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL
+ url = f"{base_url}/files/upload/for-plugin"
if user_id is None:
user_id = "DEFAULT-USER"
diff --git a/api/core/file/models.py b/api/core/file/models.py
index aa3b5f629c..f61334e7bc 100644
--- a/api/core/file/models.py
+++ b/api/core/file/models.py
@@ -51,7 +51,7 @@ class File(BaseModel):
# It should be set to `ToolFile.id` when `transfer_method` is `tool_file`.
related_id: Optional[str] = None
filename: Optional[str] = None
- extension: Optional[str] = Field(default=None, description="File extension, should contains dot")
+ extension: Optional[str] = Field(default=None, description="File extension, should contain dot")
mime_type: Optional[str] = None
size: int = -1
diff --git a/api/core/file/upload_file_parser.py b/api/core/file/upload_file_parser.py
deleted file mode 100644
index 96b2884811..0000000000
--- a/api/core/file/upload_file_parser.py
+++ /dev/null
@@ -1,67 +0,0 @@
-import base64
-import logging
-import time
-from typing import Optional
-
-from configs import dify_config
-from constants import IMAGE_EXTENSIONS
-from core.helper.url_signer import UrlSigner
-from extensions.ext_storage import storage
-
-
-class UploadFileParser:
- @classmethod
- def get_image_data(cls, upload_file, force_url: bool = False) -> Optional[str]:
- if not upload_file:
- return None
-
- if upload_file.extension not in IMAGE_EXTENSIONS:
- return None
-
- if dify_config.MULTIMODAL_SEND_FORMAT == "url" or force_url:
- return cls.get_signed_temp_image_url(upload_file.id)
- else:
- # get image file base64
- try:
- data = storage.load(upload_file.key)
- except FileNotFoundError:
- logging.exception(f"File not found: {upload_file.key}")
- return None
-
- encoded_string = base64.b64encode(data).decode("utf-8")
- return f"data:{upload_file.mime_type};base64,{encoded_string}"
-
- @classmethod
- def get_signed_temp_image_url(cls, upload_file_id) -> str:
- """
- get signed url from upload file
-
- :param upload_file_id: the id of UploadFile object
- :return:
- """
- base_url = dify_config.FILES_URL
- image_preview_url = f"{base_url}/files/{upload_file_id}/image-preview"
-
- return UrlSigner.get_signed_url(url=image_preview_url, sign_key=upload_file_id, prefix="image-preview")
-
- @classmethod
- def verify_image_file_signature(cls, upload_file_id: str, timestamp: str, nonce: str, sign: str) -> bool:
- """
- verify signature
-
- :param upload_file_id: file id
- :param timestamp: timestamp
- :param nonce: nonce
- :param sign: signature
- :return:
- """
- result = UrlSigner.verify(
- sign_key=upload_file_id, timestamp=timestamp, nonce=nonce, sign=sign, prefix="image-preview"
- )
-
- # verify signature
- if not result:
- return False
-
- current_time = int(time.time())
- return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT
diff --git a/api/core/helper/code_executor/template_transformer.py b/api/core/helper/code_executor/template_transformer.py
index baa792b5bc..b416e48ce4 100644
--- a/api/core/helper/code_executor/template_transformer.py
+++ b/api/core/helper/code_executor/template_transformer.py
@@ -5,6 +5,8 @@ from base64 import b64encode
from collections.abc import Mapping
from typing import Any
+from core.variables.utils import SegmentJSONEncoder
+
class TemplateTransformer(ABC):
_code_placeholder: str = "{{code}}"
@@ -28,7 +30,7 @@ class TemplateTransformer(ABC):
def extract_result_str_from_response(cls, response: str):
result = re.search(rf"{cls._result_tag}(.*){cls._result_tag}", response, re.DOTALL)
if not result:
- raise ValueError("Failed to parse result")
+ raise ValueError(f"Failed to parse result: no result tag found in response. Response: {response[:200]}...")
return result.group(1)
@classmethod
@@ -38,16 +40,49 @@ class TemplateTransformer(ABC):
:param response: response
:return:
"""
+
try:
- result = json.loads(cls.extract_result_str_from_response(response))
- except json.JSONDecodeError:
- raise ValueError("failed to parse response")
+ result_str = cls.extract_result_str_from_response(response)
+ result = json.loads(result_str)
+ except json.JSONDecodeError as e:
+ raise ValueError(f"Failed to parse JSON response: {str(e)}.")
+ except ValueError as e:
+ # Re-raise ValueError from extract_result_str_from_response
+ raise e
+ except Exception as e:
+ raise ValueError(f"Unexpected error during response transformation: {str(e)}")
+
if not isinstance(result, dict):
- raise ValueError("result must be a dict")
+ raise ValueError(f"Result must be a dict, got {type(result).__name__}")
if not all(isinstance(k, str) for k in result):
- raise ValueError("result keys must be strings")
+ raise ValueError("Result keys must be strings")
+
+ # Post-process the result to convert scientific notation strings back to numbers
+ result = cls._post_process_result(result)
return result
+ @classmethod
+ def _post_process_result(cls, result: dict[Any, Any]) -> dict[Any, Any]:
+ """
+ Post-process the result to convert scientific notation strings back to numbers
+ """
+
+ def convert_scientific_notation(value):
+ if isinstance(value, str):
+ # Check if the string looks like scientific notation
+ if re.match(r"^-?\d+\.?\d*e[+-]\d+$", value, re.IGNORECASE):
+ try:
+ return float(value)
+ except ValueError:
+ pass
+ elif isinstance(value, dict):
+ return {k: convert_scientific_notation(v) for k, v in value.items()}
+ elif isinstance(value, list):
+ return [convert_scientific_notation(v) for v in value]
+ return value
+
+ return convert_scientific_notation(result) # type: ignore[no-any-return]
+
@classmethod
@abstractmethod
def get_runner_script(cls) -> str:
@@ -58,7 +93,7 @@ class TemplateTransformer(ABC):
@classmethod
def serialize_inputs(cls, inputs: Mapping[str, Any]) -> str:
- inputs_json_str = json.dumps(inputs, ensure_ascii=False).encode()
+ inputs_json_str = json.dumps(inputs, ensure_ascii=False, cls=SegmentJSONEncoder).encode()
input_base64_encoded = b64encode(inputs_json_str).decode("utf-8")
return input_base64_encoded
diff --git a/api/core/helper/lru_cache.py b/api/core/helper/lru_cache.py
deleted file mode 100644
index 81501d2e4e..0000000000
--- a/api/core/helper/lru_cache.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from collections import OrderedDict
-from typing import Any
-
-
-class LRUCache:
- def __init__(self, capacity: int):
- self.cache: OrderedDict[Any, Any] = OrderedDict()
- self.capacity = capacity
-
- def get(self, key: Any) -> Any:
- if key not in self.cache:
- return None
- else:
- self.cache.move_to_end(key) # move the key to the end of the OrderedDict
- return self.cache[key]
-
- def put(self, key: Any, value: Any) -> None:
- if key in self.cache:
- self.cache.move_to_end(key)
- self.cache[key] = value
- if len(self.cache) > self.capacity:
- self.cache.popitem(last=False) # pop the first item
diff --git a/api/core/indexing_runner.py b/api/core/indexing_runner.py
index 848d897779..305a9190d5 100644
--- a/api/core/indexing_runner.py
+++ b/api/core/indexing_runner.py
@@ -317,9 +317,10 @@ class IndexingRunner:
image_upload_file_ids = get_image_upload_file_ids(document.page_content)
for upload_file_id in image_upload_file_ids:
image_file = db.session.query(UploadFile).filter(UploadFile.id == upload_file_id).first()
+ if image_file is None:
+ continue
try:
- if image_file:
- storage.delete(image_file.key)
+ storage.delete(image_file.key)
except Exception:
logging.exception(
"Delete image_files failed while indexing_estimate, \
@@ -534,7 +535,7 @@ class IndexingRunner:
# chunk nodes by chunk size
indexing_start_at = time.perf_counter()
tokens = 0
- if dataset_document.doc_form != IndexType.PARENT_CHILD_INDEX:
+ if dataset_document.doc_form != IndexType.PARENT_CHILD_INDEX and dataset.indexing_technique == "economy":
# create keyword index
create_keyword_thread = threading.Thread(
target=self._process_keyword_index,
@@ -572,7 +573,7 @@ class IndexingRunner:
for future in futures:
tokens += future.result()
- if dataset_document.doc_form != IndexType.PARENT_CHILD_INDEX:
+ if dataset_document.doc_form != IndexType.PARENT_CHILD_INDEX and dataset.indexing_technique == "economy":
create_keyword_thread.join()
indexing_end_at = time.perf_counter()
diff --git a/api/core/llm_generator/output_parser/structured_output.py b/api/core/llm_generator/output_parser/structured_output.py
new file mode 100644
index 0000000000..151cef1bc3
--- /dev/null
+++ b/api/core/llm_generator/output_parser/structured_output.py
@@ -0,0 +1,380 @@
+import json
+from collections.abc import Generator, Mapping, Sequence
+from copy import deepcopy
+from enum import StrEnum
+from typing import Any, Literal, Optional, cast, overload
+
+import json_repair
+from pydantic import TypeAdapter, ValidationError
+
+from core.llm_generator.output_parser.errors import OutputParserError
+from core.llm_generator.prompts import STRUCTURED_OUTPUT_PROMPT
+from core.model_manager import ModelInstance
+from core.model_runtime.callbacks.base_callback import Callback
+from core.model_runtime.entities.llm_entities import (
+ LLMResult,
+ LLMResultChunk,
+ LLMResultChunkDelta,
+ LLMResultChunkWithStructuredOutput,
+ LLMResultWithStructuredOutput,
+)
+from core.model_runtime.entities.message_entities import (
+ AssistantPromptMessage,
+ PromptMessage,
+ PromptMessageTool,
+ SystemPromptMessage,
+ TextPromptMessageContent,
+)
+from core.model_runtime.entities.model_entities import AIModelEntity, ParameterRule
+
+
+class ResponseFormat(StrEnum):
+ """Constants for model response formats"""
+
+ JSON_SCHEMA = "json_schema" # model's structured output mode. some model like gemini, gpt-4o, support this mode.
+ JSON = "JSON" # model's json mode. some model like claude support this mode.
+ JSON_OBJECT = "json_object" # json mode's another alias. some model like deepseek-chat, qwen use this alias.
+
+
+class SpecialModelType(StrEnum):
+ """Constants for identifying model types"""
+
+ GEMINI = "gemini"
+ OLLAMA = "ollama"
+
+
+@overload
+def invoke_llm_with_structured_output(
+ provider: str,
+ model_schema: AIModelEntity,
+ model_instance: ModelInstance,
+ prompt_messages: Sequence[PromptMessage],
+ json_schema: Mapping[str, Any],
+ model_parameters: Optional[Mapping] = None,
+ tools: Sequence[PromptMessageTool] | None = None,
+ stop: Optional[list[str]] = None,
+ stream: Literal[True] = True,
+ user: Optional[str] = None,
+ callbacks: Optional[list[Callback]] = None,
+) -> Generator[LLMResultChunkWithStructuredOutput, None, None]: ...
+
+
+@overload
+def invoke_llm_with_structured_output(
+ provider: str,
+ model_schema: AIModelEntity,
+ model_instance: ModelInstance,
+ prompt_messages: Sequence[PromptMessage],
+ json_schema: Mapping[str, Any],
+ model_parameters: Optional[Mapping] = None,
+ tools: Sequence[PromptMessageTool] | None = None,
+ stop: Optional[list[str]] = None,
+ stream: Literal[False] = False,
+ user: Optional[str] = None,
+ callbacks: Optional[list[Callback]] = None,
+) -> LLMResultWithStructuredOutput: ...
+
+
+@overload
+def invoke_llm_with_structured_output(
+ provider: str,
+ model_schema: AIModelEntity,
+ model_instance: ModelInstance,
+ prompt_messages: Sequence[PromptMessage],
+ json_schema: Mapping[str, Any],
+ model_parameters: Optional[Mapping] = None,
+ tools: Sequence[PromptMessageTool] | None = None,
+ stop: Optional[list[str]] = None,
+ stream: bool = True,
+ user: Optional[str] = None,
+ callbacks: Optional[list[Callback]] = None,
+) -> LLMResultWithStructuredOutput | Generator[LLMResultChunkWithStructuredOutput, None, None]: ...
+
+
+def invoke_llm_with_structured_output(
+ provider: str,
+ model_schema: AIModelEntity,
+ model_instance: ModelInstance,
+ prompt_messages: Sequence[PromptMessage],
+ json_schema: Mapping[str, Any],
+ model_parameters: Optional[Mapping] = None,
+ tools: Sequence[PromptMessageTool] | None = None,
+ stop: Optional[list[str]] = None,
+ stream: bool = True,
+ user: Optional[str] = None,
+ callbacks: Optional[list[Callback]] = None,
+) -> LLMResultWithStructuredOutput | Generator[LLMResultChunkWithStructuredOutput, None, None]:
+ """
+ Invoke large language model with structured output
+ 1. This method invokes model_instance.invoke_llm with json_schema
+ 2. Try to parse the result as structured output
+
+ :param prompt_messages: prompt messages
+ :param json_schema: json schema
+ :param model_parameters: model parameters
+ :param tools: tools for tool calling
+ :param stop: stop words
+ :param stream: is stream response
+ :param user: unique user id
+ :param callbacks: callbacks
+ :return: full response or stream response chunk generator result
+ """
+
+ # handle native json schema
+ model_parameters_with_json_schema: dict[str, Any] = {
+ **(model_parameters or {}),
+ }
+
+ if model_schema.support_structure_output:
+ model_parameters = _handle_native_json_schema(
+ provider, model_schema, json_schema, model_parameters_with_json_schema, model_schema.parameter_rules
+ )
+ else:
+ # Set appropriate response format based on model capabilities
+ _set_response_format(model_parameters_with_json_schema, model_schema.parameter_rules)
+
+ # handle prompt based schema
+ prompt_messages = _handle_prompt_based_schema(
+ prompt_messages=prompt_messages,
+ structured_output_schema=json_schema,
+ )
+
+ llm_result = model_instance.invoke_llm(
+ prompt_messages=list(prompt_messages),
+ model_parameters=model_parameters_with_json_schema,
+ tools=tools,
+ stop=stop,
+ stream=stream,
+ user=user,
+ callbacks=callbacks,
+ )
+
+ if isinstance(llm_result, LLMResult):
+ if not isinstance(llm_result.message.content, str):
+ raise OutputParserError(
+ f"Failed to parse structured output, LLM result is not a string: {llm_result.message.content}"
+ )
+
+ return LLMResultWithStructuredOutput(
+ structured_output=_parse_structured_output(llm_result.message.content),
+ model=llm_result.model,
+ message=llm_result.message,
+ usage=llm_result.usage,
+ system_fingerprint=llm_result.system_fingerprint,
+ prompt_messages=llm_result.prompt_messages,
+ )
+ else:
+
+ def generator() -> Generator[LLMResultChunkWithStructuredOutput, None, None]:
+ result_text: str = ""
+ prompt_messages: Sequence[PromptMessage] = []
+ system_fingerprint: Optional[str] = None
+ for event in llm_result:
+ if isinstance(event, LLMResultChunk):
+ prompt_messages = event.prompt_messages
+ system_fingerprint = event.system_fingerprint
+
+ if isinstance(event.delta.message.content, str):
+ result_text += event.delta.message.content
+ elif isinstance(event.delta.message.content, list):
+ for item in event.delta.message.content:
+ if isinstance(item, TextPromptMessageContent):
+ result_text += item.data
+
+ yield LLMResultChunkWithStructuredOutput(
+ model=model_schema.model,
+ prompt_messages=prompt_messages,
+ system_fingerprint=system_fingerprint,
+ delta=event.delta,
+ )
+
+ yield LLMResultChunkWithStructuredOutput(
+ structured_output=_parse_structured_output(result_text),
+ model=model_schema.model,
+ prompt_messages=prompt_messages,
+ system_fingerprint=system_fingerprint,
+ delta=LLMResultChunkDelta(
+ index=0,
+ message=AssistantPromptMessage(content=""),
+ usage=None,
+ finish_reason=None,
+ ),
+ )
+
+ return generator()
+
+
+def _handle_native_json_schema(
+ provider: str,
+ model_schema: AIModelEntity,
+ structured_output_schema: Mapping,
+ model_parameters: dict,
+ rules: list[ParameterRule],
+) -> dict:
+ """
+ Handle structured output for models with native JSON schema support.
+
+ :param model_parameters: Model parameters to update
+ :param rules: Model parameter rules
+ :return: Updated model parameters with JSON schema configuration
+ """
+ # Process schema according to model requirements
+ schema_json = _prepare_schema_for_model(provider, model_schema, structured_output_schema)
+
+ # Set JSON schema in parameters
+ model_parameters["json_schema"] = json.dumps(schema_json, ensure_ascii=False)
+
+ # Set appropriate response format if required by the model
+ for rule in rules:
+ if rule.name == "response_format" and ResponseFormat.JSON_SCHEMA.value in rule.options:
+ model_parameters["response_format"] = ResponseFormat.JSON_SCHEMA.value
+
+ return model_parameters
+
+
+def _set_response_format(model_parameters: dict, rules: list) -> None:
+ """
+ Set the appropriate response format parameter based on model rules.
+
+ :param model_parameters: Model parameters to update
+ :param rules: Model parameter rules
+ """
+ for rule in rules:
+ if rule.name == "response_format":
+ if ResponseFormat.JSON.value in rule.options:
+ model_parameters["response_format"] = ResponseFormat.JSON.value
+ elif ResponseFormat.JSON_OBJECT.value in rule.options:
+ model_parameters["response_format"] = ResponseFormat.JSON_OBJECT.value
+
+
+def _handle_prompt_based_schema(
+ prompt_messages: Sequence[PromptMessage], structured_output_schema: Mapping
+) -> list[PromptMessage]:
+ """
+ Handle structured output for models without native JSON schema support.
+ This function modifies the prompt messages to include schema-based output requirements.
+
+ Args:
+ prompt_messages: Original sequence of prompt messages
+
+ Returns:
+ list[PromptMessage]: Updated prompt messages with structured output requirements
+ """
+ # Convert schema to string format
+ schema_str = json.dumps(structured_output_schema, ensure_ascii=False)
+
+ # Find existing system prompt with schema placeholder
+ system_prompt = next(
+ (prompt for prompt in prompt_messages if isinstance(prompt, SystemPromptMessage)),
+ None,
+ )
+ structured_output_prompt = STRUCTURED_OUTPUT_PROMPT.replace("{{schema}}", schema_str)
+ # Prepare system prompt content
+ system_prompt_content = (
+ structured_output_prompt + "\n\n" + system_prompt.content
+ if system_prompt and isinstance(system_prompt.content, str)
+ else structured_output_prompt
+ )
+ system_prompt = SystemPromptMessage(content=system_prompt_content)
+
+ # Extract content from the last user message
+
+ filtered_prompts = [prompt for prompt in prompt_messages if not isinstance(prompt, SystemPromptMessage)]
+ updated_prompt = [system_prompt] + filtered_prompts
+
+ return updated_prompt
+
+
+def _parse_structured_output(result_text: str) -> Mapping[str, Any]:
+ structured_output: Mapping[str, Any] = {}
+ parsed: Mapping[str, Any] = {}
+ try:
+ parsed = TypeAdapter(Mapping).validate_json(result_text)
+ if not isinstance(parsed, dict):
+ raise OutputParserError(f"Failed to parse structured output: {result_text}")
+ structured_output = parsed
+ except ValidationError:
+ # if the result_text is not a valid json, try to repair it
+ temp_parsed = json_repair.loads(result_text)
+ if not isinstance(temp_parsed, dict):
+ # handle reasoning model like deepseek-r1 got '\n\n\n' prefix
+ if isinstance(temp_parsed, list):
+ temp_parsed = next((item for item in temp_parsed if isinstance(item, dict)), {})
+ else:
+ raise OutputParserError(f"Failed to parse structured output: {result_text}")
+ structured_output = cast(dict, temp_parsed)
+ return structured_output
+
+
+def _prepare_schema_for_model(provider: str, model_schema: AIModelEntity, schema: Mapping) -> dict:
+ """
+ Prepare JSON schema based on model requirements.
+
+ Different models have different requirements for JSON schema formatting.
+ This function handles these differences.
+
+ :param schema: The original JSON schema
+ :return: Processed schema compatible with the current model
+ """
+
+ # Deep copy to avoid modifying the original schema
+ processed_schema = dict(deepcopy(schema))
+
+ # Convert boolean types to string types (common requirement)
+ convert_boolean_to_string(processed_schema)
+
+ # Apply model-specific transformations
+ if SpecialModelType.GEMINI in model_schema.model:
+ remove_additional_properties(processed_schema)
+ return processed_schema
+ elif SpecialModelType.OLLAMA in provider:
+ return processed_schema
+ else:
+ # Default format with name field
+ return {"schema": processed_schema, "name": "llm_response"}
+
+
+def remove_additional_properties(schema: dict) -> None:
+ """
+ Remove additionalProperties fields from JSON schema.
+ Used for models like Gemini that don't support this property.
+
+ :param schema: JSON schema to modify in-place
+ """
+ if not isinstance(schema, dict):
+ return
+
+ # Remove additionalProperties at current level
+ schema.pop("additionalProperties", None)
+
+ # Process nested structures recursively
+ for value in schema.values():
+ if isinstance(value, dict):
+ remove_additional_properties(value)
+ elif isinstance(value, list):
+ for item in value:
+ if isinstance(item, dict):
+ remove_additional_properties(item)
+
+
+def convert_boolean_to_string(schema: dict) -> None:
+ """
+ Convert boolean type specifications to string in JSON schema.
+
+ :param schema: JSON schema to modify in-place
+ """
+ if not isinstance(schema, dict):
+ return
+
+ # Check for boolean type at current level
+ if schema.get("type") == "boolean":
+ schema["type"] = "string"
+
+ # Process nested dictionaries and lists recursively
+ for value in schema.values():
+ if isinstance(value, dict):
+ convert_boolean_to_string(value)
+ elif isinstance(value, list):
+ for item in value:
+ if isinstance(item, dict):
+ convert_boolean_to_string(item)
diff --git a/api/core/llm_generator/prompts.py b/api/core/llm_generator/prompts.py
index ddfa1e7a66..ef81e38dc5 100644
--- a/api/core/llm_generator/prompts.py
+++ b/api/core/llm_generator/prompts.py
@@ -291,3 +291,21 @@ Your task is to convert simple user descriptions into properly formatted JSON Sc
Now, generate a JSON Schema based on my description
""" # noqa: E501
+
+STRUCTURED_OUTPUT_PROMPT = """You’re a helpful AI assistant. You could answer questions and output in JSON format.
+constraints:
+ - You must output in JSON format.
+ - Do not output boolean value, use string type instead.
+ - Do not output integer or float value, use number type instead.
+eg:
+ Here is the JSON schema:
+ {"additionalProperties": false, "properties": {"age": {"type": "number"}, "name": {"type": "string"}}, "required": ["name", "age"], "type": "object"}
+
+ Here is the user's question:
+ My name is John Doe and I am 30 years old.
+
+ output:
+ {"name": "John Doe", "age": 30}
+Here is the JSON schema:
+{{schema}}
+""" # noqa: E501
diff --git a/api/core/mcp/__init__.py b/api/core/mcp/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/core/mcp/auth/auth_flow.py b/api/core/mcp/auth/auth_flow.py
new file mode 100644
index 0000000000..b63478e822
--- /dev/null
+++ b/api/core/mcp/auth/auth_flow.py
@@ -0,0 +1,342 @@
+import base64
+import hashlib
+import json
+import os
+import secrets
+import urllib.parse
+from typing import Optional
+from urllib.parse import urljoin
+
+import requests
+from pydantic import BaseModel, ValidationError
+
+from core.mcp.auth.auth_provider import OAuthClientProvider
+from core.mcp.types import (
+ OAuthClientInformation,
+ OAuthClientInformationFull,
+ OAuthClientMetadata,
+ OAuthMetadata,
+ OAuthTokens,
+)
+from extensions.ext_redis import redis_client
+
+LATEST_PROTOCOL_VERSION = "1.0"
+OAUTH_STATE_EXPIRY_SECONDS = 5 * 60 # 5 minutes expiry
+OAUTH_STATE_REDIS_KEY_PREFIX = "oauth_state:"
+
+
+class OAuthCallbackState(BaseModel):
+ provider_id: str
+ tenant_id: str
+ server_url: str
+ metadata: OAuthMetadata | None = None
+ client_information: OAuthClientInformation
+ code_verifier: str
+ redirect_uri: str
+
+
+def generate_pkce_challenge() -> tuple[str, str]:
+ """Generate PKCE challenge and verifier."""
+ code_verifier = base64.urlsafe_b64encode(os.urandom(40)).decode("utf-8")
+ code_verifier = code_verifier.replace("=", "").replace("+", "-").replace("/", "_")
+
+ code_challenge_hash = hashlib.sha256(code_verifier.encode("utf-8")).digest()
+ code_challenge = base64.urlsafe_b64encode(code_challenge_hash).decode("utf-8")
+ code_challenge = code_challenge.replace("=", "").replace("+", "-").replace("/", "_")
+
+ return code_verifier, code_challenge
+
+
+def _create_secure_redis_state(state_data: OAuthCallbackState) -> str:
+ """Create a secure state parameter by storing state data in Redis and returning a random state key."""
+ # Generate a secure random state key
+ state_key = secrets.token_urlsafe(32)
+
+ # Store the state data in Redis with expiration
+ redis_key = f"{OAUTH_STATE_REDIS_KEY_PREFIX}{state_key}"
+ redis_client.setex(redis_key, OAUTH_STATE_EXPIRY_SECONDS, state_data.model_dump_json())
+
+ return state_key
+
+
+def _retrieve_redis_state(state_key: str) -> OAuthCallbackState:
+ """Retrieve and decode OAuth state data from Redis using the state key, then delete it."""
+ redis_key = f"{OAUTH_STATE_REDIS_KEY_PREFIX}{state_key}"
+
+ # Get state data from Redis
+ state_data = redis_client.get(redis_key)
+
+ if not state_data:
+ raise ValueError("State parameter has expired or does not exist")
+
+ # Delete the state data from Redis immediately after retrieval to prevent reuse
+ redis_client.delete(redis_key)
+
+ try:
+ # Parse and validate the state data
+ oauth_state = OAuthCallbackState.model_validate_json(state_data)
+
+ return oauth_state
+ except ValidationError as e:
+ raise ValueError(f"Invalid state parameter: {str(e)}")
+
+
+def handle_callback(state_key: str, authorization_code: str) -> OAuthCallbackState:
+ """Handle the callback from the OAuth provider."""
+ # Retrieve state data from Redis (state is automatically deleted after retrieval)
+ full_state_data = _retrieve_redis_state(state_key)
+
+ tokens = exchange_authorization(
+ full_state_data.server_url,
+ full_state_data.metadata,
+ full_state_data.client_information,
+ authorization_code,
+ full_state_data.code_verifier,
+ full_state_data.redirect_uri,
+ )
+ provider = OAuthClientProvider(full_state_data.provider_id, full_state_data.tenant_id, for_list=True)
+ provider.save_tokens(tokens)
+ return full_state_data
+
+
+def discover_oauth_metadata(server_url: str, protocol_version: Optional[str] = None) -> Optional[OAuthMetadata]:
+ """Looks up RFC 8414 OAuth 2.0 Authorization Server Metadata."""
+ url = urljoin(server_url, "/.well-known/oauth-authorization-server")
+
+ try:
+ headers = {"MCP-Protocol-Version": protocol_version or LATEST_PROTOCOL_VERSION}
+ response = requests.get(url, headers=headers)
+ if response.status_code == 404:
+ return None
+ if not response.ok:
+ raise ValueError(f"HTTP {response.status_code} trying to load well-known OAuth metadata")
+ return OAuthMetadata.model_validate(response.json())
+ except requests.RequestException as e:
+ if isinstance(e, requests.ConnectionError):
+ response = requests.get(url)
+ if response.status_code == 404:
+ return None
+ if not response.ok:
+ raise ValueError(f"HTTP {response.status_code} trying to load well-known OAuth metadata")
+ return OAuthMetadata.model_validate(response.json())
+ raise
+
+
+def start_authorization(
+ server_url: str,
+ metadata: Optional[OAuthMetadata],
+ client_information: OAuthClientInformation,
+ redirect_url: str,
+ provider_id: str,
+ tenant_id: str,
+) -> tuple[str, str]:
+ """Begins the authorization flow with secure Redis state storage."""
+ response_type = "code"
+ code_challenge_method = "S256"
+
+ if metadata:
+ authorization_url = metadata.authorization_endpoint
+ if response_type not in metadata.response_types_supported:
+ raise ValueError(f"Incompatible auth server: does not support response type {response_type}")
+ if (
+ not metadata.code_challenge_methods_supported
+ or code_challenge_method not in metadata.code_challenge_methods_supported
+ ):
+ raise ValueError(
+ f"Incompatible auth server: does not support code challenge method {code_challenge_method}"
+ )
+ else:
+ authorization_url = urljoin(server_url, "/authorize")
+
+ code_verifier, code_challenge = generate_pkce_challenge()
+
+ # Prepare state data with all necessary information
+ state_data = OAuthCallbackState(
+ provider_id=provider_id,
+ tenant_id=tenant_id,
+ server_url=server_url,
+ metadata=metadata,
+ client_information=client_information,
+ code_verifier=code_verifier,
+ redirect_uri=redirect_url,
+ )
+
+ # Store state data in Redis and generate secure state key
+ state_key = _create_secure_redis_state(state_data)
+
+ params = {
+ "response_type": response_type,
+ "client_id": client_information.client_id,
+ "code_challenge": code_challenge,
+ "code_challenge_method": code_challenge_method,
+ "redirect_uri": redirect_url,
+ "state": state_key,
+ }
+
+ authorization_url = f"{authorization_url}?{urllib.parse.urlencode(params)}"
+ return authorization_url, code_verifier
+
+
+def exchange_authorization(
+ server_url: str,
+ metadata: Optional[OAuthMetadata],
+ client_information: OAuthClientInformation,
+ authorization_code: str,
+ code_verifier: str,
+ redirect_uri: str,
+) -> OAuthTokens:
+ """Exchanges an authorization code for an access token."""
+ grant_type = "authorization_code"
+
+ if metadata:
+ token_url = metadata.token_endpoint
+ if metadata.grant_types_supported and grant_type not in metadata.grant_types_supported:
+ raise ValueError(f"Incompatible auth server: does not support grant type {grant_type}")
+ else:
+ token_url = urljoin(server_url, "/token")
+
+ params = {
+ "grant_type": grant_type,
+ "client_id": client_information.client_id,
+ "code": authorization_code,
+ "code_verifier": code_verifier,
+ "redirect_uri": redirect_uri,
+ }
+
+ if client_information.client_secret:
+ params["client_secret"] = client_information.client_secret
+
+ response = requests.post(token_url, data=params)
+ if not response.ok:
+ raise ValueError(f"Token exchange failed: HTTP {response.status_code}")
+ return OAuthTokens.model_validate(response.json())
+
+
+def refresh_authorization(
+ server_url: str,
+ metadata: Optional[OAuthMetadata],
+ client_information: OAuthClientInformation,
+ refresh_token: str,
+) -> OAuthTokens:
+ """Exchange a refresh token for an updated access token."""
+ grant_type = "refresh_token"
+
+ if metadata:
+ token_url = metadata.token_endpoint
+ if metadata.grant_types_supported and grant_type not in metadata.grant_types_supported:
+ raise ValueError(f"Incompatible auth server: does not support grant type {grant_type}")
+ else:
+ token_url = urljoin(server_url, "/token")
+
+ params = {
+ "grant_type": grant_type,
+ "client_id": client_information.client_id,
+ "refresh_token": refresh_token,
+ }
+
+ if client_information.client_secret:
+ params["client_secret"] = client_information.client_secret
+
+ response = requests.post(token_url, data=params)
+ if not response.ok:
+ raise ValueError(f"Token refresh failed: HTTP {response.status_code}")
+ return OAuthTokens.parse_obj(response.json())
+
+
+def register_client(
+ server_url: str,
+ metadata: Optional[OAuthMetadata],
+ client_metadata: OAuthClientMetadata,
+) -> OAuthClientInformationFull:
+ """Performs OAuth 2.0 Dynamic Client Registration."""
+ if metadata:
+ if not metadata.registration_endpoint:
+ raise ValueError("Incompatible auth server: does not support dynamic client registration")
+ registration_url = metadata.registration_endpoint
+ else:
+ registration_url = urljoin(server_url, "/register")
+
+ response = requests.post(
+ registration_url,
+ json=client_metadata.model_dump(),
+ headers={"Content-Type": "application/json"},
+ )
+ if not response.ok:
+ response.raise_for_status()
+ return OAuthClientInformationFull.model_validate(response.json())
+
+
+def auth(
+ provider: OAuthClientProvider,
+ server_url: str,
+ authorization_code: Optional[str] = None,
+ state_param: Optional[str] = None,
+ for_list: bool = False,
+) -> dict[str, str]:
+ """Orchestrates the full auth flow with a server using secure Redis state storage."""
+ metadata = discover_oauth_metadata(server_url)
+
+ # Handle client registration if needed
+ client_information = provider.client_information()
+ if not client_information:
+ if authorization_code is not None:
+ raise ValueError("Existing OAuth client information is required when exchanging an authorization code")
+ try:
+ full_information = register_client(server_url, metadata, provider.client_metadata)
+ except requests.RequestException as e:
+ raise ValueError(f"Could not register OAuth client: {e}")
+ provider.save_client_information(full_information)
+ client_information = full_information
+
+ # Exchange authorization code for tokens
+ if authorization_code is not None:
+ if not state_param:
+ raise ValueError("State parameter is required when exchanging authorization code")
+
+ try:
+ # Retrieve state data from Redis using state key
+ full_state_data = _retrieve_redis_state(state_param)
+
+ code_verifier = full_state_data.code_verifier
+ redirect_uri = full_state_data.redirect_uri
+
+ if not code_verifier or not redirect_uri:
+ raise ValueError("Missing code_verifier or redirect_uri in state data")
+
+ except (json.JSONDecodeError, ValueError) as e:
+ raise ValueError(f"Invalid state parameter: {e}")
+
+ tokens = exchange_authorization(
+ server_url,
+ metadata,
+ client_information,
+ authorization_code,
+ code_verifier,
+ redirect_uri,
+ )
+ provider.save_tokens(tokens)
+ return {"result": "success"}
+
+ provider_tokens = provider.tokens()
+
+ # Handle token refresh or new authorization
+ if provider_tokens and provider_tokens.refresh_token:
+ try:
+ new_tokens = refresh_authorization(server_url, metadata, client_information, provider_tokens.refresh_token)
+ provider.save_tokens(new_tokens)
+ return {"result": "success"}
+ except Exception as e:
+ raise ValueError(f"Could not refresh OAuth tokens: {e}")
+
+ # Start new authorization flow
+ authorization_url, code_verifier = start_authorization(
+ server_url,
+ metadata,
+ client_information,
+ provider.redirect_url,
+ provider.mcp_provider.id,
+ provider.mcp_provider.tenant_id,
+ )
+
+ provider.save_code_verifier(code_verifier)
+ return {"authorization_url": authorization_url}
diff --git a/api/core/mcp/auth/auth_provider.py b/api/core/mcp/auth/auth_provider.py
new file mode 100644
index 0000000000..cd55dbf64f
--- /dev/null
+++ b/api/core/mcp/auth/auth_provider.py
@@ -0,0 +1,81 @@
+from typing import Optional
+
+from configs import dify_config
+from core.mcp.types import (
+ OAuthClientInformation,
+ OAuthClientInformationFull,
+ OAuthClientMetadata,
+ OAuthTokens,
+)
+from models.tools import MCPToolProvider
+from services.tools.mcp_tools_mange_service import MCPToolManageService
+
+LATEST_PROTOCOL_VERSION = "1.0"
+
+
+class OAuthClientProvider:
+ mcp_provider: MCPToolProvider
+
+ def __init__(self, provider_id: str, tenant_id: str, for_list: bool = False):
+ if for_list:
+ self.mcp_provider = MCPToolManageService.get_mcp_provider_by_provider_id(provider_id, tenant_id)
+ else:
+ self.mcp_provider = MCPToolManageService.get_mcp_provider_by_server_identifier(provider_id, tenant_id)
+
+ @property
+ def redirect_url(self) -> str:
+ """The URL to redirect the user agent to after authorization."""
+ return dify_config.CONSOLE_API_URL + "/console/api/mcp/oauth/callback"
+
+ @property
+ def client_metadata(self) -> OAuthClientMetadata:
+ """Metadata about this OAuth client."""
+ return OAuthClientMetadata(
+ redirect_uris=[self.redirect_url],
+ token_endpoint_auth_method="none",
+ grant_types=["authorization_code", "refresh_token"],
+ response_types=["code"],
+ client_name="Dify",
+ client_uri="https://github.com/langgenius/dify",
+ )
+
+ def client_information(self) -> Optional[OAuthClientInformation]:
+ """Loads information about this OAuth client."""
+ client_information = self.mcp_provider.decrypted_credentials.get("client_information", {})
+ if not client_information:
+ return None
+ return OAuthClientInformation.model_validate(client_information)
+
+ def save_client_information(self, client_information: OAuthClientInformationFull) -> None:
+ """Saves client information after dynamic registration."""
+ MCPToolManageService.update_mcp_provider_credentials(
+ self.mcp_provider,
+ {"client_information": client_information.model_dump()},
+ )
+
+ def tokens(self) -> Optional[OAuthTokens]:
+ """Loads any existing OAuth tokens for the current session."""
+ credentials = self.mcp_provider.decrypted_credentials
+ if not credentials:
+ return None
+ return OAuthTokens(
+ access_token=credentials.get("access_token", ""),
+ token_type=credentials.get("token_type", "Bearer"),
+ expires_in=int(credentials.get("expires_in", "3600") or 3600),
+ refresh_token=credentials.get("refresh_token", ""),
+ )
+
+ def save_tokens(self, tokens: OAuthTokens) -> None:
+ """Stores new OAuth tokens for the current session."""
+ # update mcp provider credentials
+ token_dict = tokens.model_dump()
+ MCPToolManageService.update_mcp_provider_credentials(self.mcp_provider, token_dict, authed=True)
+
+ def save_code_verifier(self, code_verifier: str) -> None:
+ """Saves a PKCE code verifier for the current session."""
+ MCPToolManageService.update_mcp_provider_credentials(self.mcp_provider, {"code_verifier": code_verifier})
+
+ def code_verifier(self) -> str:
+ """Loads the PKCE code verifier for the current session."""
+ # get code verifier from mcp provider credentials
+ return str(self.mcp_provider.decrypted_credentials.get("code_verifier", ""))
diff --git a/api/core/mcp/client/sse_client.py b/api/core/mcp/client/sse_client.py
new file mode 100644
index 0000000000..91debcc8f9
--- /dev/null
+++ b/api/core/mcp/client/sse_client.py
@@ -0,0 +1,361 @@
+import logging
+import queue
+from collections.abc import Generator
+from concurrent.futures import ThreadPoolExecutor
+from contextlib import contextmanager
+from typing import Any, TypeAlias, final
+from urllib.parse import urljoin, urlparse
+
+import httpx
+from sseclient import SSEClient
+
+from core.mcp import types
+from core.mcp.error import MCPAuthError, MCPConnectionError
+from core.mcp.types import SessionMessage
+from core.mcp.utils import create_ssrf_proxy_mcp_http_client, ssrf_proxy_sse_connect
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_QUEUE_READ_TIMEOUT = 3
+
+
+@final
+class _StatusReady:
+ def __init__(self, endpoint_url: str):
+ self._endpoint_url = endpoint_url
+
+
+@final
+class _StatusError:
+ def __init__(self, exc: Exception):
+ self._exc = exc
+
+
+# Type aliases for better readability
+ReadQueue: TypeAlias = queue.Queue[SessionMessage | Exception | None]
+WriteQueue: TypeAlias = queue.Queue[SessionMessage | Exception | None]
+StatusQueue: TypeAlias = queue.Queue[_StatusReady | _StatusError]
+
+
+def remove_request_params(url: str) -> str:
+ """Remove request parameters from URL, keeping only the path."""
+ return urljoin(url, urlparse(url).path)
+
+
+class SSETransport:
+ """SSE client transport implementation."""
+
+ def __init__(
+ self,
+ url: str,
+ headers: dict[str, Any] | None = None,
+ timeout: float = 5.0,
+ sse_read_timeout: float = 5 * 60,
+ ) -> None:
+ """Initialize the SSE transport.
+
+ Args:
+ url: The SSE endpoint URL.
+ headers: Optional headers to include in requests.
+ timeout: HTTP timeout for regular operations.
+ sse_read_timeout: Timeout for SSE read operations.
+ """
+ self.url = url
+ self.headers = headers or {}
+ self.timeout = timeout
+ self.sse_read_timeout = sse_read_timeout
+ self.endpoint_url: str | None = None
+
+ def _validate_endpoint_url(self, endpoint_url: str) -> bool:
+ """Validate that the endpoint URL matches the connection origin.
+
+ Args:
+ endpoint_url: The endpoint URL to validate.
+
+ Returns:
+ True if valid, False otherwise.
+ """
+ url_parsed = urlparse(self.url)
+ endpoint_parsed = urlparse(endpoint_url)
+
+ return url_parsed.netloc == endpoint_parsed.netloc and url_parsed.scheme == endpoint_parsed.scheme
+
+ def _handle_endpoint_event(self, sse_data: str, status_queue: StatusQueue) -> None:
+ """Handle an 'endpoint' SSE event.
+
+ Args:
+ sse_data: The SSE event data.
+ status_queue: Queue to put status updates.
+ """
+ endpoint_url = urljoin(self.url, sse_data)
+ logger.info(f"Received endpoint URL: {endpoint_url}")
+
+ if not self._validate_endpoint_url(endpoint_url):
+ error_msg = f"Endpoint origin does not match connection origin: {endpoint_url}"
+ logger.error(error_msg)
+ status_queue.put(_StatusError(ValueError(error_msg)))
+ return
+
+ status_queue.put(_StatusReady(endpoint_url))
+
+ def _handle_message_event(self, sse_data: str, read_queue: ReadQueue) -> None:
+ """Handle a 'message' SSE event.
+
+ Args:
+ sse_data: The SSE event data.
+ read_queue: Queue to put parsed messages.
+ """
+ try:
+ message = types.JSONRPCMessage.model_validate_json(sse_data)
+ logger.debug(f"Received server message: {message}")
+ session_message = SessionMessage(message)
+ read_queue.put(session_message)
+ except Exception as exc:
+ logger.exception("Error parsing server message")
+ read_queue.put(exc)
+
+ def _handle_sse_event(self, sse, read_queue: ReadQueue, status_queue: StatusQueue) -> None:
+ """Handle a single SSE event.
+
+ Args:
+ sse: The SSE event object.
+ read_queue: Queue for message events.
+ status_queue: Queue for status events.
+ """
+ match sse.event:
+ case "endpoint":
+ self._handle_endpoint_event(sse.data, status_queue)
+ case "message":
+ self._handle_message_event(sse.data, read_queue)
+ case _:
+ logger.warning(f"Unknown SSE event: {sse.event}")
+
+ def sse_reader(self, event_source, read_queue: ReadQueue, status_queue: StatusQueue) -> None:
+ """Read and process SSE events.
+
+ Args:
+ event_source: The SSE event source.
+ read_queue: Queue to put received messages.
+ status_queue: Queue to put status updates.
+ """
+ try:
+ for sse in event_source.iter_sse():
+ self._handle_sse_event(sse, read_queue, status_queue)
+ except httpx.ReadError as exc:
+ logger.debug(f"SSE reader shutting down normally: {exc}")
+ except Exception as exc:
+ read_queue.put(exc)
+ finally:
+ read_queue.put(None)
+
+ def _send_message(self, client: httpx.Client, endpoint_url: str, message: SessionMessage) -> None:
+ """Send a single message to the server.
+
+ Args:
+ client: HTTP client to use.
+ endpoint_url: The endpoint URL to send to.
+ message: The message to send.
+ """
+ response = client.post(
+ endpoint_url,
+ json=message.message.model_dump(
+ by_alias=True,
+ mode="json",
+ exclude_none=True,
+ ),
+ )
+ response.raise_for_status()
+ logger.debug(f"Client message sent successfully: {response.status_code}")
+
+ def post_writer(self, client: httpx.Client, endpoint_url: str, write_queue: WriteQueue) -> None:
+ """Handle writing messages to the server.
+
+ Args:
+ client: HTTP client to use.
+ endpoint_url: The endpoint URL to send messages to.
+ write_queue: Queue to read messages from.
+ """
+ try:
+ while True:
+ try:
+ message = write_queue.get(timeout=DEFAULT_QUEUE_READ_TIMEOUT)
+ if message is None:
+ break
+ if isinstance(message, Exception):
+ write_queue.put(message)
+ continue
+
+ self._send_message(client, endpoint_url, message)
+
+ except queue.Empty:
+ continue
+ except httpx.ReadError as exc:
+ logger.debug(f"Post writer shutting down normally: {exc}")
+ except Exception as exc:
+ logger.exception("Error writing messages")
+ write_queue.put(exc)
+ finally:
+ write_queue.put(None)
+
+ def _wait_for_endpoint(self, status_queue: StatusQueue) -> str:
+ """Wait for the endpoint URL from the status queue.
+
+ Args:
+ status_queue: Queue to read status from.
+
+ Returns:
+ The endpoint URL.
+
+ Raises:
+ ValueError: If endpoint URL is not received or there's an error.
+ """
+ try:
+ status = status_queue.get(timeout=1)
+ except queue.Empty:
+ raise ValueError("failed to get endpoint URL")
+
+ if isinstance(status, _StatusReady):
+ return status._endpoint_url
+ elif isinstance(status, _StatusError):
+ raise status._exc
+ else:
+ raise ValueError("failed to get endpoint URL")
+
+ def connect(
+ self,
+ executor: ThreadPoolExecutor,
+ client: httpx.Client,
+ event_source,
+ ) -> tuple[ReadQueue, WriteQueue]:
+ """Establish connection and start worker threads.
+
+ Args:
+ executor: Thread pool executor.
+ client: HTTP client.
+ event_source: SSE event source.
+
+ Returns:
+ Tuple of (read_queue, write_queue).
+ """
+ read_queue: ReadQueue = queue.Queue()
+ write_queue: WriteQueue = queue.Queue()
+ status_queue: StatusQueue = queue.Queue()
+
+ # Start SSE reader thread
+ executor.submit(self.sse_reader, event_source, read_queue, status_queue)
+
+ # Wait for endpoint URL
+ endpoint_url = self._wait_for_endpoint(status_queue)
+ self.endpoint_url = endpoint_url
+
+ # Start post writer thread
+ executor.submit(self.post_writer, client, endpoint_url, write_queue)
+
+ return read_queue, write_queue
+
+
+@contextmanager
+def sse_client(
+ url: str,
+ headers: dict[str, Any] | None = None,
+ timeout: float = 5.0,
+ sse_read_timeout: float = 5 * 60,
+) -> Generator[tuple[ReadQueue, WriteQueue], None, None]:
+ """
+ Client transport for SSE.
+ `sse_read_timeout` determines how long (in seconds) the client will wait for a new
+ event before disconnecting. All other HTTP operations are controlled by `timeout`.
+
+ Args:
+ url: The SSE endpoint URL.
+ headers: Optional headers to include in requests.
+ timeout: HTTP timeout for regular operations.
+ sse_read_timeout: Timeout for SSE read operations.
+
+ Yields:
+ Tuple of (read_queue, write_queue) for message communication.
+ """
+ transport = SSETransport(url, headers, timeout, sse_read_timeout)
+
+ read_queue: ReadQueue | None = None
+ write_queue: WriteQueue | None = None
+
+ with ThreadPoolExecutor() as executor:
+ try:
+ with create_ssrf_proxy_mcp_http_client(headers=transport.headers) as client:
+ with ssrf_proxy_sse_connect(
+ url, timeout=httpx.Timeout(timeout, read=sse_read_timeout), client=client
+ ) as event_source:
+ event_source.response.raise_for_status()
+
+ read_queue, write_queue = transport.connect(executor, client, event_source)
+
+ yield read_queue, write_queue
+
+ except httpx.HTTPStatusError as exc:
+ if exc.response.status_code == 401:
+ raise MCPAuthError()
+ raise MCPConnectionError()
+ except Exception:
+ logger.exception("Error connecting to SSE endpoint")
+ raise
+ finally:
+ # Clean up queues
+ if read_queue:
+ read_queue.put(None)
+ if write_queue:
+ write_queue.put(None)
+
+
+def send_message(http_client: httpx.Client, endpoint_url: str, session_message: SessionMessage) -> None:
+ """
+ Send a message to the server using the provided HTTP client.
+
+ Args:
+ http_client: The HTTP client to use for sending
+ endpoint_url: The endpoint URL to send the message to
+ session_message: The message to send
+ """
+ try:
+ response = http_client.post(
+ endpoint_url,
+ json=session_message.message.model_dump(
+ by_alias=True,
+ mode="json",
+ exclude_none=True,
+ ),
+ )
+ response.raise_for_status()
+ logger.debug(f"Client message sent successfully: {response.status_code}")
+ except Exception as exc:
+ logger.exception("Error sending message")
+ raise
+
+
+def read_messages(
+ sse_client: SSEClient,
+) -> Generator[SessionMessage | Exception, None, None]:
+ """
+ Read messages from the SSE client.
+
+ Args:
+ sse_client: The SSE client to read from
+
+ Yields:
+ SessionMessage or Exception for each event received
+ """
+ try:
+ for sse in sse_client.events():
+ if sse.event == "message":
+ try:
+ message = types.JSONRPCMessage.model_validate_json(sse.data)
+ logger.debug(f"Received server message: {message}")
+ yield SessionMessage(message)
+ except Exception as exc:
+ logger.exception("Error parsing server message")
+ yield exc
+ else:
+ logger.warning(f"Unknown SSE event: {sse.event}")
+ except Exception as exc:
+ logger.exception("Error reading SSE messages")
+ yield exc
diff --git a/api/core/mcp/client/streamable_client.py b/api/core/mcp/client/streamable_client.py
new file mode 100644
index 0000000000..fbd8d05f9e
--- /dev/null
+++ b/api/core/mcp/client/streamable_client.py
@@ -0,0 +1,476 @@
+"""
+StreamableHTTP Client Transport Module
+
+This module implements the StreamableHTTP transport for MCP clients,
+providing support for HTTP POST requests with optional SSE streaming responses
+and session management.
+"""
+
+import logging
+import queue
+from collections.abc import Callable, Generator
+from concurrent.futures import ThreadPoolExecutor
+from contextlib import contextmanager
+from dataclasses import dataclass
+from datetime import timedelta
+from typing import Any, cast
+
+import httpx
+from httpx_sse import EventSource, ServerSentEvent
+
+from core.mcp.types import (
+ ClientMessageMetadata,
+ ErrorData,
+ JSONRPCError,
+ JSONRPCMessage,
+ JSONRPCNotification,
+ JSONRPCRequest,
+ JSONRPCResponse,
+ RequestId,
+ SessionMessage,
+)
+from core.mcp.utils import create_ssrf_proxy_mcp_http_client, ssrf_proxy_sse_connect
+
+logger = logging.getLogger(__name__)
+
+
+SessionMessageOrError = SessionMessage | Exception | None
+# Queue types with clearer names for their roles
+ServerToClientQueue = queue.Queue[SessionMessageOrError] # Server to client messages
+ClientToServerQueue = queue.Queue[SessionMessage | None] # Client to server messages
+GetSessionIdCallback = Callable[[], str | None]
+
+MCP_SESSION_ID = "mcp-session-id"
+LAST_EVENT_ID = "last-event-id"
+CONTENT_TYPE = "content-type"
+ACCEPT = "Accept"
+
+
+JSON = "application/json"
+SSE = "text/event-stream"
+
+DEFAULT_QUEUE_READ_TIMEOUT = 3
+
+
+class StreamableHTTPError(Exception):
+ """Base exception for StreamableHTTP transport errors."""
+
+ pass
+
+
+class ResumptionError(StreamableHTTPError):
+ """Raised when resumption request is invalid."""
+
+ pass
+
+
+@dataclass
+class RequestContext:
+ """Context for a request operation."""
+
+ client: httpx.Client
+ headers: dict[str, str]
+ session_id: str | None
+ session_message: SessionMessage
+ metadata: ClientMessageMetadata | None
+ server_to_client_queue: ServerToClientQueue # Renamed for clarity
+ sse_read_timeout: timedelta
+
+
+class StreamableHTTPTransport:
+ """StreamableHTTP client transport implementation."""
+
+ def __init__(
+ self,
+ url: str,
+ headers: dict[str, Any] | None = None,
+ timeout: timedelta = timedelta(seconds=30),
+ sse_read_timeout: timedelta = timedelta(seconds=60 * 5),
+ ) -> None:
+ """Initialize the StreamableHTTP transport.
+
+ Args:
+ url: The endpoint URL.
+ headers: Optional headers to include in requests.
+ timeout: HTTP timeout for regular operations.
+ sse_read_timeout: Timeout for SSE read operations.
+ """
+ self.url = url
+ self.headers = headers or {}
+ self.timeout = timeout
+ self.sse_read_timeout = sse_read_timeout
+ self.session_id: str | None = None
+ self.request_headers = {
+ ACCEPT: f"{JSON}, {SSE}",
+ CONTENT_TYPE: JSON,
+ **self.headers,
+ }
+
+ def _update_headers_with_session(self, base_headers: dict[str, str]) -> dict[str, str]:
+ """Update headers with session ID if available."""
+ headers = base_headers.copy()
+ if self.session_id:
+ headers[MCP_SESSION_ID] = self.session_id
+ return headers
+
+ def _is_initialization_request(self, message: JSONRPCMessage) -> bool:
+ """Check if the message is an initialization request."""
+ return isinstance(message.root, JSONRPCRequest) and message.root.method == "initialize"
+
+ def _is_initialized_notification(self, message: JSONRPCMessage) -> bool:
+ """Check if the message is an initialized notification."""
+ return isinstance(message.root, JSONRPCNotification) and message.root.method == "notifications/initialized"
+
+ def _maybe_extract_session_id_from_response(
+ self,
+ response: httpx.Response,
+ ) -> None:
+ """Extract and store session ID from response headers."""
+ new_session_id = response.headers.get(MCP_SESSION_ID)
+ if new_session_id:
+ self.session_id = new_session_id
+ logger.info(f"Received session ID: {self.session_id}")
+
+ def _handle_sse_event(
+ self,
+ sse: ServerSentEvent,
+ server_to_client_queue: ServerToClientQueue,
+ original_request_id: RequestId | None = None,
+ resumption_callback: Callable[[str], None] | None = None,
+ ) -> bool:
+ """Handle an SSE event, returning True if the response is complete."""
+ if sse.event == "message":
+ try:
+ message = JSONRPCMessage.model_validate_json(sse.data)
+ logger.debug(f"SSE message: {message}")
+
+ # If this is a response and we have original_request_id, replace it
+ if original_request_id is not None and isinstance(message.root, JSONRPCResponse | JSONRPCError):
+ message.root.id = original_request_id
+
+ session_message = SessionMessage(message)
+ # Put message in queue that goes to client
+ server_to_client_queue.put(session_message)
+
+ # Call resumption token callback if we have an ID
+ if sse.id and resumption_callback:
+ resumption_callback(sse.id)
+
+ # If this is a response or error return True indicating completion
+ # Otherwise, return False to continue listening
+ return isinstance(message.root, JSONRPCResponse | JSONRPCError)
+
+ except Exception as exc:
+ # Put exception in queue that goes to client
+ server_to_client_queue.put(exc)
+ return False
+ elif sse.event == "ping":
+ logger.debug("Received ping event")
+ return False
+ else:
+ logger.warning(f"Unknown SSE event: {sse.event}")
+ return False
+
+ def handle_get_stream(
+ self,
+ client: httpx.Client,
+ server_to_client_queue: ServerToClientQueue,
+ ) -> None:
+ """Handle GET stream for server-initiated messages."""
+ try:
+ if not self.session_id:
+ return
+
+ headers = self._update_headers_with_session(self.request_headers)
+
+ with ssrf_proxy_sse_connect(
+ self.url,
+ headers=headers,
+ timeout=httpx.Timeout(self.timeout.seconds, read=self.sse_read_timeout.seconds),
+ client=client,
+ method="GET",
+ ) as event_source:
+ event_source.response.raise_for_status()
+ logger.debug("GET SSE connection established")
+
+ for sse in event_source.iter_sse():
+ self._handle_sse_event(sse, server_to_client_queue)
+
+ except Exception as exc:
+ logger.debug(f"GET stream error (non-fatal): {exc}")
+
+ def _handle_resumption_request(self, ctx: RequestContext) -> None:
+ """Handle a resumption request using GET with SSE."""
+ headers = self._update_headers_with_session(ctx.headers)
+ if ctx.metadata and ctx.metadata.resumption_token:
+ headers[LAST_EVENT_ID] = ctx.metadata.resumption_token
+ else:
+ raise ResumptionError("Resumption request requires a resumption token")
+
+ # Extract original request ID to map responses
+ original_request_id = None
+ if isinstance(ctx.session_message.message.root, JSONRPCRequest):
+ original_request_id = ctx.session_message.message.root.id
+
+ with ssrf_proxy_sse_connect(
+ self.url,
+ headers=headers,
+ timeout=httpx.Timeout(self.timeout.seconds, read=ctx.sse_read_timeout.seconds),
+ client=ctx.client,
+ method="GET",
+ ) as event_source:
+ event_source.response.raise_for_status()
+ logger.debug("Resumption GET SSE connection established")
+
+ for sse in event_source.iter_sse():
+ is_complete = self._handle_sse_event(
+ sse,
+ ctx.server_to_client_queue,
+ original_request_id,
+ ctx.metadata.on_resumption_token_update if ctx.metadata else None,
+ )
+ if is_complete:
+ break
+
+ def _handle_post_request(self, ctx: RequestContext) -> None:
+ """Handle a POST request with response processing."""
+ headers = self._update_headers_with_session(ctx.headers)
+ message = ctx.session_message.message
+ is_initialization = self._is_initialization_request(message)
+
+ with ctx.client.stream(
+ "POST",
+ self.url,
+ json=message.model_dump(by_alias=True, mode="json", exclude_none=True),
+ headers=headers,
+ ) as response:
+ if response.status_code == 202:
+ logger.debug("Received 202 Accepted")
+ return
+
+ if response.status_code == 404:
+ if isinstance(message.root, JSONRPCRequest):
+ self._send_session_terminated_error(
+ ctx.server_to_client_queue,
+ message.root.id,
+ )
+ return
+
+ response.raise_for_status()
+ if is_initialization:
+ self._maybe_extract_session_id_from_response(response)
+
+ content_type = cast(str, response.headers.get(CONTENT_TYPE, "").lower())
+
+ if content_type.startswith(JSON):
+ self._handle_json_response(response, ctx.server_to_client_queue)
+ elif content_type.startswith(SSE):
+ self._handle_sse_response(response, ctx)
+ else:
+ self._handle_unexpected_content_type(
+ content_type,
+ ctx.server_to_client_queue,
+ )
+
+ def _handle_json_response(
+ self,
+ response: httpx.Response,
+ server_to_client_queue: ServerToClientQueue,
+ ) -> None:
+ """Handle JSON response from the server."""
+ try:
+ content = response.read()
+ message = JSONRPCMessage.model_validate_json(content)
+ session_message = SessionMessage(message)
+ server_to_client_queue.put(session_message)
+ except Exception as exc:
+ server_to_client_queue.put(exc)
+
+ def _handle_sse_response(self, response: httpx.Response, ctx: RequestContext) -> None:
+ """Handle SSE response from the server."""
+ try:
+ event_source = EventSource(response)
+ for sse in event_source.iter_sse():
+ is_complete = self._handle_sse_event(
+ sse,
+ ctx.server_to_client_queue,
+ resumption_callback=(ctx.metadata.on_resumption_token_update if ctx.metadata else None),
+ )
+ if is_complete:
+ break
+ except Exception as e:
+ ctx.server_to_client_queue.put(e)
+
+ def _handle_unexpected_content_type(
+ self,
+ content_type: str,
+ server_to_client_queue: ServerToClientQueue,
+ ) -> None:
+ """Handle unexpected content type in response."""
+ error_msg = f"Unexpected content type: {content_type}"
+ logger.error(error_msg)
+ server_to_client_queue.put(ValueError(error_msg))
+
+ def _send_session_terminated_error(
+ self,
+ server_to_client_queue: ServerToClientQueue,
+ request_id: RequestId,
+ ) -> None:
+ """Send a session terminated error response."""
+ jsonrpc_error = JSONRPCError(
+ jsonrpc="2.0",
+ id=request_id,
+ error=ErrorData(code=32600, message="Session terminated by server"),
+ )
+ session_message = SessionMessage(JSONRPCMessage(jsonrpc_error))
+ server_to_client_queue.put(session_message)
+
+ def post_writer(
+ self,
+ client: httpx.Client,
+ client_to_server_queue: ClientToServerQueue,
+ server_to_client_queue: ServerToClientQueue,
+ start_get_stream: Callable[[], None],
+ ) -> None:
+ """Handle writing requests to the server.
+
+ This method processes messages from the client_to_server_queue and sends them to the server.
+ Responses are written to the server_to_client_queue.
+ """
+ while True:
+ try:
+ # Read message from client queue with timeout to check stop_event periodically
+ session_message = client_to_server_queue.get(timeout=DEFAULT_QUEUE_READ_TIMEOUT)
+ if session_message is None:
+ break
+
+ message = session_message.message
+ metadata = (
+ session_message.metadata if isinstance(session_message.metadata, ClientMessageMetadata) else None
+ )
+
+ # Check if this is a resumption request
+ is_resumption = bool(metadata and metadata.resumption_token)
+
+ logger.debug(f"Sending client message: {message}")
+
+ # Handle initialized notification
+ if self._is_initialized_notification(message):
+ start_get_stream()
+
+ ctx = RequestContext(
+ client=client,
+ headers=self.request_headers,
+ session_id=self.session_id,
+ session_message=session_message,
+ metadata=metadata,
+ server_to_client_queue=server_to_client_queue, # Queue to write responses to client
+ sse_read_timeout=self.sse_read_timeout,
+ )
+
+ if is_resumption:
+ self._handle_resumption_request(ctx)
+ else:
+ self._handle_post_request(ctx)
+ except queue.Empty:
+ continue
+ except Exception as exc:
+ server_to_client_queue.put(exc)
+
+ def terminate_session(self, client: httpx.Client) -> None:
+ """Terminate the session by sending a DELETE request."""
+ if not self.session_id:
+ return
+
+ try:
+ headers = self._update_headers_with_session(self.request_headers)
+ response = client.delete(self.url, headers=headers)
+
+ if response.status_code == 405:
+ logger.debug("Server does not allow session termination")
+ elif response.status_code != 200:
+ logger.warning(f"Session termination failed: {response.status_code}")
+ except Exception as exc:
+ logger.warning(f"Session termination failed: {exc}")
+
+ def get_session_id(self) -> str | None:
+ """Get the current session ID."""
+ return self.session_id
+
+
+@contextmanager
+def streamablehttp_client(
+ url: str,
+ headers: dict[str, Any] | None = None,
+ timeout: timedelta = timedelta(seconds=30),
+ sse_read_timeout: timedelta = timedelta(seconds=60 * 5),
+ terminate_on_close: bool = True,
+) -> Generator[
+ tuple[
+ ServerToClientQueue, # Queue for receiving messages FROM server
+ ClientToServerQueue, # Queue for sending messages TO server
+ GetSessionIdCallback,
+ ],
+ None,
+ None,
+]:
+ """
+ Client transport for StreamableHTTP.
+
+ `sse_read_timeout` determines how long (in seconds) the client will wait for a new
+ event before disconnecting. All other HTTP operations are controlled by `timeout`.
+
+ Yields:
+ Tuple containing:
+ - server_to_client_queue: Queue for reading messages FROM the server
+ - client_to_server_queue: Queue for sending messages TO the server
+ - get_session_id_callback: Function to retrieve the current session ID
+ """
+ transport = StreamableHTTPTransport(url, headers, timeout, sse_read_timeout)
+
+ # Create queues with clear directional meaning
+ server_to_client_queue: ServerToClientQueue = queue.Queue() # For messages FROM server TO client
+ client_to_server_queue: ClientToServerQueue = queue.Queue() # For messages FROM client TO server
+
+ with ThreadPoolExecutor(max_workers=2) as executor:
+ try:
+ with create_ssrf_proxy_mcp_http_client(
+ headers=transport.request_headers,
+ timeout=httpx.Timeout(transport.timeout.seconds, read=transport.sse_read_timeout.seconds),
+ ) as client:
+ # Define callbacks that need access to thread pool
+ def start_get_stream() -> None:
+ """Start a worker thread to handle server-initiated messages."""
+ executor.submit(transport.handle_get_stream, client, server_to_client_queue)
+
+ # Start the post_writer worker thread
+ executor.submit(
+ transport.post_writer,
+ client,
+ client_to_server_queue, # Queue for messages FROM client TO server
+ server_to_client_queue, # Queue for messages FROM server TO client
+ start_get_stream,
+ )
+
+ try:
+ yield (
+ server_to_client_queue, # Queue for receiving messages FROM server
+ client_to_server_queue, # Queue for sending messages TO server
+ transport.get_session_id,
+ )
+ finally:
+ if transport.session_id and terminate_on_close:
+ transport.terminate_session(client)
+
+ # Signal threads to stop
+ client_to_server_queue.put(None)
+ finally:
+ # Clear any remaining items and add None sentinel to unblock any waiting threads
+ try:
+ while not client_to_server_queue.empty():
+ client_to_server_queue.get_nowait()
+ except queue.Empty:
+ pass
+
+ client_to_server_queue.put(None)
+ server_to_client_queue.put(None)
diff --git a/api/core/mcp/entities.py b/api/core/mcp/entities.py
new file mode 100644
index 0000000000..7553c10a2e
--- /dev/null
+++ b/api/core/mcp/entities.py
@@ -0,0 +1,19 @@
+from dataclasses import dataclass
+from typing import Any, Generic, TypeVar
+
+from core.mcp.session.base_session import BaseSession
+from core.mcp.types import LATEST_PROTOCOL_VERSION, RequestId, RequestParams
+
+SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", LATEST_PROTOCOL_VERSION]
+
+
+SessionT = TypeVar("SessionT", bound=BaseSession[Any, Any, Any, Any, Any])
+LifespanContextT = TypeVar("LifespanContextT")
+
+
+@dataclass
+class RequestContext(Generic[SessionT, LifespanContextT]):
+ request_id: RequestId
+ meta: RequestParams.Meta | None
+ session: SessionT
+ lifespan_context: LifespanContextT
diff --git a/api/core/mcp/error.py b/api/core/mcp/error.py
new file mode 100644
index 0000000000..92ea7bde09
--- /dev/null
+++ b/api/core/mcp/error.py
@@ -0,0 +1,10 @@
+class MCPError(Exception):
+ pass
+
+
+class MCPConnectionError(MCPError):
+ pass
+
+
+class MCPAuthError(MCPConnectionError):
+ pass
diff --git a/api/core/mcp/mcp_client.py b/api/core/mcp/mcp_client.py
new file mode 100644
index 0000000000..e9036de8c6
--- /dev/null
+++ b/api/core/mcp/mcp_client.py
@@ -0,0 +1,150 @@
+import logging
+from collections.abc import Callable
+from contextlib import AbstractContextManager, ExitStack
+from types import TracebackType
+from typing import Any, Optional, cast
+from urllib.parse import urlparse
+
+from core.mcp.client.sse_client import sse_client
+from core.mcp.client.streamable_client import streamablehttp_client
+from core.mcp.error import MCPAuthError, MCPConnectionError
+from core.mcp.session.client_session import ClientSession
+from core.mcp.types import Tool
+
+logger = logging.getLogger(__name__)
+
+
+class MCPClient:
+ def __init__(
+ self,
+ server_url: str,
+ provider_id: str,
+ tenant_id: str,
+ authed: bool = True,
+ authorization_code: Optional[str] = None,
+ for_list: bool = False,
+ ):
+ # Initialize info
+ self.provider_id = provider_id
+ self.tenant_id = tenant_id
+ self.client_type = "streamable"
+ self.server_url = server_url
+
+ # Authentication info
+ self.authed = authed
+ self.authorization_code = authorization_code
+ if authed:
+ from core.mcp.auth.auth_provider import OAuthClientProvider
+
+ self.provider = OAuthClientProvider(self.provider_id, self.tenant_id, for_list=for_list)
+ self.token = self.provider.tokens()
+
+ # Initialize session and client objects
+ self._session: Optional[ClientSession] = None
+ self._streams_context: Optional[AbstractContextManager[Any]] = None
+ self._session_context: Optional[ClientSession] = None
+ self.exit_stack = ExitStack()
+
+ # Whether the client has been initialized
+ self._initialized = False
+
+ def __enter__(self):
+ self._initialize()
+ self._initialized = True
+ return self
+
+ def __exit__(
+ self, exc_type: Optional[type], exc_value: Optional[BaseException], traceback: Optional[TracebackType]
+ ):
+ self.cleanup()
+
+ def _initialize(
+ self,
+ ):
+ """Initialize the client with fallback to SSE if streamable connection fails"""
+ connection_methods: dict[str, Callable[..., AbstractContextManager[Any]]] = {
+ "mcp": streamablehttp_client,
+ "sse": sse_client,
+ }
+
+ parsed_url = urlparse(self.server_url)
+ path = parsed_url.path
+ method_name = path.rstrip("/").split("/")[-1] if path else ""
+ try:
+ client_factory = connection_methods[method_name]
+ self.connect_server(client_factory, method_name)
+ except KeyError:
+ try:
+ self.connect_server(sse_client, "sse")
+ except MCPConnectionError:
+ self.connect_server(streamablehttp_client, "mcp")
+
+ def connect_server(
+ self, client_factory: Callable[..., AbstractContextManager[Any]], method_name: str, first_try: bool = True
+ ):
+ from core.mcp.auth.auth_flow import auth
+
+ try:
+ headers = (
+ {"Authorization": f"{self.token.token_type.capitalize()} {self.token.access_token}"}
+ if self.authed and self.token
+ else {}
+ )
+ self._streams_context = client_factory(url=self.server_url, headers=headers)
+ if self._streams_context is None:
+ raise MCPConnectionError("Failed to create connection context")
+
+ # Use exit_stack to manage context managers properly
+ if method_name == "mcp":
+ read_stream, write_stream, _ = self.exit_stack.enter_context(self._streams_context)
+ streams = (read_stream, write_stream)
+ else: # sse_client
+ streams = self.exit_stack.enter_context(self._streams_context)
+
+ self._session_context = ClientSession(*streams)
+ self._session = self.exit_stack.enter_context(self._session_context)
+ session = cast(ClientSession, self._session)
+ session.initialize()
+ return
+
+ except MCPAuthError:
+ if not self.authed:
+ raise
+ try:
+ auth(self.provider, self.server_url, self.authorization_code)
+ except Exception as e:
+ raise ValueError(f"Failed to authenticate: {e}")
+ self.token = self.provider.tokens()
+ if first_try:
+ return self.connect_server(client_factory, method_name, first_try=False)
+
+ except MCPConnectionError:
+ raise
+
+ def list_tools(self) -> list[Tool]:
+ """Connect to an MCP server running with SSE transport"""
+ # List available tools to verify connection
+ if not self._initialized or not self._session:
+ raise ValueError("Session not initialized.")
+ response = self._session.list_tools()
+ tools = response.tools
+ return tools
+
+ def invoke_tool(self, tool_name: str, tool_args: dict):
+ """Call a tool"""
+ if not self._initialized or not self._session:
+ raise ValueError("Session not initialized.")
+ return self._session.call_tool(tool_name, tool_args)
+
+ def cleanup(self):
+ """Clean up resources"""
+ try:
+ # ExitStack will handle proper cleanup of all managed context managers
+ self.exit_stack.close()
+ self._session = None
+ self._session_context = None
+ self._streams_context = None
+ self._initialized = False
+ except Exception as e:
+ logging.exception("Error during cleanup")
+ raise ValueError(f"Error during cleanup: {e}")
diff --git a/api/core/mcp/server/streamable_http.py b/api/core/mcp/server/streamable_http.py
new file mode 100644
index 0000000000..1c2cf570e2
--- /dev/null
+++ b/api/core/mcp/server/streamable_http.py
@@ -0,0 +1,228 @@
+import json
+import logging
+from collections.abc import Mapping
+from typing import Any, cast
+
+from configs import dify_config
+from controllers.web.passport import generate_session_id
+from core.app.app_config.entities import VariableEntity, VariableEntityType
+from core.app.entities.app_invoke_entities import InvokeFrom
+from core.app.features.rate_limiting.rate_limit import RateLimitGenerator
+from core.mcp import types
+from core.mcp.types import INTERNAL_ERROR, INVALID_PARAMS, METHOD_NOT_FOUND
+from core.mcp.utils import create_mcp_error_response
+from core.model_runtime.utils.encoders import jsonable_encoder
+from extensions.ext_database import db
+from models.model import App, AppMCPServer, AppMode, EndUser
+from services.app_generate_service import AppGenerateService
+
+"""
+Apply to MCP HTTP streamable server with stateless http
+"""
+logger = logging.getLogger(__name__)
+
+
+class MCPServerStreamableHTTPRequestHandler:
+ def __init__(
+ self, app: App, request: types.ClientRequest | types.ClientNotification, user_input_form: list[VariableEntity]
+ ):
+ self.app = app
+ self.request = request
+ mcp_server = db.session.query(AppMCPServer).filter(AppMCPServer.app_id == self.app.id).first()
+ if not mcp_server:
+ raise ValueError("MCP server not found")
+ self.mcp_server: AppMCPServer = mcp_server
+ self.end_user = self.retrieve_end_user()
+ self.user_input_form = user_input_form
+
+ @property
+ def request_type(self):
+ return type(self.request.root)
+
+ @property
+ def parameter_schema(self):
+ parameters, required = self._convert_input_form_to_parameters(self.user_input_form)
+ if self.app.mode in {AppMode.COMPLETION.value, AppMode.WORKFLOW.value}:
+ return {
+ "type": "object",
+ "properties": parameters,
+ "required": required,
+ }
+ return {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "User Input/Question content"},
+ **parameters,
+ },
+ "required": ["query", *required],
+ }
+
+ @property
+ def capabilities(self):
+ return types.ServerCapabilities(
+ tools=types.ToolsCapability(listChanged=False),
+ )
+
+ def response(self, response: types.Result | str):
+ if isinstance(response, str):
+ sse_content = f"event: ping\ndata: {response}\n\n".encode()
+ yield sse_content
+ return
+ json_response = types.JSONRPCResponse(
+ jsonrpc="2.0",
+ id=(self.request.root.model_extra or {}).get("id", 1),
+ result=response.model_dump(by_alias=True, mode="json", exclude_none=True),
+ )
+ json_data = json.dumps(jsonable_encoder(json_response))
+
+ sse_content = f"event: message\ndata: {json_data}\n\n".encode()
+
+ yield sse_content
+
+ def error_response(self, code: int, message: str, data=None):
+ request_id = (self.request.root.model_extra or {}).get("id", 1) or 1
+ return create_mcp_error_response(request_id, code, message, data)
+
+ def handle(self):
+ handle_map = {
+ types.InitializeRequest: self.initialize,
+ types.ListToolsRequest: self.list_tools,
+ types.CallToolRequest: self.invoke_tool,
+ types.InitializedNotification: self.handle_notification,
+ types.PingRequest: self.handle_ping,
+ }
+ try:
+ if self.request_type in handle_map:
+ return self.response(handle_map[self.request_type]())
+ else:
+ return self.error_response(METHOD_NOT_FOUND, f"Method not found: {self.request_type}")
+ except ValueError as e:
+ logger.exception("Invalid params")
+ return self.error_response(INVALID_PARAMS, str(e))
+ except Exception as e:
+ logger.exception("Internal server error")
+ return self.error_response(INTERNAL_ERROR, f"Internal server error: {str(e)}")
+
+ def handle_notification(self):
+ return "ping"
+
+ def handle_ping(self):
+ return types.EmptyResult()
+
+ def initialize(self):
+ request = cast(types.InitializeRequest, self.request.root)
+ client_info = request.params.clientInfo
+ client_name = f"{client_info.name}@{client_info.version}"
+ if not self.end_user:
+ end_user = EndUser(
+ tenant_id=self.app.tenant_id,
+ app_id=self.app.id,
+ type="mcp",
+ name=client_name,
+ session_id=generate_session_id(),
+ external_user_id=self.mcp_server.id,
+ )
+ db.session.add(end_user)
+ db.session.commit()
+ return types.InitializeResult(
+ protocolVersion=types.SERVER_LATEST_PROTOCOL_VERSION,
+ capabilities=self.capabilities,
+ serverInfo=types.Implementation(name="Dify", version=dify_config.project.version),
+ instructions=self.mcp_server.description,
+ )
+
+ def list_tools(self):
+ if not self.end_user:
+ raise ValueError("User not found")
+ return types.ListToolsResult(
+ tools=[
+ types.Tool(
+ name=self.app.name,
+ description=self.mcp_server.description,
+ inputSchema=self.parameter_schema,
+ )
+ ],
+ )
+
+ def invoke_tool(self):
+ if not self.end_user:
+ raise ValueError("User not found")
+ request = cast(types.CallToolRequest, self.request.root)
+ args = request.params.arguments
+ if not args:
+ raise ValueError("No arguments provided")
+ if self.app.mode in {AppMode.WORKFLOW.value}:
+ args = {"inputs": args}
+ elif self.app.mode in {AppMode.COMPLETION.value}:
+ args = {"query": "", "inputs": args}
+ else:
+ args = {"query": args["query"], "inputs": {k: v for k, v in args.items() if k != "query"}}
+ response = AppGenerateService.generate(
+ self.app,
+ self.end_user,
+ args,
+ InvokeFrom.SERVICE_API,
+ streaming=self.app.mode == AppMode.AGENT_CHAT.value,
+ )
+ answer = ""
+ if isinstance(response, RateLimitGenerator):
+ for item in response.generator:
+ data = item
+ if isinstance(data, str) and data.startswith("data: "):
+ try:
+ json_str = data[6:].strip()
+ parsed_data = json.loads(json_str)
+ if parsed_data.get("event") == "agent_thought":
+ answer += parsed_data.get("thought", "")
+ except json.JSONDecodeError:
+ continue
+ if isinstance(response, Mapping):
+ if self.app.mode in {
+ AppMode.ADVANCED_CHAT.value,
+ AppMode.COMPLETION.value,
+ AppMode.CHAT.value,
+ AppMode.AGENT_CHAT.value,
+ }:
+ answer = response["answer"]
+ elif self.app.mode in {AppMode.WORKFLOW.value}:
+ answer = json.dumps(response["data"]["outputs"], ensure_ascii=False)
+ else:
+ raise ValueError("Invalid app mode")
+ # Not support image yet
+ return types.CallToolResult(content=[types.TextContent(text=answer, type="text")])
+
+ def retrieve_end_user(self):
+ return (
+ db.session.query(EndUser)
+ .filter(EndUser.external_user_id == self.mcp_server.id, EndUser.type == "mcp")
+ .first()
+ )
+
+ def _convert_input_form_to_parameters(self, user_input_form: list[VariableEntity]):
+ parameters: dict[str, dict[str, Any]] = {}
+ required = []
+ for item in user_input_form:
+ parameters[item.variable] = {}
+ if item.type in (
+ VariableEntityType.FILE,
+ VariableEntityType.FILE_LIST,
+ VariableEntityType.EXTERNAL_DATA_TOOL,
+ ):
+ continue
+ if item.required:
+ required.append(item.variable)
+ # if the workflow republished, the parameters not changed
+ # we should not raise error here
+ try:
+ description = self.mcp_server.parameters_dict[item.variable]
+ except KeyError:
+ description = ""
+ parameters[item.variable]["description"] = description
+ if item.type in (VariableEntityType.TEXT_INPUT, VariableEntityType.PARAGRAPH):
+ parameters[item.variable]["type"] = "string"
+ elif item.type == VariableEntityType.SELECT:
+ parameters[item.variable]["type"] = "string"
+ parameters[item.variable]["enum"] = item.options
+ elif item.type == VariableEntityType.NUMBER:
+ parameters[item.variable]["type"] = "float"
+ return parameters, required
diff --git a/api/core/mcp/session/base_session.py b/api/core/mcp/session/base_session.py
new file mode 100644
index 0000000000..1c0f582501
--- /dev/null
+++ b/api/core/mcp/session/base_session.py
@@ -0,0 +1,397 @@
+import logging
+import queue
+from collections.abc import Callable
+from concurrent.futures import ThreadPoolExecutor
+from contextlib import ExitStack
+from datetime import timedelta
+from types import TracebackType
+from typing import Any, Generic, Self, TypeVar
+
+from httpx import HTTPStatusError
+from pydantic import BaseModel
+
+from core.mcp.error import MCPAuthError, MCPConnectionError
+from core.mcp.types import (
+ CancelledNotification,
+ ClientNotification,
+ ClientRequest,
+ ClientResult,
+ ErrorData,
+ JSONRPCError,
+ JSONRPCMessage,
+ JSONRPCNotification,
+ JSONRPCRequest,
+ JSONRPCResponse,
+ MessageMetadata,
+ RequestId,
+ RequestParams,
+ ServerMessageMetadata,
+ ServerNotification,
+ ServerRequest,
+ ServerResult,
+ SessionMessage,
+)
+
+SendRequestT = TypeVar("SendRequestT", ClientRequest, ServerRequest)
+SendResultT = TypeVar("SendResultT", ClientResult, ServerResult)
+SendNotificationT = TypeVar("SendNotificationT", ClientNotification, ServerNotification)
+ReceiveRequestT = TypeVar("ReceiveRequestT", ClientRequest, ServerRequest)
+ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel)
+ReceiveNotificationT = TypeVar("ReceiveNotificationT", ClientNotification, ServerNotification)
+DEFAULT_RESPONSE_READ_TIMEOUT = 1.0
+
+
+class RequestResponder(Generic[ReceiveRequestT, SendResultT]):
+ """Handles responding to MCP requests and manages request lifecycle.
+
+ This class MUST be used as a context manager to ensure proper cleanup and
+ cancellation handling:
+
+ Example:
+ with request_responder as resp:
+ resp.respond(result)
+
+ The context manager ensures:
+ 1. Proper cancellation scope setup and cleanup
+ 2. Request completion tracking
+ 3. Cleanup of in-flight requests
+ """
+
+ request: ReceiveRequestT
+ _session: Any
+ _on_complete: Callable[["RequestResponder[ReceiveRequestT, SendResultT]"], Any]
+
+ def __init__(
+ self,
+ request_id: RequestId,
+ request_meta: RequestParams.Meta | None,
+ request: ReceiveRequestT,
+ session: """BaseSession[
+ SendRequestT,
+ SendNotificationT,
+ SendResultT,
+ ReceiveRequestT,
+ ReceiveNotificationT
+ ]""",
+ on_complete: Callable[["RequestResponder[ReceiveRequestT, SendResultT]"], Any],
+ ) -> None:
+ self.request_id = request_id
+ self.request_meta = request_meta
+ self.request = request
+ self._session = session
+ self._completed = False
+ self._on_complete = on_complete
+ self._entered = False # Track if we're in a context manager
+
+ def __enter__(self) -> "RequestResponder[ReceiveRequestT, SendResultT]":
+ """Enter the context manager, enabling request cancellation tracking."""
+ self._entered = True
+ return self
+
+ def __exit__(
+ self,
+ exc_type: type[BaseException] | None,
+ exc_val: BaseException | None,
+ exc_tb: TracebackType | None,
+ ) -> None:
+ """Exit the context manager, performing cleanup and notifying completion."""
+ try:
+ if self._completed:
+ self._on_complete(self)
+ finally:
+ self._entered = False
+
+ def respond(self, response: SendResultT | ErrorData) -> None:
+ """Send a response for this request.
+
+ Must be called within a context manager block.
+ Raises:
+ RuntimeError: If not used within a context manager
+ AssertionError: If request was already responded to
+ """
+ if not self._entered:
+ raise RuntimeError("RequestResponder must be used as a context manager")
+ assert not self._completed, "Request already responded to"
+
+ self._completed = True
+
+ self._session._send_response(request_id=self.request_id, response=response)
+
+ def cancel(self) -> None:
+ """Cancel this request and mark it as completed."""
+ if not self._entered:
+ raise RuntimeError("RequestResponder must be used as a context manager")
+
+ self._completed = True # Mark as completed so it's removed from in_flight
+ # Send an error response to indicate cancellation
+ self._session._send_response(
+ request_id=self.request_id,
+ response=ErrorData(code=0, message="Request cancelled", data=None),
+ )
+
+
+class BaseSession(
+ Generic[
+ SendRequestT,
+ SendNotificationT,
+ SendResultT,
+ ReceiveRequestT,
+ ReceiveNotificationT,
+ ],
+):
+ """
+ Implements an MCP "session" on top of read/write streams, including features
+ like request/response linking, notifications, and progress.
+
+ This class is a context manager that automatically starts processing
+ messages when entered.
+ """
+
+ _response_streams: dict[RequestId, queue.Queue[JSONRPCResponse | JSONRPCError]]
+ _request_id: int
+ _in_flight: dict[RequestId, RequestResponder[ReceiveRequestT, SendResultT]]
+ _receive_request_type: type[ReceiveRequestT]
+ _receive_notification_type: type[ReceiveNotificationT]
+
+ def __init__(
+ self,
+ read_stream: queue.Queue,
+ write_stream: queue.Queue,
+ receive_request_type: type[ReceiveRequestT],
+ receive_notification_type: type[ReceiveNotificationT],
+ # If none, reading will never time out
+ read_timeout_seconds: timedelta | None = None,
+ ) -> None:
+ self._read_stream = read_stream
+ self._write_stream = write_stream
+ self._response_streams = {}
+ self._request_id = 0
+ self._receive_request_type = receive_request_type
+ self._receive_notification_type = receive_notification_type
+ self._session_read_timeout_seconds = read_timeout_seconds
+ self._in_flight = {}
+ self._exit_stack = ExitStack()
+
+ def __enter__(self) -> Self:
+ self._executor = ThreadPoolExecutor()
+ self._receiver_future = self._executor.submit(self._receive_loop)
+ return self
+
+ def check_receiver_status(self) -> None:
+ if self._receiver_future.done():
+ self._receiver_future.result()
+
+ def __exit__(
+ self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None
+ ) -> None:
+ self._exit_stack.close()
+ self._read_stream.put(None)
+ self._write_stream.put(None)
+
+ def send_request(
+ self,
+ request: SendRequestT,
+ result_type: type[ReceiveResultT],
+ request_read_timeout_seconds: timedelta | None = None,
+ metadata: MessageMetadata = None,
+ ) -> ReceiveResultT:
+ """
+ Sends a request and wait for a response. Raises an McpError if the
+ response contains an error. If a request read timeout is provided, it
+ will take precedence over the session read timeout.
+
+ Do not use this method to emit notifications! Use send_notification()
+ instead.
+ """
+ self.check_receiver_status()
+
+ request_id = self._request_id
+ self._request_id = request_id + 1
+
+ response_queue: queue.Queue[JSONRPCResponse | JSONRPCError] = queue.Queue()
+ self._response_streams[request_id] = response_queue
+
+ try:
+ jsonrpc_request = JSONRPCRequest(
+ jsonrpc="2.0",
+ id=request_id,
+ **request.model_dump(by_alias=True, mode="json", exclude_none=True),
+ )
+
+ self._write_stream.put(SessionMessage(message=JSONRPCMessage(jsonrpc_request), metadata=metadata))
+ timeout = DEFAULT_RESPONSE_READ_TIMEOUT
+ if request_read_timeout_seconds is not None:
+ timeout = float(request_read_timeout_seconds.total_seconds())
+ elif self._session_read_timeout_seconds is not None:
+ timeout = float(self._session_read_timeout_seconds.total_seconds())
+ while True:
+ try:
+ response_or_error = response_queue.get(timeout=timeout)
+ break
+ except queue.Empty:
+ self.check_receiver_status()
+ continue
+
+ if response_or_error is None:
+ raise MCPConnectionError(
+ ErrorData(
+ code=500,
+ message="No response received",
+ )
+ )
+ elif isinstance(response_or_error, JSONRPCError):
+ if response_or_error.error.code == 401:
+ raise MCPAuthError(
+ ErrorData(code=response_or_error.error.code, message=response_or_error.error.message)
+ )
+ else:
+ raise MCPConnectionError(
+ ErrorData(code=response_or_error.error.code, message=response_or_error.error.message)
+ )
+ else:
+ return result_type.model_validate(response_or_error.result)
+
+ finally:
+ self._response_streams.pop(request_id, None)
+
+ def send_notification(
+ self,
+ notification: SendNotificationT,
+ related_request_id: RequestId | None = None,
+ ) -> None:
+ """
+ Emits a notification, which is a one-way message that does not expect
+ a response.
+ """
+ self.check_receiver_status()
+
+ # Some transport implementations may need to set the related_request_id
+ # to attribute to the notifications to the request that triggered them.
+ jsonrpc_notification = JSONRPCNotification(
+ jsonrpc="2.0",
+ **notification.model_dump(by_alias=True, mode="json", exclude_none=True),
+ )
+ session_message = SessionMessage(
+ message=JSONRPCMessage(jsonrpc_notification),
+ metadata=ServerMessageMetadata(related_request_id=related_request_id) if related_request_id else None,
+ )
+ self._write_stream.put(session_message)
+
+ def _send_response(self, request_id: RequestId, response: SendResultT | ErrorData) -> None:
+ if isinstance(response, ErrorData):
+ jsonrpc_error = JSONRPCError(jsonrpc="2.0", id=request_id, error=response)
+ session_message = SessionMessage(message=JSONRPCMessage(jsonrpc_error))
+ self._write_stream.put(session_message)
+ else:
+ jsonrpc_response = JSONRPCResponse(
+ jsonrpc="2.0",
+ id=request_id,
+ result=response.model_dump(by_alias=True, mode="json", exclude_none=True),
+ )
+ session_message = SessionMessage(message=JSONRPCMessage(jsonrpc_response))
+ self._write_stream.put(session_message)
+
+ def _receive_loop(self) -> None:
+ """
+ Main message processing loop.
+ In a real synchronous implementation, this would likely run in a separate thread.
+ """
+ while True:
+ try:
+ # Attempt to receive a message (this would be blocking in a synchronous context)
+ message = self._read_stream.get(timeout=DEFAULT_RESPONSE_READ_TIMEOUT)
+ if message is None:
+ break
+ if isinstance(message, HTTPStatusError):
+ response_queue = self._response_streams.get(self._request_id - 1)
+ if response_queue is not None:
+ response_queue.put(
+ JSONRPCError(
+ jsonrpc="2.0",
+ id=self._request_id - 1,
+ error=ErrorData(code=message.response.status_code, message=message.args[0]),
+ )
+ )
+ else:
+ self._handle_incoming(RuntimeError(f"Received response with an unknown request ID: {message}"))
+ elif isinstance(message, Exception):
+ self._handle_incoming(message)
+ elif isinstance(message.message.root, JSONRPCRequest):
+ validated_request = self._receive_request_type.model_validate(
+ message.message.root.model_dump(by_alias=True, mode="json", exclude_none=True)
+ )
+
+ responder = RequestResponder(
+ request_id=message.message.root.id,
+ request_meta=validated_request.root.params.meta if validated_request.root.params else None,
+ request=validated_request,
+ session=self,
+ on_complete=lambda r: self._in_flight.pop(r.request_id, None),
+ )
+
+ self._in_flight[responder.request_id] = responder
+ self._received_request(responder)
+
+ if not responder._completed:
+ self._handle_incoming(responder)
+
+ elif isinstance(message.message.root, JSONRPCNotification):
+ try:
+ notification = self._receive_notification_type.model_validate(
+ message.message.root.model_dump(by_alias=True, mode="json", exclude_none=True)
+ )
+ # Handle cancellation notifications
+ if isinstance(notification.root, CancelledNotification):
+ cancelled_id = notification.root.params.requestId
+ if cancelled_id in self._in_flight:
+ self._in_flight[cancelled_id].cancel()
+ else:
+ self._received_notification(notification)
+ self._handle_incoming(notification)
+ except Exception as e:
+ # For other validation errors, log and continue
+ logging.warning(f"Failed to validate notification: {e}. Message was: {message.message.root}")
+ else: # Response or error
+ response_queue = self._response_streams.get(message.message.root.id)
+ if response_queue is not None:
+ response_queue.put(message.message.root)
+ else:
+ self._handle_incoming(RuntimeError(f"Server Error: {message}"))
+ except queue.Empty:
+ continue
+ except Exception as e:
+ logging.exception("Error in message processing loop")
+ raise
+
+ def _received_request(self, responder: RequestResponder[ReceiveRequestT, SendResultT]) -> None:
+ """
+ Can be overridden by subclasses to handle a request without needing to
+ listen on the message stream.
+
+ If the request is responded to within this method, it will not be
+ forwarded on to the message stream.
+ """
+ pass
+
+ def _received_notification(self, notification: ReceiveNotificationT) -> None:
+ """
+ Can be overridden by subclasses to handle a notification without needing
+ to listen on the message stream.
+ """
+ pass
+
+ def send_progress_notification(
+ self, progress_token: str | int, progress: float, total: float | None = None
+ ) -> None:
+ """
+ Sends a progress notification for a request that is currently being
+ processed.
+ """
+ pass
+
+ def _handle_incoming(
+ self,
+ req: RequestResponder[ReceiveRequestT, SendResultT] | ReceiveNotificationT | Exception,
+ ) -> None:
+ """A generic handler for incoming messages. Overwritten by subclasses."""
+ pass
diff --git a/api/core/mcp/session/client_session.py b/api/core/mcp/session/client_session.py
new file mode 100644
index 0000000000..ed2ad508ab
--- /dev/null
+++ b/api/core/mcp/session/client_session.py
@@ -0,0 +1,365 @@
+from datetime import timedelta
+from typing import Any, Protocol
+
+from pydantic import AnyUrl, TypeAdapter
+
+from configs import dify_config
+from core.mcp import types
+from core.mcp.entities import SUPPORTED_PROTOCOL_VERSIONS, RequestContext
+from core.mcp.session.base_session import BaseSession, RequestResponder
+
+DEFAULT_CLIENT_INFO = types.Implementation(name="Dify", version=dify_config.project.version)
+
+
+class SamplingFnT(Protocol):
+ def __call__(
+ self,
+ context: RequestContext["ClientSession", Any],
+ params: types.CreateMessageRequestParams,
+ ) -> types.CreateMessageResult | types.ErrorData: ...
+
+
+class ListRootsFnT(Protocol):
+ def __call__(self, context: RequestContext["ClientSession", Any]) -> types.ListRootsResult | types.ErrorData: ...
+
+
+class LoggingFnT(Protocol):
+ def __call__(
+ self,
+ params: types.LoggingMessageNotificationParams,
+ ) -> None: ...
+
+
+class MessageHandlerFnT(Protocol):
+ def __call__(
+ self,
+ message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
+ ) -> None: ...
+
+
+def _default_message_handler(
+ message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
+) -> None:
+ if isinstance(message, Exception):
+ raise ValueError(str(message))
+ elif isinstance(message, (types.ServerNotification | RequestResponder)):
+ pass
+
+
+def _default_sampling_callback(
+ context: RequestContext["ClientSession", Any],
+ params: types.CreateMessageRequestParams,
+) -> types.CreateMessageResult | types.ErrorData:
+ return types.ErrorData(
+ code=types.INVALID_REQUEST,
+ message="Sampling not supported",
+ )
+
+
+def _default_list_roots_callback(
+ context: RequestContext["ClientSession", Any],
+) -> types.ListRootsResult | types.ErrorData:
+ return types.ErrorData(
+ code=types.INVALID_REQUEST,
+ message="List roots not supported",
+ )
+
+
+def _default_logging_callback(
+ params: types.LoggingMessageNotificationParams,
+) -> None:
+ pass
+
+
+ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData)
+
+
+class ClientSession(
+ BaseSession[
+ types.ClientRequest,
+ types.ClientNotification,
+ types.ClientResult,
+ types.ServerRequest,
+ types.ServerNotification,
+ ]
+):
+ def __init__(
+ self,
+ read_stream,
+ write_stream,
+ read_timeout_seconds: timedelta | None = None,
+ sampling_callback: SamplingFnT | None = None,
+ list_roots_callback: ListRootsFnT | None = None,
+ logging_callback: LoggingFnT | None = None,
+ message_handler: MessageHandlerFnT | None = None,
+ client_info: types.Implementation | None = None,
+ ) -> None:
+ super().__init__(
+ read_stream,
+ write_stream,
+ types.ServerRequest,
+ types.ServerNotification,
+ read_timeout_seconds=read_timeout_seconds,
+ )
+ self._client_info = client_info or DEFAULT_CLIENT_INFO
+ self._sampling_callback = sampling_callback or _default_sampling_callback
+ self._list_roots_callback = list_roots_callback or _default_list_roots_callback
+ self._logging_callback = logging_callback or _default_logging_callback
+ self._message_handler = message_handler or _default_message_handler
+
+ def initialize(self) -> types.InitializeResult:
+ sampling = types.SamplingCapability()
+ roots = types.RootsCapability(
+ # TODO: Should this be based on whether we
+ # _will_ send notifications, or only whether
+ # they're supported?
+ listChanged=True,
+ )
+
+ result = self.send_request(
+ types.ClientRequest(
+ types.InitializeRequest(
+ method="initialize",
+ params=types.InitializeRequestParams(
+ protocolVersion=types.LATEST_PROTOCOL_VERSION,
+ capabilities=types.ClientCapabilities(
+ sampling=sampling,
+ experimental=None,
+ roots=roots,
+ ),
+ clientInfo=self._client_info,
+ ),
+ )
+ ),
+ types.InitializeResult,
+ )
+
+ if result.protocolVersion not in SUPPORTED_PROTOCOL_VERSIONS:
+ raise RuntimeError(f"Unsupported protocol version from the server: {result.protocolVersion}")
+
+ self.send_notification(
+ types.ClientNotification(types.InitializedNotification(method="notifications/initialized"))
+ )
+
+ return result
+
+ def send_ping(self) -> types.EmptyResult:
+ """Send a ping request."""
+ return self.send_request(
+ types.ClientRequest(
+ types.PingRequest(
+ method="ping",
+ )
+ ),
+ types.EmptyResult,
+ )
+
+ def send_progress_notification(
+ self, progress_token: str | int, progress: float, total: float | None = None
+ ) -> None:
+ """Send a progress notification."""
+ self.send_notification(
+ types.ClientNotification(
+ types.ProgressNotification(
+ method="notifications/progress",
+ params=types.ProgressNotificationParams(
+ progressToken=progress_token,
+ progress=progress,
+ total=total,
+ ),
+ ),
+ )
+ )
+
+ def set_logging_level(self, level: types.LoggingLevel) -> types.EmptyResult:
+ """Send a logging/setLevel request."""
+ return self.send_request(
+ types.ClientRequest(
+ types.SetLevelRequest(
+ method="logging/setLevel",
+ params=types.SetLevelRequestParams(level=level),
+ )
+ ),
+ types.EmptyResult,
+ )
+
+ def list_resources(self) -> types.ListResourcesResult:
+ """Send a resources/list request."""
+ return self.send_request(
+ types.ClientRequest(
+ types.ListResourcesRequest(
+ method="resources/list",
+ )
+ ),
+ types.ListResourcesResult,
+ )
+
+ def list_resource_templates(self) -> types.ListResourceTemplatesResult:
+ """Send a resources/templates/list request."""
+ return self.send_request(
+ types.ClientRequest(
+ types.ListResourceTemplatesRequest(
+ method="resources/templates/list",
+ )
+ ),
+ types.ListResourceTemplatesResult,
+ )
+
+ def read_resource(self, uri: AnyUrl) -> types.ReadResourceResult:
+ """Send a resources/read request."""
+ return self.send_request(
+ types.ClientRequest(
+ types.ReadResourceRequest(
+ method="resources/read",
+ params=types.ReadResourceRequestParams(uri=uri),
+ )
+ ),
+ types.ReadResourceResult,
+ )
+
+ def subscribe_resource(self, uri: AnyUrl) -> types.EmptyResult:
+ """Send a resources/subscribe request."""
+ return self.send_request(
+ types.ClientRequest(
+ types.SubscribeRequest(
+ method="resources/subscribe",
+ params=types.SubscribeRequestParams(uri=uri),
+ )
+ ),
+ types.EmptyResult,
+ )
+
+ def unsubscribe_resource(self, uri: AnyUrl) -> types.EmptyResult:
+ """Send a resources/unsubscribe request."""
+ return self.send_request(
+ types.ClientRequest(
+ types.UnsubscribeRequest(
+ method="resources/unsubscribe",
+ params=types.UnsubscribeRequestParams(uri=uri),
+ )
+ ),
+ types.EmptyResult,
+ )
+
+ def call_tool(
+ self,
+ name: str,
+ arguments: dict[str, Any] | None = None,
+ read_timeout_seconds: timedelta | None = None,
+ ) -> types.CallToolResult:
+ """Send a tools/call request."""
+
+ return self.send_request(
+ types.ClientRequest(
+ types.CallToolRequest(
+ method="tools/call",
+ params=types.CallToolRequestParams(name=name, arguments=arguments),
+ )
+ ),
+ types.CallToolResult,
+ request_read_timeout_seconds=read_timeout_seconds,
+ )
+
+ def list_prompts(self) -> types.ListPromptsResult:
+ """Send a prompts/list request."""
+ return self.send_request(
+ types.ClientRequest(
+ types.ListPromptsRequest(
+ method="prompts/list",
+ )
+ ),
+ types.ListPromptsResult,
+ )
+
+ def get_prompt(self, name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult:
+ """Send a prompts/get request."""
+ return self.send_request(
+ types.ClientRequest(
+ types.GetPromptRequest(
+ method="prompts/get",
+ params=types.GetPromptRequestParams(name=name, arguments=arguments),
+ )
+ ),
+ types.GetPromptResult,
+ )
+
+ def complete(
+ self,
+ ref: types.ResourceReference | types.PromptReference,
+ argument: dict[str, str],
+ ) -> types.CompleteResult:
+ """Send a completion/complete request."""
+ return self.send_request(
+ types.ClientRequest(
+ types.CompleteRequest(
+ method="completion/complete",
+ params=types.CompleteRequestParams(
+ ref=ref,
+ argument=types.CompletionArgument(**argument),
+ ),
+ )
+ ),
+ types.CompleteResult,
+ )
+
+ def list_tools(self) -> types.ListToolsResult:
+ """Send a tools/list request."""
+ return self.send_request(
+ types.ClientRequest(
+ types.ListToolsRequest(
+ method="tools/list",
+ )
+ ),
+ types.ListToolsResult,
+ )
+
+ def send_roots_list_changed(self) -> None:
+ """Send a roots/list_changed notification."""
+ self.send_notification(
+ types.ClientNotification(
+ types.RootsListChangedNotification(
+ method="notifications/roots/list_changed",
+ )
+ )
+ )
+
+ def _received_request(self, responder: RequestResponder[types.ServerRequest, types.ClientResult]) -> None:
+ ctx = RequestContext[ClientSession, Any](
+ request_id=responder.request_id,
+ meta=responder.request_meta,
+ session=self,
+ lifespan_context=None,
+ )
+
+ match responder.request.root:
+ case types.CreateMessageRequest(params=params):
+ with responder:
+ response = self._sampling_callback(ctx, params)
+ client_response = ClientResponse.validate_python(response)
+ responder.respond(client_response)
+
+ case types.ListRootsRequest():
+ with responder:
+ list_roots_response = self._list_roots_callback(ctx)
+ client_response = ClientResponse.validate_python(list_roots_response)
+ responder.respond(client_response)
+
+ case types.PingRequest():
+ with responder:
+ return responder.respond(types.ClientResult(root=types.EmptyResult()))
+
+ def _handle_incoming(
+ self,
+ req: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
+ ) -> None:
+ """Handle incoming messages by forwarding to the message handler."""
+ self._message_handler(req)
+
+ def _received_notification(self, notification: types.ServerNotification) -> None:
+ """Handle notifications from the server."""
+ # Process specific notification types
+ match notification.root:
+ case types.LoggingMessageNotification(params=params):
+ self._logging_callback(params)
+ case _:
+ pass
diff --git a/api/core/mcp/types.py b/api/core/mcp/types.py
new file mode 100644
index 0000000000..99d985a781
--- /dev/null
+++ b/api/core/mcp/types.py
@@ -0,0 +1,1217 @@
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import (
+ Annotated,
+ Any,
+ Generic,
+ Literal,
+ Optional,
+ TypeAlias,
+ TypeVar,
+)
+
+from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel
+from pydantic.networks import AnyUrl, UrlConstraints
+
+"""
+Model Context Protocol bindings for Python
+
+These bindings were generated from https://github.com/modelcontextprotocol/specification,
+using Claude, with a prompt something like the following:
+
+Generate idiomatic Python bindings for this schema for MCP, or the "Model Context
+Protocol." The schema is defined in TypeScript, but there's also a JSON Schema version
+for reference.
+
+* For the bindings, let's use Pydantic V2 models.
+* Each model should allow extra fields everywhere, by specifying `model_config =
+ ConfigDict(extra='allow')`. Do this in every case, instead of a custom base class.
+* Union types should be represented with a Pydantic `RootModel`.
+* Define additional model classes instead of using dictionaries. Do this even if they're
+ not separate types in the schema.
+"""
+# Client support both version, not support 2025-06-18 yet.
+LATEST_PROTOCOL_VERSION = "2025-03-26"
+# Server support 2024-11-05 to allow claude to use.
+SERVER_LATEST_PROTOCOL_VERSION = "2024-11-05"
+ProgressToken = str | int
+Cursor = str
+Role = Literal["user", "assistant"]
+RequestId = Annotated[int | str, Field(union_mode="left_to_right")]
+AnyFunction: TypeAlias = Callable[..., Any]
+
+
+class RequestParams(BaseModel):
+ class Meta(BaseModel):
+ progressToken: ProgressToken | None = None
+ """
+ If specified, the caller requests out-of-band progress notifications for
+ this request (as represented by notifications/progress). The value of this
+ parameter is an opaque token that will be attached to any subsequent
+ notifications. The receiver is not obligated to provide these notifications.
+ """
+
+ model_config = ConfigDict(extra="allow")
+
+ meta: Meta | None = Field(alias="_meta", default=None)
+
+
+class NotificationParams(BaseModel):
+ class Meta(BaseModel):
+ model_config = ConfigDict(extra="allow")
+
+ meta: Meta | None = Field(alias="_meta", default=None)
+ """
+ This parameter name is reserved by MCP to allow clients and servers to attach
+ additional metadata to their notifications.
+ """
+
+
+RequestParamsT = TypeVar("RequestParamsT", bound=RequestParams | dict[str, Any] | None)
+NotificationParamsT = TypeVar("NotificationParamsT", bound=NotificationParams | dict[str, Any] | None)
+MethodT = TypeVar("MethodT", bound=str)
+
+
+class Request(BaseModel, Generic[RequestParamsT, MethodT]):
+ """Base class for JSON-RPC requests."""
+
+ method: MethodT
+ params: RequestParamsT
+ model_config = ConfigDict(extra="allow")
+
+
+class PaginatedRequest(Request[RequestParamsT, MethodT]):
+ cursor: Cursor | None = None
+ """
+ An opaque token representing the current pagination position.
+ If provided, the server should return results starting after this cursor.
+ """
+
+
+class Notification(BaseModel, Generic[NotificationParamsT, MethodT]):
+ """Base class for JSON-RPC notifications."""
+
+ method: MethodT
+ params: NotificationParamsT
+ model_config = ConfigDict(extra="allow")
+
+
+class Result(BaseModel):
+ """Base class for JSON-RPC results."""
+
+ model_config = ConfigDict(extra="allow")
+
+ meta: dict[str, Any] | None = Field(alias="_meta", default=None)
+ """
+ This result property is reserved by the protocol to allow clients and servers to
+ attach additional metadata to their responses.
+ """
+
+
+class PaginatedResult(Result):
+ nextCursor: Cursor | None = None
+ """
+ An opaque token representing the pagination position after the last returned result.
+ If present, there may be more results available.
+ """
+
+
+class JSONRPCRequest(Request[dict[str, Any] | None, str]):
+ """A request that expects a response."""
+
+ jsonrpc: Literal["2.0"]
+ id: RequestId
+ method: str
+ params: dict[str, Any] | None = None
+
+
+class JSONRPCNotification(Notification[dict[str, Any] | None, str]):
+ """A notification which does not expect a response."""
+
+ jsonrpc: Literal["2.0"]
+ params: dict[str, Any] | None = None
+
+
+class JSONRPCResponse(BaseModel):
+ """A successful (non-error) response to a request."""
+
+ jsonrpc: Literal["2.0"]
+ id: RequestId
+ result: dict[str, Any]
+ model_config = ConfigDict(extra="allow")
+
+
+# Standard JSON-RPC error codes
+PARSE_ERROR = -32700
+INVALID_REQUEST = -32600
+METHOD_NOT_FOUND = -32601
+INVALID_PARAMS = -32602
+INTERNAL_ERROR = -32603
+
+
+class ErrorData(BaseModel):
+ """Error information for JSON-RPC error responses."""
+
+ code: int
+ """The error type that occurred."""
+
+ message: str
+ """
+ A short description of the error. The message SHOULD be limited to a concise single
+ sentence.
+ """
+
+ data: Any | None = None
+ """
+ Additional information about the error. The value of this member is defined by the
+ sender (e.g. detailed error information, nested errors etc.).
+ """
+
+ model_config = ConfigDict(extra="allow")
+
+
+class JSONRPCError(BaseModel):
+ """A response to a request that indicates an error occurred."""
+
+ jsonrpc: Literal["2.0"]
+ id: str | int
+ error: ErrorData
+ model_config = ConfigDict(extra="allow")
+
+
+class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError]):
+ pass
+
+
+class EmptyResult(Result):
+ """A response that indicates success but carries no data."""
+
+
+class Implementation(BaseModel):
+ """Describes the name and version of an MCP implementation."""
+
+ name: str
+ version: str
+ model_config = ConfigDict(extra="allow")
+
+
+class RootsCapability(BaseModel):
+ """Capability for root operations."""
+
+ listChanged: bool | None = None
+ """Whether the client supports notifications for changes to the roots list."""
+ model_config = ConfigDict(extra="allow")
+
+
+class SamplingCapability(BaseModel):
+ """Capability for logging operations."""
+
+ model_config = ConfigDict(extra="allow")
+
+
+class ClientCapabilities(BaseModel):
+ """Capabilities a client may support."""
+
+ experimental: dict[str, dict[str, Any]] | None = None
+ """Experimental, non-standard capabilities that the client supports."""
+ sampling: SamplingCapability | None = None
+ """Present if the client supports sampling from an LLM."""
+ roots: RootsCapability | None = None
+ """Present if the client supports listing roots."""
+ model_config = ConfigDict(extra="allow")
+
+
+class PromptsCapability(BaseModel):
+ """Capability for prompts operations."""
+
+ listChanged: bool | None = None
+ """Whether this server supports notifications for changes to the prompt list."""
+ model_config = ConfigDict(extra="allow")
+
+
+class ResourcesCapability(BaseModel):
+ """Capability for resources operations."""
+
+ subscribe: bool | None = None
+ """Whether this server supports subscribing to resource updates."""
+ listChanged: bool | None = None
+ """Whether this server supports notifications for changes to the resource list."""
+ model_config = ConfigDict(extra="allow")
+
+
+class ToolsCapability(BaseModel):
+ """Capability for tools operations."""
+
+ listChanged: bool | None = None
+ """Whether this server supports notifications for changes to the tool list."""
+ model_config = ConfigDict(extra="allow")
+
+
+class LoggingCapability(BaseModel):
+ """Capability for logging operations."""
+
+ model_config = ConfigDict(extra="allow")
+
+
+class ServerCapabilities(BaseModel):
+ """Capabilities that a server may support."""
+
+ experimental: dict[str, dict[str, Any]] | None = None
+ """Experimental, non-standard capabilities that the server supports."""
+ logging: LoggingCapability | None = None
+ """Present if the server supports sending log messages to the client."""
+ prompts: PromptsCapability | None = None
+ """Present if the server offers any prompt templates."""
+ resources: ResourcesCapability | None = None
+ """Present if the server offers any resources to read."""
+ tools: ToolsCapability | None = None
+ """Present if the server offers any tools to call."""
+ model_config = ConfigDict(extra="allow")
+
+
+class InitializeRequestParams(RequestParams):
+ """Parameters for the initialize request."""
+
+ protocolVersion: str | int
+ """The latest version of the Model Context Protocol that the client supports."""
+ capabilities: ClientCapabilities
+ clientInfo: Implementation
+ model_config = ConfigDict(extra="allow")
+
+
+class InitializeRequest(Request[InitializeRequestParams, Literal["initialize"]]):
+ """
+ This request is sent from the client to the server when it first connects, asking it
+ to begin initialization.
+ """
+
+ method: Literal["initialize"]
+ params: InitializeRequestParams
+
+
+class InitializeResult(Result):
+ """After receiving an initialize request from the client, the server sends this."""
+
+ protocolVersion: str | int
+ """The version of the Model Context Protocol that the server wants to use."""
+ capabilities: ServerCapabilities
+ serverInfo: Implementation
+ instructions: str | None = None
+ """Instructions describing how to use the server and its features."""
+
+
+class InitializedNotification(Notification[NotificationParams | None, Literal["notifications/initialized"]]):
+ """
+ This notification is sent from the client to the server after initialization has
+ finished.
+ """
+
+ method: Literal["notifications/initialized"]
+ params: NotificationParams | None = None
+
+
+class PingRequest(Request[RequestParams | None, Literal["ping"]]):
+ """
+ A ping, issued by either the server or the client, to check that the other party is
+ still alive.
+ """
+
+ method: Literal["ping"]
+ params: RequestParams | None = None
+
+
+class ProgressNotificationParams(NotificationParams):
+ """Parameters for progress notifications."""
+
+ progressToken: ProgressToken
+ """
+ The progress token which was given in the initial request, used to associate this
+ notification with the request that is proceeding.
+ """
+ progress: float
+ """
+ The progress thus far. This should increase every time progress is made, even if the
+ total is unknown.
+ """
+ total: float | None = None
+ """Total number of items to process (or total progress required), if known."""
+ model_config = ConfigDict(extra="allow")
+
+
+class ProgressNotification(Notification[ProgressNotificationParams, Literal["notifications/progress"]]):
+ """
+ An out-of-band notification used to inform the receiver of a progress update for a
+ long-running request.
+ """
+
+ method: Literal["notifications/progress"]
+ params: ProgressNotificationParams
+
+
+class ListResourcesRequest(PaginatedRequest[RequestParams | None, Literal["resources/list"]]):
+ """Sent from the client to request a list of resources the server has."""
+
+ method: Literal["resources/list"]
+ params: RequestParams | None = None
+
+
+class Annotations(BaseModel):
+ audience: list[Role] | None = None
+ priority: Annotated[float, Field(ge=0.0, le=1.0)] | None = None
+ model_config = ConfigDict(extra="allow")
+
+
+class Resource(BaseModel):
+ """A known resource that the server is capable of reading."""
+
+ uri: Annotated[AnyUrl, UrlConstraints(host_required=False)]
+ """The URI of this resource."""
+ name: str
+ """A human-readable name for this resource."""
+ description: str | None = None
+ """A description of what this resource represents."""
+ mimeType: str | None = None
+ """The MIME type of this resource, if known."""
+ size: int | None = None
+ """
+ The size of the raw resource content, in bytes (i.e., before base64 encoding
+ or any tokenization), if known.
+
+ This can be used by Hosts to display file sizes and estimate context window usage.
+ """
+ annotations: Annotations | None = None
+ model_config = ConfigDict(extra="allow")
+
+
+class ResourceTemplate(BaseModel):
+ """A template description for resources available on the server."""
+
+ uriTemplate: str
+ """
+ A URI template (according to RFC 6570) that can be used to construct resource
+ URIs.
+ """
+ name: str
+ """A human-readable name for the type of resource this template refers to."""
+ description: str | None = None
+ """A human-readable description of what this template is for."""
+ mimeType: str | None = None
+ """
+ The MIME type for all resources that match this template. This should only be
+ included if all resources matching this template have the same type.
+ """
+ annotations: Annotations | None = None
+ model_config = ConfigDict(extra="allow")
+
+
+class ListResourcesResult(PaginatedResult):
+ """The server's response to a resources/list request from the client."""
+
+ resources: list[Resource]
+
+
+class ListResourceTemplatesRequest(PaginatedRequest[RequestParams | None, Literal["resources/templates/list"]]):
+ """Sent from the client to request a list of resource templates the server has."""
+
+ method: Literal["resources/templates/list"]
+ params: RequestParams | None = None
+
+
+class ListResourceTemplatesResult(PaginatedResult):
+ """The server's response to a resources/templates/list request from the client."""
+
+ resourceTemplates: list[ResourceTemplate]
+
+
+class ReadResourceRequestParams(RequestParams):
+ """Parameters for reading a resource."""
+
+ uri: Annotated[AnyUrl, UrlConstraints(host_required=False)]
+ """
+ The URI of the resource to read. The URI can use any protocol; it is up to the
+ server how to interpret it.
+ """
+ model_config = ConfigDict(extra="allow")
+
+
+class ReadResourceRequest(Request[ReadResourceRequestParams, Literal["resources/read"]]):
+ """Sent from the client to the server, to read a specific resource URI."""
+
+ method: Literal["resources/read"]
+ params: ReadResourceRequestParams
+
+
+class ResourceContents(BaseModel):
+ """The contents of a specific resource or sub-resource."""
+
+ uri: Annotated[AnyUrl, UrlConstraints(host_required=False)]
+ """The URI of this resource."""
+ mimeType: str | None = None
+ """The MIME type of this resource, if known."""
+ model_config = ConfigDict(extra="allow")
+
+
+class TextResourceContents(ResourceContents):
+ """Text contents of a resource."""
+
+ text: str
+ """
+ The text of the item. This must only be set if the item can actually be represented
+ as text (not binary data).
+ """
+
+
+class BlobResourceContents(ResourceContents):
+ """Binary contents of a resource."""
+
+ blob: str
+ """A base64-encoded string representing the binary data of the item."""
+
+
+class ReadResourceResult(Result):
+ """The server's response to a resources/read request from the client."""
+
+ contents: list[TextResourceContents | BlobResourceContents]
+
+
+class ResourceListChangedNotification(
+ Notification[NotificationParams | None, Literal["notifications/resources/list_changed"]]
+):
+ """
+ An optional notification from the server to the client, informing it that the list
+ of resources it can read from has changed.
+ """
+
+ method: Literal["notifications/resources/list_changed"]
+ params: NotificationParams | None = None
+
+
+class SubscribeRequestParams(RequestParams):
+ """Parameters for subscribing to a resource."""
+
+ uri: Annotated[AnyUrl, UrlConstraints(host_required=False)]
+ """
+ The URI of the resource to subscribe to. The URI can use any protocol; it is up to
+ the server how to interpret it.
+ """
+ model_config = ConfigDict(extra="allow")
+
+
+class SubscribeRequest(Request[SubscribeRequestParams, Literal["resources/subscribe"]]):
+ """
+ Sent from the client to request resources/updated notifications from the server
+ whenever a particular resource changes.
+ """
+
+ method: Literal["resources/subscribe"]
+ params: SubscribeRequestParams
+
+
+class UnsubscribeRequestParams(RequestParams):
+ """Parameters for unsubscribing from a resource."""
+
+ uri: Annotated[AnyUrl, UrlConstraints(host_required=False)]
+ """The URI of the resource to unsubscribe from."""
+ model_config = ConfigDict(extra="allow")
+
+
+class UnsubscribeRequest(Request[UnsubscribeRequestParams, Literal["resources/unsubscribe"]]):
+ """
+ Sent from the client to request cancellation of resources/updated notifications from
+ the server.
+ """
+
+ method: Literal["resources/unsubscribe"]
+ params: UnsubscribeRequestParams
+
+
+class ResourceUpdatedNotificationParams(NotificationParams):
+ """Parameters for resource update notifications."""
+
+ uri: Annotated[AnyUrl, UrlConstraints(host_required=False)]
+ """
+ The URI of the resource that has been updated. This might be a sub-resource of the
+ one that the client actually subscribed to.
+ """
+ model_config = ConfigDict(extra="allow")
+
+
+class ResourceUpdatedNotification(
+ Notification[ResourceUpdatedNotificationParams, Literal["notifications/resources/updated"]]
+):
+ """
+ A notification from the server to the client, informing it that a resource has
+ changed and may need to be read again.
+ """
+
+ method: Literal["notifications/resources/updated"]
+ params: ResourceUpdatedNotificationParams
+
+
+class ListPromptsRequest(PaginatedRequest[RequestParams | None, Literal["prompts/list"]]):
+ """Sent from the client to request a list of prompts and prompt templates."""
+
+ method: Literal["prompts/list"]
+ params: RequestParams | None = None
+
+
+class PromptArgument(BaseModel):
+ """An argument for a prompt template."""
+
+ name: str
+ """The name of the argument."""
+ description: str | None = None
+ """A human-readable description of the argument."""
+ required: bool | None = None
+ """Whether this argument must be provided."""
+ model_config = ConfigDict(extra="allow")
+
+
+class Prompt(BaseModel):
+ """A prompt or prompt template that the server offers."""
+
+ name: str
+ """The name of the prompt or prompt template."""
+ description: str | None = None
+ """An optional description of what this prompt provides."""
+ arguments: list[PromptArgument] | None = None
+ """A list of arguments to use for templating the prompt."""
+ model_config = ConfigDict(extra="allow")
+
+
+class ListPromptsResult(PaginatedResult):
+ """The server's response to a prompts/list request from the client."""
+
+ prompts: list[Prompt]
+
+
+class GetPromptRequestParams(RequestParams):
+ """Parameters for getting a prompt."""
+
+ name: str
+ """The name of the prompt or prompt template."""
+ arguments: dict[str, str] | None = None
+ """Arguments to use for templating the prompt."""
+ model_config = ConfigDict(extra="allow")
+
+
+class GetPromptRequest(Request[GetPromptRequestParams, Literal["prompts/get"]]):
+ """Used by the client to get a prompt provided by the server."""
+
+ method: Literal["prompts/get"]
+ params: GetPromptRequestParams
+
+
+class TextContent(BaseModel):
+ """Text content for a message."""
+
+ type: Literal["text"]
+ text: str
+ """The text content of the message."""
+ annotations: Annotations | None = None
+ model_config = ConfigDict(extra="allow")
+
+
+class ImageContent(BaseModel):
+ """Image content for a message."""
+
+ type: Literal["image"]
+ data: str
+ """The base64-encoded image data."""
+ mimeType: str
+ """
+ The MIME type of the image. Different providers may support different
+ image types.
+ """
+ annotations: Annotations | None = None
+ model_config = ConfigDict(extra="allow")
+
+
+class SamplingMessage(BaseModel):
+ """Describes a message issued to or received from an LLM API."""
+
+ role: Role
+ content: TextContent | ImageContent
+ model_config = ConfigDict(extra="allow")
+
+
+class EmbeddedResource(BaseModel):
+ """
+ The contents of a resource, embedded into a prompt or tool call result.
+
+ It is up to the client how best to render embedded resources for the benefit
+ of the LLM and/or the user.
+ """
+
+ type: Literal["resource"]
+ resource: TextResourceContents | BlobResourceContents
+ annotations: Annotations | None = None
+ model_config = ConfigDict(extra="allow")
+
+
+class PromptMessage(BaseModel):
+ """Describes a message returned as part of a prompt."""
+
+ role: Role
+ content: TextContent | ImageContent | EmbeddedResource
+ model_config = ConfigDict(extra="allow")
+
+
+class GetPromptResult(Result):
+ """The server's response to a prompts/get request from the client."""
+
+ description: str | None = None
+ """An optional description for the prompt."""
+ messages: list[PromptMessage]
+
+
+class PromptListChangedNotification(
+ Notification[NotificationParams | None, Literal["notifications/prompts/list_changed"]]
+):
+ """
+ An optional notification from the server to the client, informing it that the list
+ of prompts it offers has changed.
+ """
+
+ method: Literal["notifications/prompts/list_changed"]
+ params: NotificationParams | None = None
+
+
+class ListToolsRequest(PaginatedRequest[RequestParams | None, Literal["tools/list"]]):
+ """Sent from the client to request a list of tools the server has."""
+
+ method: Literal["tools/list"]
+ params: RequestParams | None = None
+
+
+class ToolAnnotations(BaseModel):
+ """
+ Additional properties describing a Tool to clients.
+
+ NOTE: all properties in ToolAnnotations are **hints**.
+ They are not guaranteed to provide a faithful description of
+ tool behavior (including descriptive properties like `title`).
+
+ Clients should never make tool use decisions based on ToolAnnotations
+ received from untrusted servers.
+ """
+
+ title: str | None = None
+ """A human-readable title for the tool."""
+
+ readOnlyHint: bool | None = None
+ """
+ If true, the tool does not modify its environment.
+ Default: false
+ """
+
+ destructiveHint: bool | None = None
+ """
+ If true, the tool may perform destructive updates to its environment.
+ If false, the tool performs only additive updates.
+ (This property is meaningful only when `readOnlyHint == false`)
+ Default: true
+ """
+
+ idempotentHint: bool | None = None
+ """
+ If true, calling the tool repeatedly with the same arguments
+ will have no additional effect on the its environment.
+ (This property is meaningful only when `readOnlyHint == false`)
+ Default: false
+ """
+
+ openWorldHint: bool | None = None
+ """
+ If true, this tool may interact with an "open world" of external
+ entities. If false, the tool's domain of interaction is closed.
+ For example, the world of a web search tool is open, whereas that
+ of a memory tool is not.
+ Default: true
+ """
+ model_config = ConfigDict(extra="allow")
+
+
+class Tool(BaseModel):
+ """Definition for a tool the client can call."""
+
+ name: str
+ """The name of the tool."""
+ description: str | None = None
+ """A human-readable description of the tool."""
+ inputSchema: dict[str, Any]
+ """A JSON Schema object defining the expected parameters for the tool."""
+ annotations: ToolAnnotations | None = None
+ """Optional additional tool information."""
+ model_config = ConfigDict(extra="allow")
+
+
+class ListToolsResult(PaginatedResult):
+ """The server's response to a tools/list request from the client."""
+
+ tools: list[Tool]
+
+
+class CallToolRequestParams(RequestParams):
+ """Parameters for calling a tool."""
+
+ name: str
+ arguments: dict[str, Any] | None = None
+ model_config = ConfigDict(extra="allow")
+
+
+class CallToolRequest(Request[CallToolRequestParams, Literal["tools/call"]]):
+ """Used by the client to invoke a tool provided by the server."""
+
+ method: Literal["tools/call"]
+ params: CallToolRequestParams
+
+
+class CallToolResult(Result):
+ """The server's response to a tool call."""
+
+ content: list[TextContent | ImageContent | EmbeddedResource]
+ isError: bool = False
+
+
+class ToolListChangedNotification(Notification[NotificationParams | None, Literal["notifications/tools/list_changed"]]):
+ """
+ An optional notification from the server to the client, informing it that the list
+ of tools it offers has changed.
+ """
+
+ method: Literal["notifications/tools/list_changed"]
+ params: NotificationParams | None = None
+
+
+LoggingLevel = Literal["debug", "info", "notice", "warning", "error", "critical", "alert", "emergency"]
+
+
+class SetLevelRequestParams(RequestParams):
+ """Parameters for setting the logging level."""
+
+ level: LoggingLevel
+ """The level of logging that the client wants to receive from the server."""
+ model_config = ConfigDict(extra="allow")
+
+
+class SetLevelRequest(Request[SetLevelRequestParams, Literal["logging/setLevel"]]):
+ """A request from the client to the server, to enable or adjust logging."""
+
+ method: Literal["logging/setLevel"]
+ params: SetLevelRequestParams
+
+
+class LoggingMessageNotificationParams(NotificationParams):
+ """Parameters for logging message notifications."""
+
+ level: LoggingLevel
+ """The severity of this log message."""
+ logger: str | None = None
+ """An optional name of the logger issuing this message."""
+ data: Any
+ """
+ The data to be logged, such as a string message or an object. Any JSON serializable
+ type is allowed here.
+ """
+ model_config = ConfigDict(extra="allow")
+
+
+class LoggingMessageNotification(Notification[LoggingMessageNotificationParams, Literal["notifications/message"]]):
+ """Notification of a log message passed from server to client."""
+
+ method: Literal["notifications/message"]
+ params: LoggingMessageNotificationParams
+
+
+IncludeContext = Literal["none", "thisServer", "allServers"]
+
+
+class ModelHint(BaseModel):
+ """Hints to use for model selection."""
+
+ name: str | None = None
+ """A hint for a model name."""
+
+ model_config = ConfigDict(extra="allow")
+
+
+class ModelPreferences(BaseModel):
+ """
+ The server's preferences for model selection, requested by the client during
+ sampling.
+
+ Because LLMs can vary along multiple dimensions, choosing the "best" model is
+ rarely straightforward. Different models excel in different areas—some are
+ faster but less capable, others are more capable but more expensive, and so
+ on. This interface allows servers to express their priorities across multiple
+ dimensions to help clients make an appropriate selection for their use case.
+
+ These preferences are always advisory. The client MAY ignore them. It is also
+ up to the client to decide how to interpret these preferences and how to
+ balance them against other considerations.
+ """
+
+ hints: list[ModelHint] | None = None
+ """
+ Optional hints to use for model selection.
+
+ If multiple hints are specified, the client MUST evaluate them in order
+ (such that the first match is taken).
+
+ The client SHOULD prioritize these hints over the numeric priorities, but
+ MAY still use the priorities to select from ambiguous matches.
+ """
+
+ costPriority: float | None = None
+ """
+ How much to prioritize cost when selecting a model. A value of 0 means cost
+ is not important, while a value of 1 means cost is the most important
+ factor.
+ """
+
+ speedPriority: float | None = None
+ """
+ How much to prioritize sampling speed (latency) when selecting a model. A
+ value of 0 means speed is not important, while a value of 1 means speed is
+ the most important factor.
+ """
+
+ intelligencePriority: float | None = None
+ """
+ How much to prioritize intelligence and capabilities when selecting a
+ model. A value of 0 means intelligence is not important, while a value of 1
+ means intelligence is the most important factor.
+ """
+
+ model_config = ConfigDict(extra="allow")
+
+
+class CreateMessageRequestParams(RequestParams):
+ """Parameters for creating a message."""
+
+ messages: list[SamplingMessage]
+ modelPreferences: ModelPreferences | None = None
+ """
+ The server's preferences for which model to select. The client MAY ignore
+ these preferences.
+ """
+ systemPrompt: str | None = None
+ """An optional system prompt the server wants to use for sampling."""
+ includeContext: IncludeContext | None = None
+ """
+ A request to include context from one or more MCP servers (including the caller), to
+ be attached to the prompt.
+ """
+ temperature: float | None = None
+ maxTokens: int
+ """The maximum number of tokens to sample, as requested by the server."""
+ stopSequences: list[str] | None = None
+ metadata: dict[str, Any] | None = None
+ """Optional metadata to pass through to the LLM provider."""
+ model_config = ConfigDict(extra="allow")
+
+
+class CreateMessageRequest(Request[CreateMessageRequestParams, Literal["sampling/createMessage"]]):
+ """A request from the server to sample an LLM via the client."""
+
+ method: Literal["sampling/createMessage"]
+ params: CreateMessageRequestParams
+
+
+StopReason = Literal["endTurn", "stopSequence", "maxTokens"] | str
+
+
+class CreateMessageResult(Result):
+ """The client's response to a sampling/create_message request from the server."""
+
+ role: Role
+ content: TextContent | ImageContent
+ model: str
+ """The name of the model that generated the message."""
+ stopReason: StopReason | None = None
+ """The reason why sampling stopped, if known."""
+
+
+class ResourceReference(BaseModel):
+ """A reference to a resource or resource template definition."""
+
+ type: Literal["ref/resource"]
+ uri: str
+ """The URI or URI template of the resource."""
+ model_config = ConfigDict(extra="allow")
+
+
+class PromptReference(BaseModel):
+ """Identifies a prompt."""
+
+ type: Literal["ref/prompt"]
+ name: str
+ """The name of the prompt or prompt template"""
+ model_config = ConfigDict(extra="allow")
+
+
+class CompletionArgument(BaseModel):
+ """The argument's information for completion requests."""
+
+ name: str
+ """The name of the argument"""
+ value: str
+ """The value of the argument to use for completion matching."""
+ model_config = ConfigDict(extra="allow")
+
+
+class CompleteRequestParams(RequestParams):
+ """Parameters for completion requests."""
+
+ ref: ResourceReference | PromptReference
+ argument: CompletionArgument
+ model_config = ConfigDict(extra="allow")
+
+
+class CompleteRequest(Request[CompleteRequestParams, Literal["completion/complete"]]):
+ """A request from the client to the server, to ask for completion options."""
+
+ method: Literal["completion/complete"]
+ params: CompleteRequestParams
+
+
+class Completion(BaseModel):
+ """Completion information."""
+
+ values: list[str]
+ """An array of completion values. Must not exceed 100 items."""
+ total: int | None = None
+ """
+ The total number of completion options available. This can exceed the number of
+ values actually sent in the response.
+ """
+ hasMore: bool | None = None
+ """
+ Indicates whether there are additional completion options beyond those provided in
+ the current response, even if the exact total is unknown.
+ """
+ model_config = ConfigDict(extra="allow")
+
+
+class CompleteResult(Result):
+ """The server's response to a completion/complete request"""
+
+ completion: Completion
+
+
+class ListRootsRequest(Request[RequestParams | None, Literal["roots/list"]]):
+ """
+ Sent from the server to request a list of root URIs from the client. Roots allow
+ servers to ask for specific directories or files to operate on. A common example
+ for roots is providing a set of repositories or directories a server should operate
+ on.
+
+ This request is typically used when the server needs to understand the file system
+ structure or access specific locations that the client has permission to read from.
+ """
+
+ method: Literal["roots/list"]
+ params: RequestParams | None = None
+
+
+class Root(BaseModel):
+ """Represents a root directory or file that the server can operate on."""
+
+ uri: FileUrl
+ """
+ The URI identifying the root. This *must* start with file:// for now.
+ This restriction may be relaxed in future versions of the protocol to allow
+ other URI schemes.
+ """
+ name: str | None = None
+ """
+ An optional name for the root. This can be used to provide a human-readable
+ identifier for the root, which may be useful for display purposes or for
+ referencing the root in other parts of the application.
+ """
+ model_config = ConfigDict(extra="allow")
+
+
+class ListRootsResult(Result):
+ """
+ The client's response to a roots/list request from the server.
+ This result contains an array of Root objects, each representing a root directory
+ or file that the server can operate on.
+ """
+
+ roots: list[Root]
+
+
+class RootsListChangedNotification(
+ Notification[NotificationParams | None, Literal["notifications/roots/list_changed"]]
+):
+ """
+ A notification from the client to the server, informing it that the list of
+ roots has changed.
+
+ This notification should be sent whenever the client adds, removes, or
+ modifies any root. The server should then request an updated list of roots
+ using the ListRootsRequest.
+ """
+
+ method: Literal["notifications/roots/list_changed"]
+ params: NotificationParams | None = None
+
+
+class CancelledNotificationParams(NotificationParams):
+ """Parameters for cancellation notifications."""
+
+ requestId: RequestId
+ """The ID of the request to cancel."""
+ reason: str | None = None
+ """An optional string describing the reason for the cancellation."""
+ model_config = ConfigDict(extra="allow")
+
+
+class CancelledNotification(Notification[CancelledNotificationParams, Literal["notifications/cancelled"]]):
+ """
+ This notification can be sent by either side to indicate that it is canceling a
+ previously-issued request.
+ """
+
+ method: Literal["notifications/cancelled"]
+ params: CancelledNotificationParams
+
+
+class ClientRequest(
+ RootModel[
+ PingRequest
+ | InitializeRequest
+ | CompleteRequest
+ | SetLevelRequest
+ | GetPromptRequest
+ | ListPromptsRequest
+ | ListResourcesRequest
+ | ListResourceTemplatesRequest
+ | ReadResourceRequest
+ | SubscribeRequest
+ | UnsubscribeRequest
+ | CallToolRequest
+ | ListToolsRequest
+ ]
+):
+ pass
+
+
+class ClientNotification(
+ RootModel[CancelledNotification | ProgressNotification | InitializedNotification | RootsListChangedNotification]
+):
+ pass
+
+
+class ClientResult(RootModel[EmptyResult | CreateMessageResult | ListRootsResult]):
+ pass
+
+
+class ServerRequest(RootModel[PingRequest | CreateMessageRequest | ListRootsRequest]):
+ pass
+
+
+class ServerNotification(
+ RootModel[
+ CancelledNotification
+ | ProgressNotification
+ | LoggingMessageNotification
+ | ResourceUpdatedNotification
+ | ResourceListChangedNotification
+ | ToolListChangedNotification
+ | PromptListChangedNotification
+ ]
+):
+ pass
+
+
+class ServerResult(
+ RootModel[
+ EmptyResult
+ | InitializeResult
+ | CompleteResult
+ | GetPromptResult
+ | ListPromptsResult
+ | ListResourcesResult
+ | ListResourceTemplatesResult
+ | ReadResourceResult
+ | CallToolResult
+ | ListToolsResult
+ ]
+):
+ pass
+
+
+ResumptionToken = str
+
+ResumptionTokenUpdateCallback = Callable[[ResumptionToken], None]
+
+
+@dataclass
+class ClientMessageMetadata:
+ """Metadata specific to client messages."""
+
+ resumption_token: ResumptionToken | None = None
+ on_resumption_token_update: Callable[[ResumptionToken], None] | None = None
+
+
+@dataclass
+class ServerMessageMetadata:
+ """Metadata specific to server messages."""
+
+ related_request_id: RequestId | None = None
+ request_context: object | None = None
+
+
+MessageMetadata = ClientMessageMetadata | ServerMessageMetadata | None
+
+
+@dataclass
+class SessionMessage:
+ """A message with specific metadata for transport-specific features."""
+
+ message: JSONRPCMessage
+ metadata: MessageMetadata = None
+
+
+class OAuthClientMetadata(BaseModel):
+ client_name: str
+ redirect_uris: list[str]
+ grant_types: Optional[list[str]] = None
+ response_types: Optional[list[str]] = None
+ token_endpoint_auth_method: Optional[str] = None
+ client_uri: Optional[str] = None
+ scope: Optional[str] = None
+
+
+class OAuthClientInformation(BaseModel):
+ client_id: str
+ client_secret: Optional[str] = None
+
+
+class OAuthClientInformationFull(OAuthClientInformation):
+ client_name: str | None = None
+ redirect_uris: list[str]
+ scope: Optional[str] = None
+ grant_types: Optional[list[str]] = None
+ response_types: Optional[list[str]] = None
+ token_endpoint_auth_method: Optional[str] = None
+
+
+class OAuthTokens(BaseModel):
+ access_token: str
+ token_type: str
+ expires_in: Optional[int] = None
+ refresh_token: Optional[str] = None
+ scope: Optional[str] = None
+
+
+class OAuthMetadata(BaseModel):
+ authorization_endpoint: str
+ token_endpoint: str
+ registration_endpoint: Optional[str] = None
+ response_types_supported: list[str]
+ grant_types_supported: Optional[list[str]] = None
+ code_challenge_methods_supported: Optional[list[str]] = None
diff --git a/api/core/mcp/utils.py b/api/core/mcp/utils.py
new file mode 100644
index 0000000000..a54badcd4c
--- /dev/null
+++ b/api/core/mcp/utils.py
@@ -0,0 +1,114 @@
+import json
+
+import httpx
+
+from configs import dify_config
+from core.mcp.types import ErrorData, JSONRPCError
+from core.model_runtime.utils.encoders import jsonable_encoder
+
+HTTP_REQUEST_NODE_SSL_VERIFY = dify_config.HTTP_REQUEST_NODE_SSL_VERIFY
+
+STATUS_FORCELIST = [429, 500, 502, 503, 504]
+
+
+def create_ssrf_proxy_mcp_http_client(
+ headers: dict[str, str] | None = None,
+ timeout: httpx.Timeout | None = None,
+) -> httpx.Client:
+ """Create an HTTPX client with SSRF proxy configuration for MCP connections.
+
+ Args:
+ headers: Optional headers to include in the client
+ timeout: Optional timeout configuration
+
+ Returns:
+ Configured httpx.Client with proxy settings
+ """
+ if dify_config.SSRF_PROXY_ALL_URL:
+ return httpx.Client(
+ verify=HTTP_REQUEST_NODE_SSL_VERIFY,
+ headers=headers or {},
+ timeout=timeout,
+ follow_redirects=True,
+ proxy=dify_config.SSRF_PROXY_ALL_URL,
+ )
+ elif dify_config.SSRF_PROXY_HTTP_URL and dify_config.SSRF_PROXY_HTTPS_URL:
+ proxy_mounts = {
+ "http://": httpx.HTTPTransport(proxy=dify_config.SSRF_PROXY_HTTP_URL, verify=HTTP_REQUEST_NODE_SSL_VERIFY),
+ "https://": httpx.HTTPTransport(
+ proxy=dify_config.SSRF_PROXY_HTTPS_URL, verify=HTTP_REQUEST_NODE_SSL_VERIFY
+ ),
+ }
+ return httpx.Client(
+ verify=HTTP_REQUEST_NODE_SSL_VERIFY,
+ headers=headers or {},
+ timeout=timeout,
+ follow_redirects=True,
+ mounts=proxy_mounts,
+ )
+ else:
+ return httpx.Client(
+ verify=HTTP_REQUEST_NODE_SSL_VERIFY,
+ headers=headers or {},
+ timeout=timeout,
+ follow_redirects=True,
+ )
+
+
+def ssrf_proxy_sse_connect(url, **kwargs):
+ """Connect to SSE endpoint with SSRF proxy protection.
+
+ This function creates an SSE connection using the configured proxy settings
+ to prevent SSRF attacks when connecting to external endpoints.
+
+ Args:
+ url: The SSE endpoint URL
+ **kwargs: Additional arguments passed to the SSE connection
+
+ Returns:
+ EventSource object for SSE streaming
+ """
+ from httpx_sse import connect_sse
+
+ # Extract client if provided, otherwise create one
+ client = kwargs.pop("client", None)
+ if client is None:
+ # Create client with SSRF proxy configuration
+ timeout = kwargs.pop(
+ "timeout",
+ httpx.Timeout(
+ timeout=dify_config.SSRF_DEFAULT_TIME_OUT,
+ connect=dify_config.SSRF_DEFAULT_CONNECT_TIME_OUT,
+ read=dify_config.SSRF_DEFAULT_READ_TIME_OUT,
+ write=dify_config.SSRF_DEFAULT_WRITE_TIME_OUT,
+ ),
+ )
+ headers = kwargs.pop("headers", {})
+ client = create_ssrf_proxy_mcp_http_client(headers=headers, timeout=timeout)
+ client_provided = False
+ else:
+ client_provided = True
+
+ # Extract method if provided, default to GET
+ method = kwargs.pop("method", "GET")
+
+ try:
+ return connect_sse(client, method, url, **kwargs)
+ except Exception:
+ # If we created the client, we need to clean it up on error
+ if not client_provided:
+ client.close()
+ raise
+
+
+def create_mcp_error_response(request_id: int | str | None, code: int, message: str, data=None):
+ """Create MCP error response"""
+ error_data = ErrorData(code=code, message=message, data=data)
+ json_response = JSONRPCError(
+ jsonrpc="2.0",
+ id=request_id or 1,
+ error=error_data,
+ )
+ json_data = json.dumps(jsonable_encoder(json_response))
+ sse_content = f"event: message\ndata: {json_data}\n\n".encode()
+ yield sse_content
diff --git a/api/core/memory/token_buffer_memory.py b/api/core/memory/token_buffer_memory.py
index 2254b3d4d5..a9f0a92e5d 100644
--- a/api/core/memory/token_buffer_memory.py
+++ b/api/core/memory/token_buffer_memory.py
@@ -1,6 +1,8 @@
from collections.abc import Sequence
from typing import Optional
+from sqlalchemy import select
+
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
from core.file import file_manager
from core.model_manager import ModelInstance
@@ -17,11 +19,15 @@ from core.prompt.utils.extract_thread_messages import extract_thread_messages
from extensions.ext_database import db
from factories import file_factory
from models.model import AppMode, Conversation, Message, MessageFile
-from models.workflow import WorkflowRun
+from models.workflow import Workflow, WorkflowRun
class TokenBufferMemory:
- def __init__(self, conversation: Conversation, model_instance: ModelInstance) -> None:
+ def __init__(
+ self,
+ conversation: Conversation,
+ model_instance: ModelInstance,
+ ) -> None:
self.conversation = conversation
self.model_instance = model_instance
@@ -36,20 +42,8 @@ class TokenBufferMemory:
app_record = self.conversation.app
# fetch limited messages, and return reversed
- query = (
- db.session.query(
- Message.id,
- Message.query,
- Message.answer,
- Message.created_at,
- Message.workflow_run_id,
- Message.parent_message_id,
- Message.answer_tokens,
- )
- .filter(
- Message.conversation_id == self.conversation.id,
- )
- .order_by(Message.created_at.desc())
+ stmt = (
+ select(Message).where(Message.conversation_id == self.conversation.id).order_by(Message.created_at.desc())
)
if message_limit and message_limit > 0:
@@ -57,7 +51,9 @@ class TokenBufferMemory:
else:
message_limit = 500
- messages = query.limit(message_limit).all()
+ stmt = stmt.limit(message_limit)
+
+ messages = db.session.scalars(stmt).all()
# instead of all messages from the conversation, we only need to extract messages
# that belong to the thread of last message
@@ -74,18 +70,20 @@ class TokenBufferMemory:
files = db.session.query(MessageFile).filter(MessageFile.message_id == message.id).all()
if files:
file_extra_config = None
- if self.conversation.mode not in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
+ if self.conversation.mode in {AppMode.AGENT_CHAT, AppMode.COMPLETION, AppMode.CHAT}:
file_extra_config = FileUploadConfigManager.convert(self.conversation.model_config)
+ elif self.conversation.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
+ workflow_run = db.session.scalar(
+ select(WorkflowRun).where(WorkflowRun.id == message.workflow_run_id)
+ )
+ if not workflow_run:
+ raise ValueError(f"Workflow run not found: {message.workflow_run_id}")
+ workflow = db.session.scalar(select(Workflow).where(Workflow.id == workflow_run.workflow_id))
+ if not workflow:
+ raise ValueError(f"Workflow not found: {workflow_run.workflow_id}")
+ file_extra_config = FileUploadConfigManager.convert(workflow.features_dict, is_vision=False)
else:
- if message.workflow_run_id:
- workflow_run = (
- db.session.query(WorkflowRun).filter(WorkflowRun.id == message.workflow_run_id).first()
- )
-
- if workflow_run and workflow_run.workflow:
- file_extra_config = FileUploadConfigManager.convert(
- workflow_run.workflow.features_dict, is_vision=False
- )
+ raise AssertionError(f"Invalid app mode: {self.conversation.mode}")
detail = ImagePromptMessageContent.DETAIL.LOW
if file_extra_config and app_record:
diff --git a/api/core/model_manager.py b/api/core/model_manager.py
index 995a30d44c..4886ffe244 100644
--- a/api/core/model_manager.py
+++ b/api/core/model_manager.py
@@ -542,8 +542,6 @@ class LBModelManager:
return config
- return None
-
def cooldown(self, config: ModelLoadBalancingConfiguration, expire: int = 60) -> None:
"""
Cooldown model load balancing config
diff --git a/api/core/model_runtime/entities/llm_entities.py b/api/core/model_runtime/entities/llm_entities.py
index de5a748d4f..ace2c1f770 100644
--- a/api/core/model_runtime/entities/llm_entities.py
+++ b/api/core/model_runtime/entities/llm_entities.py
@@ -1,7 +1,7 @@
-from collections.abc import Sequence
+from collections.abc import Mapping, Sequence
from decimal import Decimal
from enum import StrEnum
-from typing import Optional
+from typing import Any, Optional
from pydantic import BaseModel, Field
@@ -53,6 +53,37 @@ class LLMUsage(ModelUsage):
latency=0.0,
)
+ @classmethod
+ def from_metadata(cls, metadata: dict) -> "LLMUsage":
+ """
+ Create LLMUsage instance from metadata dictionary with default values.
+
+ Args:
+ metadata: Dictionary containing usage metadata
+
+ Returns:
+ LLMUsage instance with values from metadata or defaults
+ """
+ total_tokens = metadata.get("total_tokens", 0)
+ completion_tokens = metadata.get("completion_tokens", 0)
+ if total_tokens > 0 and completion_tokens == 0:
+ completion_tokens = total_tokens
+
+ return cls(
+ prompt_tokens=metadata.get("prompt_tokens", 0),
+ completion_tokens=completion_tokens,
+ total_tokens=total_tokens,
+ prompt_unit_price=Decimal(str(metadata.get("prompt_unit_price", 0))),
+ completion_unit_price=Decimal(str(metadata.get("completion_unit_price", 0))),
+ total_price=Decimal(str(metadata.get("total_price", 0))),
+ currency=metadata.get("currency", "USD"),
+ prompt_price_unit=Decimal(str(metadata.get("prompt_price_unit", 0))),
+ completion_price_unit=Decimal(str(metadata.get("completion_price_unit", 0))),
+ prompt_price=Decimal(str(metadata.get("prompt_price", 0))),
+ completion_price=Decimal(str(metadata.get("completion_price", 0))),
+ latency=metadata.get("latency", 0.0),
+ )
+
def plus(self, other: "LLMUsage") -> "LLMUsage":
"""
Add two LLMUsage instances together.
@@ -101,6 +132,20 @@ class LLMResult(BaseModel):
system_fingerprint: Optional[str] = None
+class LLMStructuredOutput(BaseModel):
+ """
+ Model class for llm structured output.
+ """
+
+ structured_output: Optional[Mapping[str, Any]] = None
+
+
+class LLMResultWithStructuredOutput(LLMResult, LLMStructuredOutput):
+ """
+ Model class for llm result with structured output.
+ """
+
+
class LLMResultChunkDelta(BaseModel):
"""
Model class for llm result chunk delta.
@@ -123,6 +168,12 @@ class LLMResultChunk(BaseModel):
delta: LLMResultChunkDelta
+class LLMResultChunkWithStructuredOutput(LLMResultChunk, LLMStructuredOutput):
+ """
+ Model class for llm result chunk with structured output.
+ """
+
+
class NumTokensResult(PriceInfo):
"""
Model class for number of tokens result.
diff --git a/api/core/model_runtime/entities/provider_entities.py b/api/core/model_runtime/entities/provider_entities.py
index d0f9ee13e5..c9aa8d1474 100644
--- a/api/core/model_runtime/entities/provider_entities.py
+++ b/api/core/model_runtime/entities/provider_entities.py
@@ -123,6 +123,8 @@ class ProviderEntity(BaseModel):
description: Optional[I18nObject] = None
icon_small: Optional[I18nObject] = None
icon_large: Optional[I18nObject] = None
+ icon_small_dark: Optional[I18nObject] = None
+ icon_large_dark: Optional[I18nObject] = None
background: Optional[str] = None
help: Optional[ProviderHelpEntity] = None
supported_model_types: Sequence[ModelType]
diff --git a/api/core/ops/aliyun_trace/__init__.py b/api/core/ops/aliyun_trace/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/core/ops/aliyun_trace/aliyun_trace.py b/api/core/ops/aliyun_trace/aliyun_trace.py
new file mode 100644
index 0000000000..b18a6905fe
--- /dev/null
+++ b/api/core/ops/aliyun_trace/aliyun_trace.py
@@ -0,0 +1,487 @@
+import json
+import logging
+from collections.abc import Sequence
+from typing import Optional
+from urllib.parse import urljoin
+
+from opentelemetry.trace import Status, StatusCode
+from sqlalchemy.orm import Session, sessionmaker
+
+from core.ops.aliyun_trace.data_exporter.traceclient import (
+ TraceClient,
+ convert_datetime_to_nanoseconds,
+ convert_to_span_id,
+ convert_to_trace_id,
+ generate_span_id,
+)
+from core.ops.aliyun_trace.entities.aliyun_trace_entity import SpanData
+from core.ops.aliyun_trace.entities.semconv import (
+ GEN_AI_COMPLETION,
+ GEN_AI_FRAMEWORK,
+ GEN_AI_MODEL_NAME,
+ GEN_AI_PROMPT,
+ GEN_AI_PROMPT_TEMPLATE_TEMPLATE,
+ GEN_AI_PROMPT_TEMPLATE_VARIABLE,
+ GEN_AI_RESPONSE_FINISH_REASON,
+ GEN_AI_SESSION_ID,
+ GEN_AI_SPAN_KIND,
+ GEN_AI_SYSTEM,
+ GEN_AI_USAGE_INPUT_TOKENS,
+ GEN_AI_USAGE_OUTPUT_TOKENS,
+ GEN_AI_USAGE_TOTAL_TOKENS,
+ GEN_AI_USER_ID,
+ INPUT_VALUE,
+ OUTPUT_VALUE,
+ RETRIEVAL_DOCUMENT,
+ RETRIEVAL_QUERY,
+ TOOL_DESCRIPTION,
+ TOOL_NAME,
+ TOOL_PARAMETERS,
+ GenAISpanKind,
+)
+from core.ops.base_trace_instance import BaseTraceInstance
+from core.ops.entities.config_entity import AliyunConfig
+from core.ops.entities.trace_entity import (
+ BaseTraceInfo,
+ DatasetRetrievalTraceInfo,
+ GenerateNameTraceInfo,
+ MessageTraceInfo,
+ ModerationTraceInfo,
+ SuggestedQuestionTraceInfo,
+ ToolTraceInfo,
+ WorkflowTraceInfo,
+)
+from core.rag.models.document import Document
+from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository
+from core.workflow.entities.workflow_node_execution import (
+ WorkflowNodeExecution,
+ WorkflowNodeExecutionMetadataKey,
+ WorkflowNodeExecutionStatus,
+)
+from core.workflow.nodes import NodeType
+from models import Account, App, EndUser, TenantAccountJoin, WorkflowNodeExecutionTriggeredFrom, db
+
+logger = logging.getLogger(__name__)
+
+
+class AliyunDataTrace(BaseTraceInstance):
+ def __init__(
+ self,
+ aliyun_config: AliyunConfig,
+ ):
+ super().__init__(aliyun_config)
+ base_url = aliyun_config.endpoint.rstrip("/")
+ endpoint = urljoin(base_url, f"adapt_{aliyun_config.license_key}/api/otlp/traces")
+ self.trace_client = TraceClient(service_name=aliyun_config.app_name, endpoint=endpoint)
+
+ def trace(self, trace_info: BaseTraceInfo):
+ if isinstance(trace_info, WorkflowTraceInfo):
+ self.workflow_trace(trace_info)
+ if isinstance(trace_info, MessageTraceInfo):
+ self.message_trace(trace_info)
+ if isinstance(trace_info, ModerationTraceInfo):
+ pass
+ if isinstance(trace_info, SuggestedQuestionTraceInfo):
+ self.suggested_question_trace(trace_info)
+ if isinstance(trace_info, DatasetRetrievalTraceInfo):
+ self.dataset_retrieval_trace(trace_info)
+ if isinstance(trace_info, ToolTraceInfo):
+ self.tool_trace(trace_info)
+ if isinstance(trace_info, GenerateNameTraceInfo):
+ pass
+
+ def api_check(self):
+ return self.trace_client.api_check()
+
+ def get_project_url(self):
+ try:
+ return self.trace_client.get_project_url()
+ except Exception as e:
+ logger.info(f"Aliyun get run url failed: {str(e)}", exc_info=True)
+ raise ValueError(f"Aliyun get run url failed: {str(e)}")
+
+ def workflow_trace(self, trace_info: WorkflowTraceInfo):
+ trace_id = convert_to_trace_id(trace_info.workflow_run_id)
+ workflow_span_id = convert_to_span_id(trace_info.workflow_run_id, "workflow")
+ self.add_workflow_span(trace_id, workflow_span_id, trace_info)
+
+ workflow_node_executions = self.get_workflow_node_executions(trace_info)
+ for node_execution in workflow_node_executions:
+ node_span = self.build_workflow_node_span(node_execution, trace_id, trace_info, workflow_span_id)
+ self.trace_client.add_span(node_span)
+
+ def message_trace(self, trace_info: MessageTraceInfo):
+ message_data = trace_info.message_data
+ if message_data is None:
+ return
+ message_id = trace_info.message_id
+
+ user_id = message_data.from_account_id
+ if message_data.from_end_user_id:
+ end_user_data: Optional[EndUser] = (
+ db.session.query(EndUser).filter(EndUser.id == message_data.from_end_user_id).first()
+ )
+ if end_user_data is not None:
+ user_id = end_user_data.session_id
+
+ status: Status = Status(StatusCode.OK)
+ if trace_info.error:
+ status = Status(StatusCode.ERROR, trace_info.error)
+
+ trace_id = convert_to_trace_id(message_id)
+ message_span_id = convert_to_span_id(message_id, "message")
+ message_span = SpanData(
+ trace_id=trace_id,
+ parent_span_id=None,
+ span_id=message_span_id,
+ name="message",
+ start_time=convert_datetime_to_nanoseconds(trace_info.start_time),
+ end_time=convert_datetime_to_nanoseconds(trace_info.end_time),
+ attributes={
+ GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""),
+ GEN_AI_USER_ID: str(user_id),
+ GEN_AI_SPAN_KIND: GenAISpanKind.CHAIN.value,
+ GEN_AI_FRAMEWORK: "dify",
+ INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False),
+ OUTPUT_VALUE: str(trace_info.outputs),
+ },
+ status=status,
+ )
+ self.trace_client.add_span(message_span)
+
+ app_model_config = getattr(trace_info.message_data, "app_model_config", {})
+ pre_prompt = getattr(app_model_config, "pre_prompt", "")
+ inputs_data = getattr(trace_info.message_data, "inputs", {})
+ llm_span = SpanData(
+ trace_id=trace_id,
+ parent_span_id=message_span_id,
+ span_id=convert_to_span_id(message_id, "llm"),
+ name="llm",
+ start_time=convert_datetime_to_nanoseconds(trace_info.start_time),
+ end_time=convert_datetime_to_nanoseconds(trace_info.end_time),
+ attributes={
+ GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""),
+ GEN_AI_USER_ID: str(user_id),
+ GEN_AI_SPAN_KIND: GenAISpanKind.LLM.value,
+ GEN_AI_FRAMEWORK: "dify",
+ GEN_AI_MODEL_NAME: trace_info.metadata.get("ls_model_name", ""),
+ GEN_AI_SYSTEM: trace_info.metadata.get("ls_provider", ""),
+ GEN_AI_USAGE_INPUT_TOKENS: str(trace_info.message_tokens),
+ GEN_AI_USAGE_OUTPUT_TOKENS: str(trace_info.answer_tokens),
+ GEN_AI_USAGE_TOTAL_TOKENS: str(trace_info.total_tokens),
+ GEN_AI_PROMPT_TEMPLATE_VARIABLE: json.dumps(inputs_data, ensure_ascii=False),
+ GEN_AI_PROMPT_TEMPLATE_TEMPLATE: pre_prompt,
+ GEN_AI_PROMPT: json.dumps(trace_info.inputs, ensure_ascii=False),
+ GEN_AI_COMPLETION: str(trace_info.outputs),
+ INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False),
+ OUTPUT_VALUE: str(trace_info.outputs),
+ },
+ status=status,
+ )
+ self.trace_client.add_span(llm_span)
+
+ def dataset_retrieval_trace(self, trace_info: DatasetRetrievalTraceInfo):
+ if trace_info.message_data is None:
+ return
+ message_id = trace_info.message_id
+
+ documents_data = extract_retrieval_documents(trace_info.documents)
+ dataset_retrieval_span = SpanData(
+ trace_id=convert_to_trace_id(message_id),
+ parent_span_id=convert_to_span_id(message_id, "message"),
+ span_id=generate_span_id(),
+ name="dataset_retrieval",
+ start_time=convert_datetime_to_nanoseconds(trace_info.start_time),
+ end_time=convert_datetime_to_nanoseconds(trace_info.end_time),
+ attributes={
+ GEN_AI_SPAN_KIND: GenAISpanKind.RETRIEVER.value,
+ GEN_AI_FRAMEWORK: "dify",
+ RETRIEVAL_QUERY: str(trace_info.inputs),
+ RETRIEVAL_DOCUMENT: json.dumps(documents_data, ensure_ascii=False),
+ INPUT_VALUE: str(trace_info.inputs),
+ OUTPUT_VALUE: json.dumps(documents_data, ensure_ascii=False),
+ },
+ )
+ self.trace_client.add_span(dataset_retrieval_span)
+
+ def tool_trace(self, trace_info: ToolTraceInfo):
+ if trace_info.message_data is None:
+ return
+ message_id = trace_info.message_id
+
+ status: Status = Status(StatusCode.OK)
+ if trace_info.error:
+ status = Status(StatusCode.ERROR, trace_info.error)
+
+ tool_span = SpanData(
+ trace_id=convert_to_trace_id(message_id),
+ parent_span_id=convert_to_span_id(message_id, "message"),
+ span_id=generate_span_id(),
+ name=trace_info.tool_name,
+ start_time=convert_datetime_to_nanoseconds(trace_info.start_time),
+ end_time=convert_datetime_to_nanoseconds(trace_info.end_time),
+ attributes={
+ GEN_AI_SPAN_KIND: GenAISpanKind.TOOL.value,
+ GEN_AI_FRAMEWORK: "dify",
+ TOOL_NAME: trace_info.tool_name,
+ TOOL_DESCRIPTION: json.dumps(trace_info.tool_config, ensure_ascii=False),
+ TOOL_PARAMETERS: json.dumps(trace_info.tool_inputs, ensure_ascii=False),
+ INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False),
+ OUTPUT_VALUE: str(trace_info.tool_outputs),
+ },
+ status=status,
+ )
+ self.trace_client.add_span(tool_span)
+
+ def get_workflow_node_executions(self, trace_info: WorkflowTraceInfo) -> Sequence[WorkflowNodeExecution]:
+ # through workflow_run_id get all_nodes_execution using repository
+ session_factory = sessionmaker(bind=db.engine)
+ # Find the app's creator account
+ with Session(db.engine, expire_on_commit=False) as session:
+ # Get the app to find its creator
+ app_id = trace_info.metadata.get("app_id")
+ if not app_id:
+ raise ValueError("No app_id found in trace_info metadata")
+
+ app = session.query(App).filter(App.id == app_id).first()
+ if not app:
+ raise ValueError(f"App with id {app_id} not found")
+
+ if not app.created_by:
+ raise ValueError(f"App with id {app_id} has no creator (created_by is None)")
+
+ service_account = session.query(Account).filter(Account.id == app.created_by).first()
+ if not service_account:
+ raise ValueError(f"Creator account with id {app.created_by} not found for app {app_id}")
+ current_tenant = (
+ session.query(TenantAccountJoin).filter_by(account_id=service_account.id, current=True).first()
+ )
+ if not current_tenant:
+ raise ValueError(f"Current tenant not found for account {service_account.id}")
+ service_account.set_tenant_id(current_tenant.tenant_id)
+ workflow_node_execution_repository = SQLAlchemyWorkflowNodeExecutionRepository(
+ session_factory=session_factory,
+ user=service_account,
+ app_id=trace_info.metadata.get("app_id"),
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
+ )
+ # Get all executions for this workflow run
+ workflow_node_executions = workflow_node_execution_repository.get_by_workflow_run(
+ workflow_run_id=trace_info.workflow_run_id
+ )
+ return workflow_node_executions
+
+ def build_workflow_node_span(
+ self, node_execution: WorkflowNodeExecution, trace_id: int, trace_info: WorkflowTraceInfo, workflow_span_id: int
+ ):
+ try:
+ if node_execution.node_type == NodeType.LLM:
+ node_span = self.build_workflow_llm_span(trace_id, workflow_span_id, trace_info, node_execution)
+ elif node_execution.node_type == NodeType.KNOWLEDGE_RETRIEVAL:
+ node_span = self.build_workflow_retrieval_span(trace_id, workflow_span_id, trace_info, node_execution)
+ elif node_execution.node_type == NodeType.TOOL:
+ node_span = self.build_workflow_tool_span(trace_id, workflow_span_id, trace_info, node_execution)
+ else:
+ node_span = self.build_workflow_task_span(trace_id, workflow_span_id, trace_info, node_execution)
+ return node_span
+ except Exception:
+ return None
+
+ def get_workflow_node_status(self, node_execution: WorkflowNodeExecution) -> Status:
+ span_status: Status = Status(StatusCode.UNSET)
+ if node_execution.status == WorkflowNodeExecutionStatus.SUCCEEDED:
+ span_status = Status(StatusCode.OK)
+ elif node_execution.status in [WorkflowNodeExecutionStatus.FAILED, WorkflowNodeExecutionStatus.EXCEPTION]:
+ span_status = Status(StatusCode.ERROR, str(node_execution.error))
+ return span_status
+
+ def build_workflow_task_span(
+ self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, node_execution: WorkflowNodeExecution
+ ) -> SpanData:
+ return SpanData(
+ trace_id=trace_id,
+ parent_span_id=workflow_span_id,
+ span_id=convert_to_span_id(node_execution.id, "node"),
+ name=node_execution.title,
+ start_time=convert_datetime_to_nanoseconds(node_execution.created_at),
+ end_time=convert_datetime_to_nanoseconds(node_execution.finished_at),
+ attributes={
+ GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""),
+ GEN_AI_SPAN_KIND: GenAISpanKind.TASK.value,
+ GEN_AI_FRAMEWORK: "dify",
+ INPUT_VALUE: json.dumps(node_execution.inputs, ensure_ascii=False),
+ OUTPUT_VALUE: json.dumps(node_execution.outputs, ensure_ascii=False),
+ },
+ status=self.get_workflow_node_status(node_execution),
+ )
+
+ def build_workflow_tool_span(
+ self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, node_execution: WorkflowNodeExecution
+ ) -> SpanData:
+ tool_des = {}
+ if node_execution.metadata:
+ tool_des = node_execution.metadata.get(WorkflowNodeExecutionMetadataKey.TOOL_INFO, {})
+ return SpanData(
+ trace_id=trace_id,
+ parent_span_id=workflow_span_id,
+ span_id=convert_to_span_id(node_execution.id, "node"),
+ name=node_execution.title,
+ start_time=convert_datetime_to_nanoseconds(node_execution.created_at),
+ end_time=convert_datetime_to_nanoseconds(node_execution.finished_at),
+ attributes={
+ GEN_AI_SPAN_KIND: GenAISpanKind.TOOL.value,
+ GEN_AI_FRAMEWORK: "dify",
+ TOOL_NAME: node_execution.title,
+ TOOL_DESCRIPTION: json.dumps(tool_des, ensure_ascii=False),
+ TOOL_PARAMETERS: json.dumps(node_execution.inputs if node_execution.inputs else {}, ensure_ascii=False),
+ INPUT_VALUE: json.dumps(node_execution.inputs if node_execution.inputs else {}, ensure_ascii=False),
+ OUTPUT_VALUE: json.dumps(node_execution.outputs, ensure_ascii=False),
+ },
+ status=self.get_workflow_node_status(node_execution),
+ )
+
+ def build_workflow_retrieval_span(
+ self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, node_execution: WorkflowNodeExecution
+ ) -> SpanData:
+ input_value = ""
+ if node_execution.inputs:
+ input_value = str(node_execution.inputs.get("query", ""))
+ output_value = ""
+ if node_execution.outputs:
+ output_value = json.dumps(node_execution.outputs.get("result", []), ensure_ascii=False)
+ return SpanData(
+ trace_id=trace_id,
+ parent_span_id=workflow_span_id,
+ span_id=convert_to_span_id(node_execution.id, "node"),
+ name=node_execution.title,
+ start_time=convert_datetime_to_nanoseconds(node_execution.created_at),
+ end_time=convert_datetime_to_nanoseconds(node_execution.finished_at),
+ attributes={
+ GEN_AI_SPAN_KIND: GenAISpanKind.RETRIEVER.value,
+ GEN_AI_FRAMEWORK: "dify",
+ RETRIEVAL_QUERY: input_value,
+ RETRIEVAL_DOCUMENT: output_value,
+ INPUT_VALUE: input_value,
+ OUTPUT_VALUE: output_value,
+ },
+ status=self.get_workflow_node_status(node_execution),
+ )
+
+ def build_workflow_llm_span(
+ self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo, node_execution: WorkflowNodeExecution
+ ) -> SpanData:
+ process_data = node_execution.process_data or {}
+ outputs = node_execution.outputs or {}
+ usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {})
+ return SpanData(
+ trace_id=trace_id,
+ parent_span_id=workflow_span_id,
+ span_id=convert_to_span_id(node_execution.id, "node"),
+ name=node_execution.title,
+ start_time=convert_datetime_to_nanoseconds(node_execution.created_at),
+ end_time=convert_datetime_to_nanoseconds(node_execution.finished_at),
+ attributes={
+ GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""),
+ GEN_AI_SPAN_KIND: GenAISpanKind.LLM.value,
+ GEN_AI_FRAMEWORK: "dify",
+ GEN_AI_MODEL_NAME: process_data.get("model_name", ""),
+ GEN_AI_SYSTEM: process_data.get("model_provider", ""),
+ GEN_AI_USAGE_INPUT_TOKENS: str(usage_data.get("prompt_tokens", 0)),
+ GEN_AI_USAGE_OUTPUT_TOKENS: str(usage_data.get("completion_tokens", 0)),
+ GEN_AI_USAGE_TOTAL_TOKENS: str(usage_data.get("total_tokens", 0)),
+ GEN_AI_PROMPT: json.dumps(process_data.get("prompts", []), ensure_ascii=False),
+ GEN_AI_COMPLETION: str(outputs.get("text", "")),
+ GEN_AI_RESPONSE_FINISH_REASON: outputs.get("finish_reason", ""),
+ INPUT_VALUE: json.dumps(process_data.get("prompts", []), ensure_ascii=False),
+ OUTPUT_VALUE: str(outputs.get("text", "")),
+ },
+ status=self.get_workflow_node_status(node_execution),
+ )
+
+ def add_workflow_span(self, trace_id: int, workflow_span_id: int, trace_info: WorkflowTraceInfo):
+ message_span_id = None
+ if trace_info.message_id:
+ message_span_id = convert_to_span_id(trace_info.message_id, "message")
+ user_id = trace_info.metadata.get("user_id")
+ status: Status = Status(StatusCode.OK)
+ if trace_info.error:
+ status = Status(StatusCode.ERROR, trace_info.error)
+ if message_span_id: # chatflow
+ message_span = SpanData(
+ trace_id=trace_id,
+ parent_span_id=None,
+ span_id=message_span_id,
+ name="message",
+ start_time=convert_datetime_to_nanoseconds(trace_info.start_time),
+ end_time=convert_datetime_to_nanoseconds(trace_info.end_time),
+ attributes={
+ GEN_AI_SESSION_ID: trace_info.metadata.get("conversation_id", ""),
+ GEN_AI_USER_ID: str(user_id),
+ GEN_AI_SPAN_KIND: GenAISpanKind.CHAIN.value,
+ GEN_AI_FRAMEWORK: "dify",
+ INPUT_VALUE: trace_info.workflow_run_inputs.get("sys.query", ""),
+ OUTPUT_VALUE: json.dumps(trace_info.workflow_run_outputs, ensure_ascii=False),
+ },
+ status=status,
+ )
+ self.trace_client.add_span(message_span)
+
+ workflow_span = SpanData(
+ trace_id=trace_id,
+ parent_span_id=message_span_id,
+ span_id=workflow_span_id,
+ name="workflow",
+ start_time=convert_datetime_to_nanoseconds(trace_info.start_time),
+ end_time=convert_datetime_to_nanoseconds(trace_info.end_time),
+ attributes={
+ GEN_AI_USER_ID: str(user_id),
+ GEN_AI_SPAN_KIND: GenAISpanKind.CHAIN.value,
+ GEN_AI_FRAMEWORK: "dify",
+ INPUT_VALUE: json.dumps(trace_info.workflow_run_inputs, ensure_ascii=False),
+ OUTPUT_VALUE: json.dumps(trace_info.workflow_run_outputs, ensure_ascii=False),
+ },
+ status=status,
+ )
+ self.trace_client.add_span(workflow_span)
+
+ def suggested_question_trace(self, trace_info: SuggestedQuestionTraceInfo):
+ message_id = trace_info.message_id
+ status: Status = Status(StatusCode.OK)
+ if trace_info.error:
+ status = Status(StatusCode.ERROR, trace_info.error)
+ suggested_question_span = SpanData(
+ trace_id=convert_to_trace_id(message_id),
+ parent_span_id=convert_to_span_id(message_id, "message"),
+ span_id=convert_to_span_id(message_id, "suggested_question"),
+ name="suggested_question",
+ start_time=convert_datetime_to_nanoseconds(trace_info.start_time),
+ end_time=convert_datetime_to_nanoseconds(trace_info.end_time),
+ attributes={
+ GEN_AI_SPAN_KIND: GenAISpanKind.LLM.value,
+ GEN_AI_FRAMEWORK: "dify",
+ GEN_AI_MODEL_NAME: trace_info.metadata.get("ls_model_name", ""),
+ GEN_AI_SYSTEM: trace_info.metadata.get("ls_provider", ""),
+ GEN_AI_PROMPT: json.dumps(trace_info.inputs, ensure_ascii=False),
+ GEN_AI_COMPLETION: json.dumps(trace_info.suggested_question, ensure_ascii=False),
+ INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False),
+ OUTPUT_VALUE: json.dumps(trace_info.suggested_question, ensure_ascii=False),
+ },
+ status=status,
+ )
+ self.trace_client.add_span(suggested_question_span)
+
+
+def extract_retrieval_documents(documents: list[Document]):
+ documents_data = []
+ for document in documents:
+ document_data = {
+ "content": document.page_content,
+ "metadata": {
+ "dataset_id": document.metadata.get("dataset_id"),
+ "doc_id": document.metadata.get("doc_id"),
+ "document_id": document.metadata.get("document_id"),
+ },
+ "score": document.metadata.get("score"),
+ }
+ documents_data.append(document_data)
+ return documents_data
diff --git a/api/core/ops/aliyun_trace/data_exporter/__init__.py b/api/core/ops/aliyun_trace/data_exporter/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/core/ops/aliyun_trace/data_exporter/traceclient.py b/api/core/ops/aliyun_trace/data_exporter/traceclient.py
new file mode 100644
index 0000000000..ba5ac3f420
--- /dev/null
+++ b/api/core/ops/aliyun_trace/data_exporter/traceclient.py
@@ -0,0 +1,200 @@
+import hashlib
+import logging
+import random
+import socket
+import threading
+import uuid
+from collections import deque
+from collections.abc import Sequence
+from datetime import datetime
+from typing import Optional
+
+import requests
+from opentelemetry import trace as trace_api
+from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
+from opentelemetry.sdk.resources import Resource
+from opentelemetry.sdk.trace import ReadableSpan
+from opentelemetry.sdk.util.instrumentation import InstrumentationScope
+from opentelemetry.semconv.resource import ResourceAttributes
+
+from configs import dify_config
+from core.ops.aliyun_trace.entities.aliyun_trace_entity import SpanData
+
+INVALID_SPAN_ID = 0x0000000000000000
+INVALID_TRACE_ID = 0x00000000000000000000000000000000
+
+logger = logging.getLogger(__name__)
+
+
+class TraceClient:
+ def __init__(
+ self,
+ service_name: str,
+ endpoint: str,
+ max_queue_size: int = 1000,
+ schedule_delay_sec: int = 5,
+ max_export_batch_size: int = 50,
+ ):
+ self.endpoint = endpoint
+ self.resource = Resource(
+ attributes={
+ ResourceAttributes.SERVICE_NAME: service_name,
+ ResourceAttributes.SERVICE_VERSION: f"dify-{dify_config.project.version}-{dify_config.COMMIT_SHA}",
+ ResourceAttributes.DEPLOYMENT_ENVIRONMENT: f"{dify_config.DEPLOY_ENV}-{dify_config.EDITION}",
+ ResourceAttributes.HOST_NAME: socket.gethostname(),
+ }
+ )
+ self.span_builder = SpanBuilder(self.resource)
+ self.exporter = OTLPSpanExporter(endpoint=endpoint)
+
+ self.max_queue_size = max_queue_size
+ self.schedule_delay_sec = schedule_delay_sec
+ self.max_export_batch_size = max_export_batch_size
+
+ self.queue: deque = deque(maxlen=max_queue_size)
+ self.condition = threading.Condition(threading.Lock())
+ self.done = False
+
+ self.worker_thread = threading.Thread(target=self._worker, daemon=True)
+ self.worker_thread.start()
+
+ self._spans_dropped = False
+
+ def export(self, spans: Sequence[ReadableSpan]):
+ self.exporter.export(spans)
+
+ def api_check(self):
+ try:
+ response = requests.head(self.endpoint, timeout=5)
+ if response.status_code == 405:
+ return True
+ else:
+ logger.debug(f"AliyunTrace API check failed: Unexpected status code: {response.status_code}")
+ return False
+ except requests.exceptions.RequestException as e:
+ logger.debug(f"AliyunTrace API check failed: {str(e)}")
+ raise ValueError(f"AliyunTrace API check failed: {str(e)}")
+
+ def get_project_url(self):
+ return "https://arms.console.aliyun.com/#/llm"
+
+ def add_span(self, span_data: SpanData):
+ if span_data is None:
+ return
+ span: ReadableSpan = self.span_builder.build_span(span_data)
+ with self.condition:
+ if len(self.queue) == self.max_queue_size:
+ if not self._spans_dropped:
+ logger.warning("Queue is full, likely spans will be dropped.")
+ self._spans_dropped = True
+
+ self.queue.appendleft(span)
+ if len(self.queue) >= self.max_export_batch_size:
+ self.condition.notify()
+
+ def _worker(self):
+ while not self.done:
+ with self.condition:
+ if len(self.queue) < self.max_export_batch_size and not self.done:
+ self.condition.wait(timeout=self.schedule_delay_sec)
+ self._export_batch()
+
+ def _export_batch(self):
+ spans_to_export: list[ReadableSpan] = []
+ with self.condition:
+ while len(spans_to_export) < self.max_export_batch_size and self.queue:
+ spans_to_export.append(self.queue.pop())
+
+ if spans_to_export:
+ try:
+ self.exporter.export(spans_to_export)
+ except Exception as e:
+ logger.debug(f"Error exporting spans: {e}")
+
+ def shutdown(self):
+ with self.condition:
+ self.done = True
+ self.condition.notify_all()
+ self.worker_thread.join()
+ self._export_batch()
+ self.exporter.shutdown()
+
+
+class SpanBuilder:
+ def __init__(self, resource):
+ self.resource = resource
+ self.instrumentation_scope = InstrumentationScope(
+ __name__,
+ "",
+ None,
+ None,
+ )
+
+ def build_span(self, span_data: SpanData) -> ReadableSpan:
+ span_context = trace_api.SpanContext(
+ trace_id=span_data.trace_id,
+ span_id=span_data.span_id,
+ is_remote=False,
+ trace_flags=trace_api.TraceFlags(trace_api.TraceFlags.SAMPLED),
+ trace_state=None,
+ )
+
+ parent_span_context = None
+ if span_data.parent_span_id is not None:
+ parent_span_context = trace_api.SpanContext(
+ trace_id=span_data.trace_id,
+ span_id=span_data.parent_span_id,
+ is_remote=False,
+ trace_flags=trace_api.TraceFlags(trace_api.TraceFlags.SAMPLED),
+ trace_state=None,
+ )
+
+ span = ReadableSpan(
+ name=span_data.name,
+ context=span_context,
+ parent=parent_span_context,
+ resource=self.resource,
+ attributes=span_data.attributes,
+ events=span_data.events,
+ links=span_data.links,
+ kind=trace_api.SpanKind.INTERNAL,
+ status=span_data.status,
+ start_time=span_data.start_time,
+ end_time=span_data.end_time,
+ instrumentation_scope=self.instrumentation_scope,
+ )
+ return span
+
+
+def generate_span_id() -> int:
+ span_id = random.getrandbits(64)
+ while span_id == INVALID_SPAN_ID:
+ span_id = random.getrandbits(64)
+ return span_id
+
+
+def convert_to_trace_id(uuid_v4: Optional[str]) -> int:
+ try:
+ uuid_obj = uuid.UUID(uuid_v4)
+ return uuid_obj.int
+ except Exception as e:
+ raise ValueError(f"Invalid UUID input: {e}")
+
+
+def convert_to_span_id(uuid_v4: Optional[str], span_type: str) -> int:
+ try:
+ uuid_obj = uuid.UUID(uuid_v4)
+ except Exception as e:
+ raise ValueError(f"Invalid UUID input: {e}")
+ combined_key = f"{uuid_obj.hex}-{span_type}"
+ hash_bytes = hashlib.sha256(combined_key.encode("utf-8")).digest()
+ span_id = int.from_bytes(hash_bytes[:8], byteorder="big", signed=False)
+ return span_id
+
+
+def convert_datetime_to_nanoseconds(start_time_a: Optional[datetime]) -> Optional[int]:
+ if start_time_a is None:
+ return None
+ timestamp_in_seconds = start_time_a.timestamp()
+ timestamp_in_nanoseconds = int(timestamp_in_seconds * 1e9)
+ return timestamp_in_nanoseconds
diff --git a/api/core/ops/aliyun_trace/entities/__init__.py b/api/core/ops/aliyun_trace/entities/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/core/ops/aliyun_trace/entities/aliyun_trace_entity.py b/api/core/ops/aliyun_trace/entities/aliyun_trace_entity.py
new file mode 100644
index 0000000000..1caa822cd0
--- /dev/null
+++ b/api/core/ops/aliyun_trace/entities/aliyun_trace_entity.py
@@ -0,0 +1,21 @@
+from collections.abc import Sequence
+from typing import Optional
+
+from opentelemetry import trace as trace_api
+from opentelemetry.sdk.trace import Event, Status, StatusCode
+from pydantic import BaseModel, Field
+
+
+class SpanData(BaseModel):
+ model_config = {"arbitrary_types_allowed": True}
+
+ trace_id: int = Field(..., description="The unique identifier for the trace.")
+ parent_span_id: Optional[int] = Field(None, description="The ID of the parent span, if any.")
+ span_id: int = Field(..., description="The unique identifier for this span.")
+ name: str = Field(..., description="The name of the span.")
+ attributes: dict[str, str] = Field(default_factory=dict, description="Attributes associated with the span.")
+ events: Sequence[Event] = Field(default_factory=list, description="Events recorded in the span.")
+ links: Sequence[trace_api.Link] = Field(default_factory=list, description="Links to other spans.")
+ status: Status = Field(default=Status(StatusCode.UNSET), description="The status of the span.")
+ start_time: Optional[int] = Field(..., description="The start time of the span in nanoseconds.")
+ end_time: Optional[int] = Field(..., description="The end time of the span in nanoseconds.")
diff --git a/api/core/ops/aliyun_trace/entities/semconv.py b/api/core/ops/aliyun_trace/entities/semconv.py
new file mode 100644
index 0000000000..5d70264320
--- /dev/null
+++ b/api/core/ops/aliyun_trace/entities/semconv.py
@@ -0,0 +1,64 @@
+from enum import Enum
+
+# public
+GEN_AI_SESSION_ID = "gen_ai.session.id"
+
+GEN_AI_USER_ID = "gen_ai.user.id"
+
+GEN_AI_USER_NAME = "gen_ai.user.name"
+
+GEN_AI_SPAN_KIND = "gen_ai.span.kind"
+
+GEN_AI_FRAMEWORK = "gen_ai.framework"
+
+
+# Chain
+INPUT_VALUE = "input.value"
+
+OUTPUT_VALUE = "output.value"
+
+
+# Retriever
+RETRIEVAL_QUERY = "retrieval.query"
+
+RETRIEVAL_DOCUMENT = "retrieval.document"
+
+
+# LLM
+GEN_AI_MODEL_NAME = "gen_ai.model_name"
+
+GEN_AI_SYSTEM = "gen_ai.system"
+
+GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
+
+GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
+
+GEN_AI_USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens"
+
+GEN_AI_PROMPT_TEMPLATE_TEMPLATE = "gen_ai.prompt_template.template"
+
+GEN_AI_PROMPT_TEMPLATE_VARIABLE = "gen_ai.prompt_template.variable"
+
+GEN_AI_PROMPT = "gen_ai.prompt"
+
+GEN_AI_COMPLETION = "gen_ai.completion"
+
+GEN_AI_RESPONSE_FINISH_REASON = "gen_ai.response.finish_reason"
+
+# Tool
+TOOL_NAME = "tool.name"
+
+TOOL_DESCRIPTION = "tool.description"
+
+TOOL_PARAMETERS = "tool.parameters"
+
+
+class GenAISpanKind(Enum):
+ CHAIN = "CHAIN"
+ RETRIEVER = "RETRIEVER"
+ RERANKER = "RERANKER"
+ LLM = "LLM"
+ EMBEDDING = "EMBEDDING"
+ TOOL = "TOOL"
+ AGENT = "AGENT"
+ TASK = "TASK"
diff --git a/api/core/ops/arize_phoenix_trace/__init__.py b/api/core/ops/arize_phoenix_trace/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py b/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py
new file mode 100644
index 0000000000..ffda0885d4
--- /dev/null
+++ b/api/core/ops/arize_phoenix_trace/arize_phoenix_trace.py
@@ -0,0 +1,726 @@
+import hashlib
+import json
+import logging
+import os
+from datetime import datetime, timedelta
+from typing import Optional, Union, cast
+
+from openinference.semconv.trace import OpenInferenceSpanKindValues, SpanAttributes
+from opentelemetry import trace
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GrpcOTLPSpanExporter
+from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as HttpOTLPSpanExporter
+from opentelemetry.sdk import trace as trace_sdk
+from opentelemetry.sdk.resources import Resource
+from opentelemetry.sdk.trace.export import SimpleSpanProcessor
+from opentelemetry.sdk.trace.id_generator import RandomIdGenerator
+from opentelemetry.trace import SpanContext, TraceFlags, TraceState
+
+from core.ops.base_trace_instance import BaseTraceInstance
+from core.ops.entities.config_entity import ArizeConfig, PhoenixConfig
+from core.ops.entities.trace_entity import (
+ BaseTraceInfo,
+ DatasetRetrievalTraceInfo,
+ GenerateNameTraceInfo,
+ MessageTraceInfo,
+ ModerationTraceInfo,
+ SuggestedQuestionTraceInfo,
+ ToolTraceInfo,
+ TraceTaskName,
+ WorkflowTraceInfo,
+)
+from extensions.ext_database import db
+from models.model import EndUser, MessageFile
+from models.workflow import WorkflowNodeExecutionModel
+
+logger = logging.getLogger(__name__)
+
+
+def setup_tracer(arize_phoenix_config: ArizeConfig | PhoenixConfig) -> tuple[trace_sdk.Tracer, SimpleSpanProcessor]:
+ """Configure OpenTelemetry tracer with OTLP exporter for Arize/Phoenix."""
+ try:
+ # Choose the appropriate exporter based on config type
+ exporter: Union[GrpcOTLPSpanExporter, HttpOTLPSpanExporter]
+ if isinstance(arize_phoenix_config, ArizeConfig):
+ arize_endpoint = f"{arize_phoenix_config.endpoint}/v1"
+ arize_headers = {
+ "api_key": arize_phoenix_config.api_key or "",
+ "space_id": arize_phoenix_config.space_id or "",
+ "authorization": f"Bearer {arize_phoenix_config.api_key or ''}",
+ }
+ exporter = GrpcOTLPSpanExporter(
+ endpoint=arize_endpoint,
+ headers=arize_headers,
+ timeout=30,
+ )
+ else:
+ phoenix_endpoint = f"{arize_phoenix_config.endpoint}/v1/traces"
+ phoenix_headers = {
+ "api_key": arize_phoenix_config.api_key or "",
+ "authorization": f"Bearer {arize_phoenix_config.api_key or ''}",
+ }
+ exporter = HttpOTLPSpanExporter(
+ endpoint=phoenix_endpoint,
+ headers=phoenix_headers,
+ timeout=30,
+ )
+
+ attributes = {
+ "openinference.project.name": arize_phoenix_config.project or "",
+ "model_id": arize_phoenix_config.project or "",
+ }
+ resource = Resource(attributes=attributes)
+ provider = trace_sdk.TracerProvider(resource=resource)
+ processor = SimpleSpanProcessor(
+ exporter,
+ )
+ provider.add_span_processor(processor)
+
+ # Create a named tracer instead of setting the global provider
+ tracer_name = f"arize_phoenix_tracer_{arize_phoenix_config.project}"
+ logger.info(f"[Arize/Phoenix] Created tracer with name: {tracer_name}")
+ return cast(trace_sdk.Tracer, provider.get_tracer(tracer_name)), processor
+ except Exception as e:
+ logger.error(f"[Arize/Phoenix] Failed to setup the tracer: {str(e)}", exc_info=True)
+ raise
+
+
+def datetime_to_nanos(dt: Optional[datetime]) -> int:
+ """Convert datetime to nanoseconds since epoch. If None, use current time."""
+ if dt is None:
+ dt = datetime.now()
+ return int(dt.timestamp() * 1_000_000_000)
+
+
+def uuid_to_trace_id(string: Optional[str]) -> int:
+ """Convert UUID string to a valid trace ID (16-byte integer)."""
+ if string is None:
+ string = ""
+ hash_object = hashlib.sha256(string.encode())
+
+ # Take the first 16 bytes (128 bits) of the hash
+ digest = hash_object.digest()[:16]
+
+ # Convert to integer (128 bits)
+ return int.from_bytes(digest, byteorder="big")
+
+
+class ArizePhoenixDataTrace(BaseTraceInstance):
+ def __init__(
+ self,
+ arize_phoenix_config: ArizeConfig | PhoenixConfig,
+ ):
+ super().__init__(arize_phoenix_config)
+ import logging
+
+ logging.basicConfig()
+ logging.getLogger().setLevel(logging.DEBUG)
+ self.arize_phoenix_config = arize_phoenix_config
+ self.tracer, self.processor = setup_tracer(arize_phoenix_config)
+ self.project = arize_phoenix_config.project
+ self.file_base_url = os.getenv("FILES_URL", "http://127.0.0.1:5001")
+
+ def trace(self, trace_info: BaseTraceInfo):
+ logger.info(f"[Arize/Phoenix] Trace: {trace_info}")
+ try:
+ if isinstance(trace_info, WorkflowTraceInfo):
+ self.workflow_trace(trace_info)
+ if isinstance(trace_info, MessageTraceInfo):
+ self.message_trace(trace_info)
+ if isinstance(trace_info, ModerationTraceInfo):
+ self.moderation_trace(trace_info)
+ if isinstance(trace_info, SuggestedQuestionTraceInfo):
+ self.suggested_question_trace(trace_info)
+ if isinstance(trace_info, DatasetRetrievalTraceInfo):
+ self.dataset_retrieval_trace(trace_info)
+ if isinstance(trace_info, ToolTraceInfo):
+ self.tool_trace(trace_info)
+ if isinstance(trace_info, GenerateNameTraceInfo):
+ self.generate_name_trace(trace_info)
+
+ except Exception as e:
+ logger.error(f"[Arize/Phoenix] Error in the trace: {str(e)}", exc_info=True)
+ raise
+
+ def workflow_trace(self, trace_info: WorkflowTraceInfo):
+ if trace_info.message_data is None:
+ return
+
+ workflow_metadata = {
+ "workflow_id": trace_info.workflow_run_id or "",
+ "message_id": trace_info.message_id or "",
+ "workflow_app_log_id": trace_info.workflow_app_log_id or "",
+ "status": trace_info.workflow_run_status or "",
+ "status_message": trace_info.error or "",
+ "level": "ERROR" if trace_info.error else "DEFAULT",
+ "total_tokens": trace_info.total_tokens or 0,
+ }
+ workflow_metadata.update(trace_info.metadata)
+
+ trace_id = uuid_to_trace_id(trace_info.message_id)
+ span_id = RandomIdGenerator().generate_span_id()
+ context = SpanContext(
+ trace_id=trace_id,
+ span_id=span_id,
+ is_remote=False,
+ trace_flags=TraceFlags(TraceFlags.SAMPLED),
+ trace_state=TraceState(),
+ )
+
+ workflow_span = self.tracer.start_span(
+ name=TraceTaskName.WORKFLOW_TRACE.value,
+ attributes={
+ SpanAttributes.INPUT_VALUE: json.dumps(trace_info.workflow_run_inputs, ensure_ascii=False),
+ SpanAttributes.OUTPUT_VALUE: json.dumps(trace_info.workflow_run_outputs, ensure_ascii=False),
+ SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value,
+ SpanAttributes.METADATA: json.dumps(workflow_metadata, ensure_ascii=False),
+ SpanAttributes.SESSION_ID: trace_info.conversation_id or "",
+ },
+ start_time=datetime_to_nanos(trace_info.start_time),
+ context=trace.set_span_in_context(trace.NonRecordingSpan(context)),
+ )
+
+ try:
+ # Process workflow nodes
+ for node_execution in self._get_workflow_nodes(trace_info.workflow_run_id):
+ created_at = node_execution.created_at or datetime.now()
+ elapsed_time = node_execution.elapsed_time
+ finished_at = created_at + timedelta(seconds=elapsed_time)
+
+ process_data = json.loads(node_execution.process_data) if node_execution.process_data else {}
+
+ node_metadata = {
+ "node_id": node_execution.id,
+ "node_type": node_execution.node_type,
+ "node_status": node_execution.status,
+ "tenant_id": node_execution.tenant_id,
+ "app_id": node_execution.app_id,
+ "app_name": node_execution.title,
+ "status": node_execution.status,
+ "level": "ERROR" if node_execution.status != "succeeded" else "DEFAULT",
+ }
+
+ if node_execution.execution_metadata:
+ node_metadata.update(json.loads(node_execution.execution_metadata))
+
+ # Determine the correct span kind based on node type
+ span_kind = OpenInferenceSpanKindValues.CHAIN.value
+ if node_execution.node_type == "llm":
+ span_kind = OpenInferenceSpanKindValues.LLM.value
+ provider = process_data.get("model_provider")
+ model = process_data.get("model_name")
+ if provider:
+ node_metadata["ls_provider"] = provider
+ if model:
+ node_metadata["ls_model_name"] = model
+
+ outputs = json.loads(node_execution.outputs).get("usage", {})
+ usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {})
+ if usage_data:
+ node_metadata["total_tokens"] = usage_data.get("total_tokens", 0)
+ node_metadata["prompt_tokens"] = usage_data.get("prompt_tokens", 0)
+ node_metadata["completion_tokens"] = usage_data.get("completion_tokens", 0)
+ elif node_execution.node_type == "dataset_retrieval":
+ span_kind = OpenInferenceSpanKindValues.RETRIEVER.value
+ elif node_execution.node_type == "tool":
+ span_kind = OpenInferenceSpanKindValues.TOOL.value
+ else:
+ span_kind = OpenInferenceSpanKindValues.CHAIN.value
+
+ node_span = self.tracer.start_span(
+ name=node_execution.node_type,
+ attributes={
+ SpanAttributes.INPUT_VALUE: node_execution.inputs or "{}",
+ SpanAttributes.OUTPUT_VALUE: node_execution.outputs or "{}",
+ SpanAttributes.OPENINFERENCE_SPAN_KIND: span_kind,
+ SpanAttributes.METADATA: json.dumps(node_metadata, ensure_ascii=False),
+ SpanAttributes.SESSION_ID: trace_info.conversation_id or "",
+ },
+ start_time=datetime_to_nanos(created_at),
+ )
+
+ try:
+ if node_execution.node_type == "llm":
+ provider = process_data.get("model_provider")
+ model = process_data.get("model_name")
+ if provider:
+ node_span.set_attribute(SpanAttributes.LLM_PROVIDER, provider)
+ if model:
+ node_span.set_attribute(SpanAttributes.LLM_MODEL_NAME, model)
+
+ outputs = json.loads(node_execution.outputs).get("usage", {})
+ usage_data = (
+ process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {})
+ )
+ if usage_data:
+ node_span.set_attribute(
+ SpanAttributes.LLM_TOKEN_COUNT_TOTAL, usage_data.get("total_tokens", 0)
+ )
+ node_span.set_attribute(
+ SpanAttributes.LLM_TOKEN_COUNT_PROMPT, usage_data.get("prompt_tokens", 0)
+ )
+ node_span.set_attribute(
+ SpanAttributes.LLM_TOKEN_COUNT_COMPLETION, usage_data.get("completion_tokens", 0)
+ )
+ finally:
+ node_span.end(end_time=datetime_to_nanos(finished_at))
+ finally:
+ workflow_span.end(end_time=datetime_to_nanos(trace_info.end_time))
+
+ def message_trace(self, trace_info: MessageTraceInfo):
+ if trace_info.message_data is None:
+ return
+
+ file_list = cast(list[str], trace_info.file_list) or []
+ message_file_data: Optional[MessageFile] = trace_info.message_file_data
+
+ if message_file_data is not None:
+ file_url = f"{self.file_base_url}/{message_file_data.url}" if message_file_data else ""
+ file_list.append(file_url)
+
+ message_metadata = {
+ "message_id": trace_info.message_id or "",
+ "conversation_mode": str(trace_info.conversation_mode or ""),
+ "user_id": trace_info.message_data.from_account_id or "",
+ "file_list": json.dumps(file_list),
+ "status": trace_info.message_data.status or "",
+ "status_message": trace_info.error or "",
+ "level": "ERROR" if trace_info.error else "DEFAULT",
+ "total_tokens": trace_info.total_tokens or 0,
+ "prompt_tokens": trace_info.message_tokens or 0,
+ "completion_tokens": trace_info.answer_tokens or 0,
+ "ls_provider": trace_info.message_data.model_provider or "",
+ "ls_model_name": trace_info.message_data.model_id or "",
+ }
+ message_metadata.update(trace_info.metadata)
+
+ # Add end user data if available
+ if trace_info.message_data.from_end_user_id:
+ end_user_data: Optional[EndUser] = (
+ db.session.query(EndUser).filter(EndUser.id == trace_info.message_data.from_end_user_id).first()
+ )
+ if end_user_data is not None:
+ message_metadata["end_user_id"] = end_user_data.session_id
+
+ attributes = {
+ SpanAttributes.INPUT_VALUE: trace_info.message_data.query,
+ SpanAttributes.OUTPUT_VALUE: trace_info.message_data.answer,
+ SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value,
+ SpanAttributes.METADATA: json.dumps(message_metadata, ensure_ascii=False),
+ SpanAttributes.SESSION_ID: trace_info.message_data.conversation_id,
+ }
+
+ trace_id = uuid_to_trace_id(trace_info.message_id)
+ message_span_id = RandomIdGenerator().generate_span_id()
+ span_context = SpanContext(
+ trace_id=trace_id,
+ span_id=message_span_id,
+ is_remote=False,
+ trace_flags=TraceFlags(TraceFlags.SAMPLED),
+ trace_state=TraceState(),
+ )
+
+ message_span = self.tracer.start_span(
+ name=TraceTaskName.MESSAGE_TRACE.value,
+ attributes=attributes,
+ start_time=datetime_to_nanos(trace_info.start_time),
+ context=trace.set_span_in_context(trace.NonRecordingSpan(span_context)),
+ )
+
+ try:
+ if trace_info.error:
+ message_span.add_event(
+ "exception",
+ attributes={
+ "exception.message": trace_info.error,
+ "exception.type": "Error",
+ "exception.stacktrace": trace_info.error,
+ },
+ )
+
+ # Convert outputs to string based on type
+ if isinstance(trace_info.outputs, dict | list):
+ outputs_str = json.dumps(trace_info.outputs, ensure_ascii=False)
+ elif isinstance(trace_info.outputs, str):
+ outputs_str = trace_info.outputs
+ else:
+ outputs_str = str(trace_info.outputs)
+
+ llm_attributes = {
+ SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.LLM.value,
+ SpanAttributes.INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False),
+ SpanAttributes.OUTPUT_VALUE: outputs_str,
+ SpanAttributes.METADATA: json.dumps(message_metadata, ensure_ascii=False),
+ SpanAttributes.SESSION_ID: trace_info.message_data.conversation_id,
+ }
+
+ if isinstance(trace_info.inputs, list):
+ for i, msg in enumerate(trace_info.inputs):
+ if isinstance(msg, dict):
+ llm_attributes[f"{SpanAttributes.LLM_INPUT_MESSAGES}.{i}.message.content"] = msg.get("text", "")
+ llm_attributes[f"{SpanAttributes.LLM_INPUT_MESSAGES}.{i}.message.role"] = msg.get(
+ "role", "user"
+ )
+ # todo: handle assistant and tool role messages, as they don't always
+ # have a text field, but may have a tool_calls field instead
+ # e.g. 'tool_calls': [{'id': '98af3a29-b066-45a5-b4b1-46c74ddafc58',
+ # 'type': 'function', 'function': {'name': 'current_time', 'arguments': '{}'}}]}
+ elif isinstance(trace_info.inputs, dict):
+ llm_attributes[f"{SpanAttributes.LLM_INPUT_MESSAGES}.0.message.content"] = json.dumps(trace_info.inputs)
+ llm_attributes[f"{SpanAttributes.LLM_INPUT_MESSAGES}.0.message.role"] = "user"
+ elif isinstance(trace_info.inputs, str):
+ llm_attributes[f"{SpanAttributes.LLM_INPUT_MESSAGES}.0.message.content"] = trace_info.inputs
+ llm_attributes[f"{SpanAttributes.LLM_INPUT_MESSAGES}.0.message.role"] = "user"
+
+ if trace_info.total_tokens is not None and trace_info.total_tokens > 0:
+ llm_attributes[SpanAttributes.LLM_TOKEN_COUNT_TOTAL] = trace_info.total_tokens
+ if trace_info.message_tokens is not None and trace_info.message_tokens > 0:
+ llm_attributes[SpanAttributes.LLM_TOKEN_COUNT_PROMPT] = trace_info.message_tokens
+ if trace_info.answer_tokens is not None and trace_info.answer_tokens > 0:
+ llm_attributes[SpanAttributes.LLM_TOKEN_COUNT_COMPLETION] = trace_info.answer_tokens
+
+ if trace_info.message_data.model_id is not None:
+ llm_attributes[SpanAttributes.LLM_MODEL_NAME] = trace_info.message_data.model_id
+ if trace_info.message_data.model_provider is not None:
+ llm_attributes[SpanAttributes.LLM_PROVIDER] = trace_info.message_data.model_provider
+
+ if trace_info.message_data and trace_info.message_data.message_metadata:
+ metadata_dict = json.loads(trace_info.message_data.message_metadata)
+ if model_params := metadata_dict.get("model_parameters"):
+ llm_attributes[SpanAttributes.LLM_INVOCATION_PARAMETERS] = json.dumps(model_params)
+
+ llm_span = self.tracer.start_span(
+ name="llm",
+ attributes=llm_attributes,
+ start_time=datetime_to_nanos(trace_info.start_time),
+ context=trace.set_span_in_context(trace.NonRecordingSpan(span_context)),
+ )
+
+ try:
+ if trace_info.error:
+ llm_span.add_event(
+ "exception",
+ attributes={
+ "exception.message": trace_info.error,
+ "exception.type": "Error",
+ "exception.stacktrace": trace_info.error,
+ },
+ )
+ finally:
+ llm_span.end(end_time=datetime_to_nanos(trace_info.end_time))
+ finally:
+ message_span.end(end_time=datetime_to_nanos(trace_info.end_time))
+
+ def moderation_trace(self, trace_info: ModerationTraceInfo):
+ if trace_info.message_data is None:
+ return
+
+ metadata = {
+ "message_id": trace_info.message_id,
+ "tool_name": "moderation",
+ "status": trace_info.message_data.status,
+ "status_message": trace_info.message_data.error or "",
+ "level": "ERROR" if trace_info.message_data.error else "DEFAULT",
+ }
+ metadata.update(trace_info.metadata)
+
+ trace_id = uuid_to_trace_id(trace_info.message_id)
+ span_id = RandomIdGenerator().generate_span_id()
+ context = SpanContext(
+ trace_id=trace_id,
+ span_id=span_id,
+ is_remote=False,
+ trace_flags=TraceFlags(TraceFlags.SAMPLED),
+ trace_state=TraceState(),
+ )
+
+ span = self.tracer.start_span(
+ name=TraceTaskName.MODERATION_TRACE.value,
+ attributes={
+ SpanAttributes.INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False),
+ SpanAttributes.OUTPUT_VALUE: json.dumps(
+ {
+ "action": trace_info.action,
+ "flagged": trace_info.flagged,
+ "preset_response": trace_info.preset_response,
+ "inputs": trace_info.inputs,
+ },
+ ensure_ascii=False,
+ ),
+ SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value,
+ SpanAttributes.METADATA: json.dumps(metadata, ensure_ascii=False),
+ },
+ start_time=datetime_to_nanos(trace_info.start_time),
+ context=trace.set_span_in_context(trace.NonRecordingSpan(context)),
+ )
+
+ try:
+ if trace_info.message_data.error:
+ span.add_event(
+ "exception",
+ attributes={
+ "exception.message": trace_info.message_data.error,
+ "exception.type": "Error",
+ "exception.stacktrace": trace_info.message_data.error,
+ },
+ )
+ finally:
+ span.end(end_time=datetime_to_nanos(trace_info.end_time))
+
+ def suggested_question_trace(self, trace_info: SuggestedQuestionTraceInfo):
+ if trace_info.message_data is None:
+ return
+
+ start_time = trace_info.start_time or trace_info.message_data.created_at
+ end_time = trace_info.end_time or trace_info.message_data.updated_at
+
+ metadata = {
+ "message_id": trace_info.message_id,
+ "tool_name": "suggested_question",
+ "status": trace_info.status,
+ "status_message": trace_info.error or "",
+ "level": "ERROR" if trace_info.error else "DEFAULT",
+ "total_tokens": trace_info.total_tokens,
+ "ls_provider": trace_info.model_provider or "",
+ "ls_model_name": trace_info.model_id or "",
+ }
+ metadata.update(trace_info.metadata)
+
+ trace_id = uuid_to_trace_id(trace_info.message_id)
+ span_id = RandomIdGenerator().generate_span_id()
+ context = SpanContext(
+ trace_id=trace_id,
+ span_id=span_id,
+ is_remote=False,
+ trace_flags=TraceFlags(TraceFlags.SAMPLED),
+ trace_state=TraceState(),
+ )
+
+ span = self.tracer.start_span(
+ name=TraceTaskName.SUGGESTED_QUESTION_TRACE.value,
+ attributes={
+ SpanAttributes.INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False),
+ SpanAttributes.OUTPUT_VALUE: json.dumps(trace_info.suggested_question, ensure_ascii=False),
+ SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value,
+ SpanAttributes.METADATA: json.dumps(metadata, ensure_ascii=False),
+ },
+ start_time=datetime_to_nanos(start_time),
+ context=trace.set_span_in_context(trace.NonRecordingSpan(context)),
+ )
+
+ try:
+ if trace_info.error:
+ span.add_event(
+ "exception",
+ attributes={
+ "exception.message": trace_info.error,
+ "exception.type": "Error",
+ "exception.stacktrace": trace_info.error,
+ },
+ )
+ finally:
+ span.end(end_time=datetime_to_nanos(end_time))
+
+ def dataset_retrieval_trace(self, trace_info: DatasetRetrievalTraceInfo):
+ if trace_info.message_data is None:
+ return
+
+ start_time = trace_info.start_time or trace_info.message_data.created_at
+ end_time = trace_info.end_time or trace_info.message_data.updated_at
+
+ metadata = {
+ "message_id": trace_info.message_id,
+ "tool_name": "dataset_retrieval",
+ "status": trace_info.message_data.status,
+ "status_message": trace_info.message_data.error or "",
+ "level": "ERROR" if trace_info.message_data.error else "DEFAULT",
+ "ls_provider": trace_info.message_data.model_provider or "",
+ "ls_model_name": trace_info.message_data.model_id or "",
+ }
+ metadata.update(trace_info.metadata)
+
+ trace_id = uuid_to_trace_id(trace_info.message_id)
+ span_id = RandomIdGenerator().generate_span_id()
+ context = SpanContext(
+ trace_id=trace_id,
+ span_id=span_id,
+ is_remote=False,
+ trace_flags=TraceFlags(TraceFlags.SAMPLED),
+ trace_state=TraceState(),
+ )
+
+ span = self.tracer.start_span(
+ name=TraceTaskName.DATASET_RETRIEVAL_TRACE.value,
+ attributes={
+ SpanAttributes.INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False),
+ SpanAttributes.OUTPUT_VALUE: json.dumps({"documents": trace_info.documents}, ensure_ascii=False),
+ SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.RETRIEVER.value,
+ SpanAttributes.METADATA: json.dumps(metadata, ensure_ascii=False),
+ "start_time": start_time.isoformat() if start_time else "",
+ "end_time": end_time.isoformat() if end_time else "",
+ },
+ start_time=datetime_to_nanos(start_time),
+ context=trace.set_span_in_context(trace.NonRecordingSpan(context)),
+ )
+
+ try:
+ if trace_info.message_data.error:
+ span.add_event(
+ "exception",
+ attributes={
+ "exception.message": trace_info.message_data.error,
+ "exception.type": "Error",
+ "exception.stacktrace": trace_info.message_data.error,
+ },
+ )
+ finally:
+ span.end(end_time=datetime_to_nanos(end_time))
+
+ def tool_trace(self, trace_info: ToolTraceInfo):
+ if trace_info.message_data is None:
+ logger.warning("[Arize/Phoenix] Message data is None, skipping tool trace.")
+ return
+
+ metadata = {
+ "message_id": trace_info.message_id,
+ "tool_config": json.dumps(trace_info.tool_config, ensure_ascii=False),
+ }
+
+ trace_id = uuid_to_trace_id(trace_info.message_id)
+ tool_span_id = RandomIdGenerator().generate_span_id()
+ logger.info(f"[Arize/Phoenix] Creating tool trace with trace_id: {trace_id}, span_id: {tool_span_id}")
+
+ # Create span context with the same trace_id as the parent
+ # todo: Create with the appropriate parent span context, so that the tool span is
+ # a child of the appropriate span (e.g. message span)
+ span_context = SpanContext(
+ trace_id=trace_id,
+ span_id=tool_span_id,
+ is_remote=False,
+ trace_flags=TraceFlags(TraceFlags.SAMPLED),
+ trace_state=TraceState(),
+ )
+
+ tool_params_str = (
+ json.dumps(trace_info.tool_parameters, ensure_ascii=False)
+ if isinstance(trace_info.tool_parameters, dict)
+ else str(trace_info.tool_parameters)
+ )
+
+ span = self.tracer.start_span(
+ name=trace_info.tool_name,
+ attributes={
+ SpanAttributes.INPUT_VALUE: json.dumps(trace_info.tool_inputs, ensure_ascii=False),
+ SpanAttributes.OUTPUT_VALUE: trace_info.tool_outputs,
+ SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.TOOL.value,
+ SpanAttributes.METADATA: json.dumps(metadata, ensure_ascii=False),
+ SpanAttributes.TOOL_NAME: trace_info.tool_name,
+ SpanAttributes.TOOL_PARAMETERS: tool_params_str,
+ },
+ start_time=datetime_to_nanos(trace_info.start_time),
+ context=trace.set_span_in_context(trace.NonRecordingSpan(span_context)),
+ )
+
+ try:
+ if trace_info.error:
+ span.add_event(
+ "exception",
+ attributes={
+ "exception.message": trace_info.error,
+ "exception.type": "Error",
+ "exception.stacktrace": trace_info.error,
+ },
+ )
+ finally:
+ span.end(end_time=datetime_to_nanos(trace_info.end_time))
+
+ def generate_name_trace(self, trace_info: GenerateNameTraceInfo):
+ if trace_info.message_data is None:
+ return
+
+ metadata = {
+ "project_name": self.project,
+ "message_id": trace_info.message_id,
+ "status": trace_info.message_data.status,
+ "status_message": trace_info.message_data.error or "",
+ "level": "ERROR" if trace_info.message_data.error else "DEFAULT",
+ }
+ metadata.update(trace_info.metadata)
+
+ trace_id = uuid_to_trace_id(trace_info.message_id)
+ span_id = RandomIdGenerator().generate_span_id()
+ context = SpanContext(
+ trace_id=trace_id,
+ span_id=span_id,
+ is_remote=False,
+ trace_flags=TraceFlags(TraceFlags.SAMPLED),
+ trace_state=TraceState(),
+ )
+
+ span = self.tracer.start_span(
+ name=TraceTaskName.GENERATE_NAME_TRACE.value,
+ attributes={
+ SpanAttributes.INPUT_VALUE: json.dumps(trace_info.inputs, ensure_ascii=False),
+ SpanAttributes.OUTPUT_VALUE: json.dumps(trace_info.outputs, ensure_ascii=False),
+ SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value,
+ SpanAttributes.METADATA: json.dumps(metadata, ensure_ascii=False),
+ SpanAttributes.SESSION_ID: trace_info.message_data.conversation_id,
+ "start_time": trace_info.start_time.isoformat() if trace_info.start_time else "",
+ "end_time": trace_info.end_time.isoformat() if trace_info.end_time else "",
+ },
+ start_time=datetime_to_nanos(trace_info.start_time),
+ context=trace.set_span_in_context(trace.NonRecordingSpan(context)),
+ )
+
+ try:
+ if trace_info.message_data.error:
+ span.add_event(
+ "exception",
+ attributes={
+ "exception.message": trace_info.message_data.error,
+ "exception.type": "Error",
+ "exception.stacktrace": trace_info.message_data.error,
+ },
+ )
+ finally:
+ span.end(end_time=datetime_to_nanos(trace_info.end_time))
+
+ def api_check(self):
+ try:
+ with self.tracer.start_span("api_check") as span:
+ span.set_attribute("test", "true")
+ return True
+ except Exception as e:
+ logger.info(f"[Arize/Phoenix] API check failed: {str(e)}", exc_info=True)
+ raise ValueError(f"[Arize/Phoenix] API check failed: {str(e)}")
+
+ def get_project_url(self):
+ try:
+ if self.arize_phoenix_config.endpoint == "https://otlp.arize.com":
+ return "https://app.arize.com/"
+ else:
+ return f"{self.arize_phoenix_config.endpoint}/projects/"
+ except Exception as e:
+ logger.info(f"[Arize/Phoenix] Get run url failed: {str(e)}", exc_info=True)
+ raise ValueError(f"[Arize/Phoenix] Get run url failed: {str(e)}")
+
+ def _get_workflow_nodes(self, workflow_run_id: str):
+ """Helper method to get workflow nodes"""
+ workflow_nodes = (
+ db.session.query(
+ WorkflowNodeExecutionModel.id,
+ WorkflowNodeExecutionModel.tenant_id,
+ WorkflowNodeExecutionModel.app_id,
+ WorkflowNodeExecutionModel.title,
+ WorkflowNodeExecutionModel.node_type,
+ WorkflowNodeExecutionModel.status,
+ WorkflowNodeExecutionModel.inputs,
+ WorkflowNodeExecutionModel.outputs,
+ WorkflowNodeExecutionModel.created_at,
+ WorkflowNodeExecutionModel.elapsed_time,
+ WorkflowNodeExecutionModel.process_data,
+ WorkflowNodeExecutionModel.execution_metadata,
+ )
+ .filter(WorkflowNodeExecutionModel.workflow_run_id == workflow_run_id)
+ .all()
+ )
+ return workflow_nodes
diff --git a/api/core/ops/entities/config_entity.py b/api/core/ops/entities/config_entity.py
index c988bf48d1..89ff0cfded 100644
--- a/api/core/ops/entities/config_entity.py
+++ b/api/core/ops/entities/config_entity.py
@@ -2,20 +2,92 @@ from enum import StrEnum
from pydantic import BaseModel, ValidationInfo, field_validator
+from core.ops.utils import validate_project_name, validate_url, validate_url_with_path
+
class TracingProviderEnum(StrEnum):
+ ARIZE = "arize"
+ PHOENIX = "phoenix"
LANGFUSE = "langfuse"
LANGSMITH = "langsmith"
OPIK = "opik"
WEAVE = "weave"
+ ALIYUN = "aliyun"
class BaseTracingConfig(BaseModel):
"""
- Base model class for tracing
+ Base model class for tracing configurations
"""
- ...
+ @classmethod
+ def validate_endpoint_url(cls, v: str, default_url: str) -> str:
+ """
+ Common endpoint URL validation logic
+
+ Args:
+ v: URL value to validate
+ default_url: Default URL to use if input is None or empty
+
+ Returns:
+ Validated and normalized URL
+ """
+ return validate_url(v, default_url)
+
+ @classmethod
+ def validate_project_field(cls, v: str, default_name: str) -> str:
+ """
+ Common project name validation logic
+
+ Args:
+ v: Project name to validate
+ default_name: Default name to use if input is None or empty
+
+ Returns:
+ Validated project name
+ """
+ return validate_project_name(v, default_name)
+
+
+class ArizeConfig(BaseTracingConfig):
+ """
+ Model class for Arize tracing config.
+ """
+
+ api_key: str | None = None
+ space_id: str | None = None
+ project: str | None = None
+ endpoint: str = "https://otlp.arize.com"
+
+ @field_validator("project")
+ @classmethod
+ def project_validator(cls, v, info: ValidationInfo):
+ return cls.validate_project_field(v, "default")
+
+ @field_validator("endpoint")
+ @classmethod
+ def endpoint_validator(cls, v, info: ValidationInfo):
+ return cls.validate_endpoint_url(v, "https://otlp.arize.com")
+
+
+class PhoenixConfig(BaseTracingConfig):
+ """
+ Model class for Phoenix tracing config.
+ """
+
+ api_key: str | None = None
+ project: str | None = None
+ endpoint: str = "https://app.phoenix.arize.com"
+
+ @field_validator("project")
+ @classmethod
+ def project_validator(cls, v, info: ValidationInfo):
+ return cls.validate_project_field(v, "default")
+
+ @field_validator("endpoint")
+ @classmethod
+ def endpoint_validator(cls, v, info: ValidationInfo):
+ return cls.validate_endpoint_url(v, "https://app.phoenix.arize.com")
class LangfuseConfig(BaseTracingConfig):
@@ -29,13 +101,8 @@ class LangfuseConfig(BaseTracingConfig):
@field_validator("host")
@classmethod
- def set_value(cls, v, info: ValidationInfo):
- if v is None or v == "":
- v = "https://api.langfuse.com"
- if not v.startswith("https://") and not v.startswith("http://"):
- raise ValueError("host must start with https:// or http://")
-
- return v
+ def host_validator(cls, v, info: ValidationInfo):
+ return cls.validate_endpoint_url(v, "https://api.langfuse.com")
class LangSmithConfig(BaseTracingConfig):
@@ -49,13 +116,9 @@ class LangSmithConfig(BaseTracingConfig):
@field_validator("endpoint")
@classmethod
- def set_value(cls, v, info: ValidationInfo):
- if v is None or v == "":
- v = "https://api.smith.langchain.com"
- if not v.startswith("https://"):
- raise ValueError("endpoint must start with https://")
-
- return v
+ def endpoint_validator(cls, v, info: ValidationInfo):
+ # LangSmith only allows HTTPS
+ return validate_url(v, "https://api.smith.langchain.com", allowed_schemes=("https",))
class OpikConfig(BaseTracingConfig):
@@ -71,22 +134,12 @@ class OpikConfig(BaseTracingConfig):
@field_validator("project")
@classmethod
def project_validator(cls, v, info: ValidationInfo):
- if v is None or v == "":
- v = "Default Project"
-
- return v
+ return cls.validate_project_field(v, "Default Project")
@field_validator("url")
@classmethod
def url_validator(cls, v, info: ValidationInfo):
- if v is None or v == "":
- v = "https://www.comet.com/opik/api/"
- if not v.startswith(("https://", "http://")):
- raise ValueError("url must start with https:// or http://")
- if not v.endswith("/api/"):
- raise ValueError("url should ends with /api/")
-
- return v
+ return validate_url_with_path(v, "https://www.comet.com/opik/api/", required_suffix="/api/")
class WeaveConfig(BaseTracingConfig):
@@ -102,22 +155,44 @@ class WeaveConfig(BaseTracingConfig):
@field_validator("endpoint")
@classmethod
- def set_value(cls, v, info: ValidationInfo):
- if v is None or v == "":
- v = "https://trace.wandb.ai"
- if not v.startswith("https://"):
- raise ValueError("endpoint must start with https://")
+ def endpoint_validator(cls, v, info: ValidationInfo):
+ # Weave only allows HTTPS for endpoint
+ return validate_url(v, "https://trace.wandb.ai", allowed_schemes=("https",))
+ @field_validator("host")
+ @classmethod
+ def host_validator(cls, v, info: ValidationInfo):
+ if v is not None and v.strip() != "":
+ return validate_url(v, v, allowed_schemes=("https", "http"))
return v
- @field_validator("host")
+
+class AliyunConfig(BaseTracingConfig):
+ """
+ Model class for Aliyun tracing config.
+ """
+
+ app_name: str = "dify_app"
+ license_key: str
+ endpoint: str
+
+ @field_validator("app_name")
+ @classmethod
+ def app_name_validator(cls, v, info: ValidationInfo):
+ return cls.validate_project_field(v, "dify_app")
+
+ @field_validator("license_key")
@classmethod
- def validate_host(cls, v, info: ValidationInfo):
- if v is not None and v != "":
- if not v.startswith(("https://", "http://")):
- raise ValueError("host must start with https:// or http://")
+ def license_key_validator(cls, v, info: ValidationInfo):
+ if not v or v.strip() == "":
+ raise ValueError("License key cannot be empty")
return v
+ @field_validator("endpoint")
+ @classmethod
+ def endpoint_validator(cls, v, info: ValidationInfo):
+ return cls.validate_endpoint_url(v, "https://tracing-analysis-dc-hz.aliyuncs.com")
+
OPS_FILE_PATH = "ops_trace/"
OPS_TRACE_FAILED_KEY = "FAILED_OPS_TRACE"
diff --git a/api/core/ops/langfuse_trace/langfuse_trace.py b/api/core/ops/langfuse_trace/langfuse_trace.py
index 0ea74e9ef0..4a7e66d27c 100644
--- a/api/core/ops/langfuse_trace/langfuse_trace.py
+++ b/api/core/ops/langfuse_trace/langfuse_trace.py
@@ -28,10 +28,11 @@ from core.ops.langfuse_trace.entities.langfuse_trace_entity import (
UnitEnum,
)
from core.ops.utils import filter_none_values
-from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository
+from core.repositories import DifyCoreRepositoryFactory
from core.workflow.nodes.enums import NodeType
from extensions.ext_database import db
from models import EndUser, WorkflowNodeExecutionTriggeredFrom
+from models.enums import MessageStatus
logger = logging.getLogger(__name__)
@@ -83,6 +84,7 @@ class LangFuseDataTrace(BaseTraceInstance):
metadata=metadata,
session_id=trace_info.conversation_id,
tags=["message", "workflow"],
+ version=trace_info.workflow_run_version,
)
self.add_trace(langfuse_trace_data=trace_data)
workflow_span_data = LangfuseSpan(
@@ -108,6 +110,7 @@ class LangFuseDataTrace(BaseTraceInstance):
metadata=metadata,
session_id=trace_info.conversation_id,
tags=["workflow"],
+ version=trace_info.workflow_run_version,
)
self.add_trace(langfuse_trace_data=trace_data)
@@ -120,10 +123,10 @@ class LangFuseDataTrace(BaseTraceInstance):
service_account = self.get_service_account_with_tenant(app_id)
- workflow_node_execution_repository = SQLAlchemyWorkflowNodeExecutionRepository(
+ workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
session_factory=session_factory,
user=service_account,
- app_id=trace_info.metadata.get("app_id"),
+ app_id=app_id,
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
)
@@ -172,48 +175,15 @@ class LangFuseDataTrace(BaseTraceInstance):
}
)
- # add span
- if trace_info.message_id:
- span_data = LangfuseSpan(
- id=node_execution_id,
- name=node_type,
- input=inputs,
- output=outputs,
- trace_id=trace_id,
- start_time=created_at,
- end_time=finished_at,
- metadata=metadata,
- level=(LevelEnum.DEFAULT if status == "succeeded" else LevelEnum.ERROR),
- status_message=trace_info.error or "",
- parent_observation_id=trace_info.workflow_run_id,
- )
- else:
- span_data = LangfuseSpan(
- id=node_execution_id,
- name=node_type,
- input=inputs,
- output=outputs,
- trace_id=trace_id,
- start_time=created_at,
- end_time=finished_at,
- metadata=metadata,
- level=(LevelEnum.DEFAULT if status == "succeeded" else LevelEnum.ERROR),
- status_message=trace_info.error or "",
- )
-
- self.add_span(langfuse_span_data=span_data)
-
+ # add generation span
if process_data and process_data.get("model_mode") == "chat":
total_token = metadata.get("total_tokens", 0)
prompt_tokens = 0
completion_tokens = 0
try:
- if outputs.get("usage"):
- prompt_tokens = outputs.get("usage", {}).get("prompt_tokens", 0)
- completion_tokens = outputs.get("usage", {}).get("completion_tokens", 0)
- else:
- prompt_tokens = process_data.get("usage", {}).get("prompt_tokens", 0)
- completion_tokens = process_data.get("usage", {}).get("completion_tokens", 0)
+ usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {})
+ prompt_tokens = usage_data.get("prompt_tokens", 0)
+ completion_tokens = usage_data.get("completion_tokens", 0)
except Exception:
logger.error("Failed to extract usage", exc_info=True)
@@ -226,10 +196,10 @@ class LangFuseDataTrace(BaseTraceInstance):
)
node_generation_data = LangfuseGeneration(
- name="llm",
+ id=node_execution_id,
+ name=node_name,
trace_id=trace_id,
model=process_data.get("model_name"),
- parent_observation_id=node_execution_id,
start_time=created_at,
end_time=finished_at,
input=inputs,
@@ -237,11 +207,30 @@ class LangFuseDataTrace(BaseTraceInstance):
metadata=metadata,
level=(LevelEnum.DEFAULT if status == "succeeded" else LevelEnum.ERROR),
status_message=trace_info.error or "",
+ parent_observation_id=trace_info.workflow_run_id if trace_info.message_id else None,
usage=generation_usage,
)
self.add_generation(langfuse_generation_data=node_generation_data)
+ # add normal span
+ else:
+ span_data = LangfuseSpan(
+ id=node_execution_id,
+ name=node_name,
+ input=inputs,
+ output=outputs,
+ trace_id=trace_id,
+ start_time=created_at,
+ end_time=finished_at,
+ metadata=metadata,
+ level=(LevelEnum.DEFAULT if status == "succeeded" else LevelEnum.ERROR),
+ status_message=trace_info.error or "",
+ parent_observation_id=trace_info.workflow_run_id if trace_info.message_id else None,
+ )
+
+ self.add_span(langfuse_span_data=span_data)
+
def message_trace(self, trace_info: MessageTraceInfo, **kwargs):
# get message file data
file_list = trace_info.file_list
@@ -284,7 +273,7 @@ class LangFuseDataTrace(BaseTraceInstance):
)
self.add_trace(langfuse_trace_data=trace_data)
- # start add span
+ # add generation
generation_usage = GenerationUsage(
input=trace_info.message_tokens,
output=trace_info.answer_tokens,
@@ -302,7 +291,7 @@ class LangFuseDataTrace(BaseTraceInstance):
input=trace_info.inputs,
output=message_data.answer,
metadata=metadata,
- level=(LevelEnum.DEFAULT if message_data.status != "error" else LevelEnum.ERROR),
+ level=(LevelEnum.DEFAULT if message_data.status != MessageStatus.ERROR else LevelEnum.ERROR),
status_message=message_data.error or "",
usage=generation_usage,
)
@@ -348,7 +337,7 @@ class LangFuseDataTrace(BaseTraceInstance):
start_time=trace_info.start_time,
end_time=trace_info.end_time,
metadata=trace_info.metadata,
- level=(LevelEnum.DEFAULT if message_data.status != "error" else LevelEnum.ERROR),
+ level=(LevelEnum.DEFAULT if message_data.status != MessageStatus.ERROR else LevelEnum.ERROR),
status_message=message_data.error or "",
usage=generation_usage,
)
diff --git a/api/core/ops/langsmith_trace/langsmith_trace.py b/api/core/ops/langsmith_trace/langsmith_trace.py
index 8a392940db..8a559c4929 100644
--- a/api/core/ops/langsmith_trace/langsmith_trace.py
+++ b/api/core/ops/langsmith_trace/langsmith_trace.py
@@ -27,7 +27,7 @@ from core.ops.langsmith_trace.entities.langsmith_trace_entity import (
LangSmithRunUpdateModel,
)
from core.ops.utils import filter_none_values, generate_dotted_order
-from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository
+from core.repositories import DifyCoreRepositoryFactory
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionMetadataKey
from core.workflow.nodes.enums import NodeType
from extensions.ext_database import db
@@ -145,10 +145,10 @@ class LangSmithDataTrace(BaseTraceInstance):
service_account = self.get_service_account_with_tenant(app_id)
- workflow_node_execution_repository = SQLAlchemyWorkflowNodeExecutionRepository(
+ workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
session_factory=session_factory,
user=service_account,
- app_id=trace_info.metadata.get("app_id"),
+ app_id=app_id,
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
)
@@ -206,12 +206,9 @@ class LangSmithDataTrace(BaseTraceInstance):
prompt_tokens = 0
completion_tokens = 0
try:
- if outputs.get("usage"):
- prompt_tokens = outputs.get("usage", {}).get("prompt_tokens", 0)
- completion_tokens = outputs.get("usage", {}).get("completion_tokens", 0)
- else:
- prompt_tokens = process_data.get("usage", {}).get("prompt_tokens", 0)
- completion_tokens = process_data.get("usage", {}).get("completion_tokens", 0)
+ usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {})
+ prompt_tokens = usage_data.get("prompt_tokens", 0)
+ completion_tokens = usage_data.get("completion_tokens", 0)
except Exception:
logger.error("Failed to extract usage", exc_info=True)
diff --git a/api/core/ops/opik_trace/opik_trace.py b/api/core/ops/opik_trace/opik_trace.py
index f4d2760ba5..be4997a5bf 100644
--- a/api/core/ops/opik_trace/opik_trace.py
+++ b/api/core/ops/opik_trace/opik_trace.py
@@ -21,7 +21,7 @@ from core.ops.entities.trace_entity import (
TraceTaskName,
WorkflowTraceInfo,
)
-from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository
+from core.repositories import DifyCoreRepositoryFactory
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionMetadataKey
from core.workflow.nodes.enums import NodeType
from extensions.ext_database import db
@@ -160,10 +160,10 @@ class OpikDataTrace(BaseTraceInstance):
service_account = self.get_service_account_with_tenant(app_id)
- workflow_node_execution_repository = SQLAlchemyWorkflowNodeExecutionRepository(
+ workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
session_factory=session_factory,
user=service_account,
- app_id=trace_info.metadata.get("app_id"),
+ app_id=app_id,
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
)
@@ -222,10 +222,10 @@ class OpikDataTrace(BaseTraceInstance):
)
try:
- if outputs.get("usage"):
- total_tokens = outputs["usage"].get("total_tokens", 0)
- prompt_tokens = outputs["usage"].get("prompt_tokens", 0)
- completion_tokens = outputs["usage"].get("completion_tokens", 0)
+ usage_data = process_data.get("usage", {}) if "usage" in process_data else outputs.get("usage", {})
+ total_tokens = usage_data.get("total_tokens", 0)
+ prompt_tokens = usage_data.get("prompt_tokens", 0)
+ completion_tokens = usage_data.get("completion_tokens", 0)
except Exception:
logger.error("Failed to extract usage", exc_info=True)
@@ -241,7 +241,7 @@ class OpikDataTrace(BaseTraceInstance):
"trace_id": opik_trace_id,
"id": prepare_opik_uuid(created_at, node_execution_id),
"parent_span_id": prepare_opik_uuid(trace_info.start_time, parent_span_id),
- "name": node_type,
+ "name": node_name,
"type": run_type,
"start_time": created_at,
"end_time": finished_at,
diff --git a/api/core/ops/ops_trace_manager.py b/api/core/ops/ops_trace_manager.py
index e0dfe0c312..5c9b9d27b7 100644
--- a/api/core/ops/ops_trace_manager.py
+++ b/api/core/ops/ops_trace_manager.py
@@ -84,6 +84,36 @@ class OpsTraceProviderConfigMap(dict[str, dict[str, Any]]):
"other_keys": ["project", "entity", "endpoint", "host"],
"trace_instance": WeaveDataTrace,
}
+ case TracingProviderEnum.ARIZE:
+ from core.ops.arize_phoenix_trace.arize_phoenix_trace import ArizePhoenixDataTrace
+ from core.ops.entities.config_entity import ArizeConfig
+
+ return {
+ "config_class": ArizeConfig,
+ "secret_keys": ["api_key", "space_id"],
+ "other_keys": ["project", "endpoint"],
+ "trace_instance": ArizePhoenixDataTrace,
+ }
+ case TracingProviderEnum.PHOENIX:
+ from core.ops.arize_phoenix_trace.arize_phoenix_trace import ArizePhoenixDataTrace
+ from core.ops.entities.config_entity import PhoenixConfig
+
+ return {
+ "config_class": PhoenixConfig,
+ "secret_keys": ["api_key"],
+ "other_keys": ["project", "endpoint"],
+ "trace_instance": ArizePhoenixDataTrace,
+ }
+ case TracingProviderEnum.ALIYUN:
+ from core.ops.aliyun_trace.aliyun_trace import AliyunDataTrace
+ from core.ops.entities.config_entity import AliyunConfig
+
+ return {
+ "config_class": AliyunConfig,
+ "secret_keys": ["license_key"],
+ "other_keys": ["endpoint", "app_name"],
+ "trace_instance": AliyunDataTrace,
+ }
case _:
raise KeyError(f"Unsupported tracing provider: {provider}")
@@ -251,7 +281,7 @@ class OpsTraceManager:
provider_config_map[tracing_provider]["trace_instance"],
provider_config_map[tracing_provider]["config_class"],
)
- decrypt_trace_config_key = str(decrypt_trace_config)
+ decrypt_trace_config_key = json.dumps(decrypt_trace_config, sort_keys=True)
tracing_instance = cls.ops_trace_instances_cache.get(decrypt_trace_config_key)
if tracing_instance is None:
# create new tracing_instance and update the cache if it absent
diff --git a/api/core/ops/utils.py b/api/core/ops/utils.py
index 8b06df1930..36d060afd2 100644
--- a/api/core/ops/utils.py
+++ b/api/core/ops/utils.py
@@ -1,6 +1,7 @@
from contextlib import contextmanager
from datetime import datetime
from typing import Optional, Union
+from urllib.parse import urlparse
from extensions.ext_database import db
from models.model import Message
@@ -60,3 +61,83 @@ def generate_dotted_order(
return current_segment
return f"{parent_dotted_order}.{current_segment}"
+
+
+def validate_url(url: str, default_url: str, allowed_schemes: tuple = ("https", "http")) -> str:
+ """
+ Validate and normalize URL with proper error handling
+
+ Args:
+ url: The URL to validate
+ default_url: Default URL to use if input is None or empty
+ allowed_schemes: Tuple of allowed URL schemes (default: https, http)
+
+ Returns:
+ Normalized URL string
+
+ Raises:
+ ValueError: If URL format is invalid or scheme not allowed
+ """
+ if not url or url.strip() == "":
+ return default_url
+
+ # Parse URL to validate format
+ parsed = urlparse(url)
+
+ # Check if scheme is allowed
+ if parsed.scheme not in allowed_schemes:
+ raise ValueError(f"URL scheme must be one of: {', '.join(allowed_schemes)}")
+
+ # Reconstruct URL with only scheme, netloc (removing path, query, fragment)
+ normalized_url = f"{parsed.scheme}://{parsed.netloc}"
+
+ return normalized_url
+
+
+def validate_url_with_path(url: str, default_url: str, required_suffix: str | None = None) -> str:
+ """
+ Validate URL that may include path components
+
+ Args:
+ url: The URL to validate
+ default_url: Default URL to use if input is None or empty
+ required_suffix: Optional suffix that URL must end with
+
+ Returns:
+ Validated URL string
+
+ Raises:
+ ValueError: If URL format is invalid or doesn't match required suffix
+ """
+ if not url or url.strip() == "":
+ return default_url
+
+ # Parse URL to validate format
+ parsed = urlparse(url)
+
+ # Check if scheme is allowed
+ if parsed.scheme not in ("https", "http"):
+ raise ValueError("URL must start with https:// or http://")
+
+ # Check required suffix if specified
+ if required_suffix and not url.endswith(required_suffix):
+ raise ValueError(f"URL should end with {required_suffix}")
+
+ return url
+
+
+def validate_project_name(project: str, default_name: str) -> str:
+ """
+ Validate and normalize project name
+
+ Args:
+ project: Project name to validate
+ default_name: Default name to use if input is None or empty
+
+ Returns:
+ Normalized project name
+ """
+ if not project or project.strip() == "":
+ return default_name
+
+ return project.strip()
diff --git a/api/core/ops/weave_trace/weave_trace.py b/api/core/ops/weave_trace/weave_trace.py
index 3917348a91..445c6a8741 100644
--- a/api/core/ops/weave_trace/weave_trace.py
+++ b/api/core/ops/weave_trace/weave_trace.py
@@ -22,7 +22,7 @@ from core.ops.entities.trace_entity import (
WorkflowTraceInfo,
)
from core.ops.weave_trace.entities.weave_trace_entity import WeaveTraceModel
-from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository
+from core.repositories import DifyCoreRepositoryFactory
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionMetadataKey
from core.workflow.nodes.enums import NodeType
from extensions.ext_database import db
@@ -144,10 +144,10 @@ class WeaveDataTrace(BaseTraceInstance):
service_account = self.get_service_account_with_tenant(app_id)
- workflow_node_execution_repository = SQLAlchemyWorkflowNodeExecutionRepository(
+ workflow_node_execution_repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
session_factory=session_factory,
user=service_account,
- app_id=trace_info.metadata.get("app_id"),
+ app_id=app_id,
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
)
diff --git a/api/core/plugin/backwards_invocation/model.py b/api/core/plugin/backwards_invocation/model.py
index 072644e53b..d07ab3d0c4 100644
--- a/api/core/plugin/backwards_invocation/model.py
+++ b/api/core/plugin/backwards_invocation/model.py
@@ -2,8 +2,15 @@ import tempfile
from binascii import hexlify, unhexlify
from collections.abc import Generator
+from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output
from core.model_manager import ModelManager
-from core.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta
+from core.model_runtime.entities.llm_entities import (
+ LLMResult,
+ LLMResultChunk,
+ LLMResultChunkDelta,
+ LLMResultChunkWithStructuredOutput,
+ LLMResultWithStructuredOutput,
+)
from core.model_runtime.entities.message_entities import (
PromptMessage,
SystemPromptMessage,
@@ -12,6 +19,7 @@ from core.model_runtime.entities.message_entities import (
from core.plugin.backwards_invocation.base import BaseBackwardsInvocation
from core.plugin.entities.request import (
RequestInvokeLLM,
+ RequestInvokeLLMWithStructuredOutput,
RequestInvokeModeration,
RequestInvokeRerank,
RequestInvokeSpeech2Text,
@@ -81,6 +89,72 @@ class PluginModelBackwardsInvocation(BaseBackwardsInvocation):
return handle_non_streaming(response)
+ @classmethod
+ def invoke_llm_with_structured_output(
+ cls, user_id: str, tenant: Tenant, payload: RequestInvokeLLMWithStructuredOutput
+ ):
+ """
+ invoke llm with structured output
+ """
+ model_instance = ModelManager().get_model_instance(
+ tenant_id=tenant.id,
+ provider=payload.provider,
+ model_type=payload.model_type,
+ model=payload.model,
+ )
+
+ model_schema = model_instance.model_type_instance.get_model_schema(payload.model, model_instance.credentials)
+
+ if not model_schema:
+ raise ValueError(f"Model schema not found for {payload.model}")
+
+ response = invoke_llm_with_structured_output(
+ provider=payload.provider,
+ model_schema=model_schema,
+ model_instance=model_instance,
+ prompt_messages=payload.prompt_messages,
+ json_schema=payload.structured_output_schema,
+ tools=payload.tools,
+ stop=payload.stop,
+ stream=True if payload.stream is None else payload.stream,
+ user=user_id,
+ model_parameters=payload.completion_params,
+ )
+
+ if isinstance(response, Generator):
+
+ def handle() -> Generator[LLMResultChunkWithStructuredOutput, None, None]:
+ for chunk in response:
+ if chunk.delta.usage:
+ llm_utils.deduct_llm_quota(
+ tenant_id=tenant.id, model_instance=model_instance, usage=chunk.delta.usage
+ )
+ chunk.prompt_messages = []
+ yield chunk
+
+ return handle()
+ else:
+ if response.usage:
+ llm_utils.deduct_llm_quota(tenant_id=tenant.id, model_instance=model_instance, usage=response.usage)
+
+ def handle_non_streaming(
+ response: LLMResultWithStructuredOutput,
+ ) -> Generator[LLMResultChunkWithStructuredOutput, None, None]:
+ yield LLMResultChunkWithStructuredOutput(
+ model=response.model,
+ prompt_messages=[],
+ system_fingerprint=response.system_fingerprint,
+ structured_output=response.structured_output,
+ delta=LLMResultChunkDelta(
+ index=0,
+ message=response.message,
+ usage=response.usage,
+ finish_reason="",
+ ),
+ )
+
+ return handle_non_streaming(response)
+
@classmethod
def invoke_text_embedding(cls, user_id: str, tenant: Tenant, payload: RequestInvokeTextEmbedding):
"""
diff --git a/api/core/plugin/entities/parameters.py b/api/core/plugin/entities/parameters.py
index 895dd0d0fc..2be65d67a0 100644
--- a/api/core/plugin/entities/parameters.py
+++ b/api/core/plugin/entities/parameters.py
@@ -10,6 +10,9 @@ from core.tools.entities.common_entities import I18nObject
class PluginParameterOption(BaseModel):
value: str = Field(..., description="The value of the option")
label: I18nObject = Field(..., description="The label of the option")
+ icon: Optional[str] = Field(
+ default=None, description="The icon of the option, can be a url or a base64 encoded image"
+ )
@field_validator("value", mode="before")
@classmethod
@@ -35,10 +38,24 @@ class PluginParameterType(enum.StrEnum):
APP_SELECTOR = CommonParameterType.APP_SELECTOR.value
MODEL_SELECTOR = CommonParameterType.MODEL_SELECTOR.value
TOOLS_SELECTOR = CommonParameterType.TOOLS_SELECTOR.value
+ DYNAMIC_SELECT = CommonParameterType.DYNAMIC_SELECT.value
# deprecated, should not use.
SYSTEM_FILES = CommonParameterType.SYSTEM_FILES.value
+ # MCP object and array type parameters
+ ARRAY = CommonParameterType.ARRAY.value
+ OBJECT = CommonParameterType.OBJECT.value
+
+
+class MCPServerParameterType(enum.StrEnum):
+ """
+ MCP server got complex parameter types
+ """
+
+ ARRAY = "array"
+ OBJECT = "object"
+
class PluginParameterAutoGenerate(BaseModel):
class Type(enum.StrEnum):
@@ -134,6 +151,34 @@ def cast_parameter_value(typ: enum.StrEnum, value: Any, /):
if value and not isinstance(value, list):
raise ValueError("The tools selector must be a list.")
return value
+ case PluginParameterType.ARRAY:
+ if not isinstance(value, list):
+ # Try to parse JSON string for arrays
+ if isinstance(value, str):
+ try:
+ import json
+
+ parsed_value = json.loads(value)
+ if isinstance(parsed_value, list):
+ return parsed_value
+ except (json.JSONDecodeError, ValueError):
+ pass
+ return [value]
+ return value
+ case PluginParameterType.OBJECT:
+ if not isinstance(value, dict):
+ # Try to parse JSON string for objects
+ if isinstance(value, str):
+ try:
+ import json
+
+ parsed_value = json.loads(value)
+ if isinstance(parsed_value, dict):
+ return parsed_value
+ except (json.JSONDecodeError, ValueError):
+ pass
+ return {}
+ return value
case _:
return str(value)
except ValueError:
diff --git a/api/core/plugin/entities/plugin.py b/api/core/plugin/entities/plugin.py
index bdf7d5ce1f..e5cf7ee03a 100644
--- a/api/core/plugin/entities/plugin.py
+++ b/api/core/plugin/entities/plugin.py
@@ -72,12 +72,14 @@ class PluginDeclaration(BaseModel):
class Meta(BaseModel):
minimum_dify_version: Optional[str] = Field(default=None, pattern=r"^\d{1,4}(\.\d{1,4}){1,3}(-\w{1,16})?$")
+ version: Optional[str] = Field(default=None)
version: str = Field(..., pattern=r"^\d{1,4}(\.\d{1,4}){1,3}(-\w{1,16})?$")
author: Optional[str] = Field(..., pattern=r"^[a-zA-Z0-9_-]{1,64}$")
name: str = Field(..., pattern=r"^[a-z0-9_-]{1,128}$")
description: I18nObject
icon: str
+ icon_dark: Optional[str] = Field(default=None)
label: I18nObject
category: PluginCategory
created_at: datetime.datetime
diff --git a/api/core/plugin/entities/plugin_daemon.py b/api/core/plugin/entities/plugin_daemon.py
index e9275c31cc..00253b8a11 100644
--- a/api/core/plugin/entities/plugin_daemon.py
+++ b/api/core/plugin/entities/plugin_daemon.py
@@ -1,4 +1,4 @@
-from collections.abc import Mapping
+from collections.abc import Mapping, Sequence
from datetime import datetime
from enum import StrEnum
from typing import Any, Generic, Optional, TypeVar
@@ -9,6 +9,7 @@ from core.agent.plugin_entities import AgentProviderEntityWithPlugin
from core.model_runtime.entities.model_entities import AIModelEntity
from core.model_runtime.entities.provider_entities import ProviderEntity
from core.plugin.entities.base import BasePluginEntity
+from core.plugin.entities.parameters import PluginParameterOption
from core.plugin.entities.plugin import PluginDeclaration, PluginEntity
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderEntityWithPlugin
@@ -52,6 +53,7 @@ class PluginAgentProviderEntity(BaseModel):
plugin_unique_identifier: str
plugin_id: str
declaration: AgentProviderEntityWithPlugin
+ meta: PluginDeclaration.Meta
class PluginBasicBooleanResponse(BaseModel):
@@ -156,9 +158,23 @@ class PluginInstallTaskStartResponse(BaseModel):
task_id: str = Field(description="The ID of the install task.")
-class PluginUploadResponse(BaseModel):
+class PluginVerification(BaseModel):
+ """
+ Verification of the plugin.
+ """
+
+ class AuthorizedCategory(StrEnum):
+ Langgenius = "langgenius"
+ Partner = "partner"
+ Community = "community"
+
+ authorized_category: AuthorizedCategory = Field(description="The authorized category of the plugin.")
+
+
+class PluginDecodeResponse(BaseModel):
unique_identifier: str = Field(description="The unique identifier of the plugin.")
manifest: PluginDeclaration
+ verification: Optional[PluginVerification] = Field(default=None, description="Basic verification information")
class PluginOAuthAuthorizationUrlResponse(BaseModel):
@@ -172,3 +188,7 @@ class PluginOAuthCredentialsResponse(BaseModel):
class PluginListResponse(BaseModel):
list: list[PluginEntity]
total: int
+
+
+class PluginDynamicSelectOptionsResponse(BaseModel):
+ options: Sequence[PluginParameterOption] = Field(description="The options of the dynamic select.")
diff --git a/api/core/plugin/entities/request.py b/api/core/plugin/entities/request.py
index 1692020ec8..89f595ec46 100644
--- a/api/core/plugin/entities/request.py
+++ b/api/core/plugin/entities/request.py
@@ -32,7 +32,7 @@ class RequestInvokeTool(BaseModel):
Request to invoke a tool
"""
- tool_type: Literal["builtin", "workflow", "api"]
+ tool_type: Literal["builtin", "workflow", "api", "mcp"]
provider: str
tool: str
tool_parameters: dict
@@ -82,6 +82,16 @@ class RequestInvokeLLM(BaseRequestInvokeModel):
return v
+class RequestInvokeLLMWithStructuredOutput(RequestInvokeLLM):
+ """
+ Request to invoke LLM with structured output
+ """
+
+ structured_output_schema: dict[str, Any] = Field(
+ default_factory=dict, description="The schema of the structured output in JSON schema format"
+ )
+
+
class RequestInvokeTextEmbedding(BaseRequestInvokeModel):
"""
Request to invoke text embedding
diff --git a/api/core/plugin/impl/dynamic_select.py b/api/core/plugin/impl/dynamic_select.py
new file mode 100644
index 0000000000..004412afd7
--- /dev/null
+++ b/api/core/plugin/impl/dynamic_select.py
@@ -0,0 +1,45 @@
+from collections.abc import Mapping
+from typing import Any
+
+from core.plugin.entities.plugin import GenericProviderID
+from core.plugin.entities.plugin_daemon import PluginDynamicSelectOptionsResponse
+from core.plugin.impl.base import BasePluginClient
+
+
+class DynamicSelectClient(BasePluginClient):
+ def fetch_dynamic_select_options(
+ self,
+ tenant_id: str,
+ user_id: str,
+ plugin_id: str,
+ provider: str,
+ action: str,
+ credentials: Mapping[str, Any],
+ parameter: str,
+ ) -> PluginDynamicSelectOptionsResponse:
+ """
+ Fetch dynamic select options for a plugin parameter.
+ """
+ response = self._request_with_plugin_daemon_response_stream(
+ "POST",
+ f"plugin/{tenant_id}/dispatch/dynamic_select/fetch_parameter_options",
+ PluginDynamicSelectOptionsResponse,
+ data={
+ "user_id": user_id,
+ "data": {
+ "provider": GenericProviderID(provider).provider_name,
+ "credentials": credentials,
+ "provider_action": action,
+ "parameter": parameter,
+ },
+ },
+ headers={
+ "X-Plugin-ID": plugin_id,
+ "Content-Type": "application/json",
+ },
+ )
+
+ for options in response:
+ return options
+
+ raise ValueError(f"Plugin service returned no options for parameter '{parameter}' in provider '{provider}'")
diff --git a/api/core/plugin/impl/oauth.py b/api/core/plugin/impl/oauth.py
index 91774984c8..b006bf1d4b 100644
--- a/api/core/plugin/impl/oauth.py
+++ b/api/core/plugin/impl/oauth.py
@@ -1,3 +1,4 @@
+import binascii
from collections.abc import Mapping
from typing import Any
@@ -16,7 +17,7 @@ class OAuthHandler(BasePluginClient):
provider: str,
system_credentials: Mapping[str, Any],
) -> PluginOAuthAuthorizationUrlResponse:
- return self._request_with_plugin_daemon_response(
+ response = self._request_with_plugin_daemon_response_stream(
"POST",
f"plugin/{tenant_id}/dispatch/oauth/get_authorization_url",
PluginOAuthAuthorizationUrlResponse,
@@ -32,6 +33,9 @@ class OAuthHandler(BasePluginClient):
"Content-Type": "application/json",
},
)
+ for resp in response:
+ return resp
+ raise ValueError("No response received from plugin daemon for authorization URL request.")
def get_credentials(
self,
@@ -49,7 +53,7 @@ class OAuthHandler(BasePluginClient):
# encode request to raw http request
raw_request_bytes = self._convert_request_to_raw_data(request)
- return self._request_with_plugin_daemon_response(
+ response = self._request_with_plugin_daemon_response_stream(
"POST",
f"plugin/{tenant_id}/dispatch/oauth/get_credentials",
PluginOAuthCredentialsResponse,
@@ -58,7 +62,8 @@ class OAuthHandler(BasePluginClient):
"data": {
"provider": provider,
"system_credentials": system_credentials,
- "raw_request_bytes": raw_request_bytes,
+ # for json serialization
+ "raw_http_request": binascii.hexlify(raw_request_bytes).decode(),
},
},
headers={
@@ -66,6 +71,9 @@ class OAuthHandler(BasePluginClient):
"Content-Type": "application/json",
},
)
+ for resp in response:
+ return resp
+ raise ValueError("No response received from plugin daemon for authorization URL request.")
def _convert_request_to_raw_data(self, request: Request) -> bytes:
"""
@@ -79,7 +87,7 @@ class OAuthHandler(BasePluginClient):
"""
# Start with the request line
method = request.method
- path = request.path
+ path = request.full_path
protocol = request.headers.get("HTTP_VERSION", "HTTP/1.1")
raw_data = f"{method} {path} {protocol}\r\n".encode()
diff --git a/api/core/plugin/impl/plugin.py b/api/core/plugin/impl/plugin.py
index 1cd2dc1be7..04ac8c9649 100644
--- a/api/core/plugin/impl/plugin.py
+++ b/api/core/plugin/impl/plugin.py
@@ -10,10 +10,10 @@ from core.plugin.entities.plugin import (
PluginInstallationSource,
)
from core.plugin.entities.plugin_daemon import (
+ PluginDecodeResponse,
PluginInstallTask,
PluginInstallTaskStartResponse,
PluginListResponse,
- PluginUploadResponse,
)
from core.plugin.impl.base import BasePluginClient
@@ -36,7 +36,7 @@ class PluginInstaller(BasePluginClient):
"GET",
f"plugin/{tenant_id}/management/list",
PluginListResponse,
- params={"page": 1, "page_size": 256},
+ params={"page": 1, "page_size": 256, "response_type": "paged"},
)
return result.list
@@ -45,7 +45,7 @@ class PluginInstaller(BasePluginClient):
"GET",
f"plugin/{tenant_id}/management/list",
PluginListResponse,
- params={"page": page, "page_size": page_size},
+ params={"page": page, "page_size": page_size, "response_type": "paged"},
)
def upload_pkg(
@@ -53,7 +53,7 @@ class PluginInstaller(BasePluginClient):
tenant_id: str,
pkg: bytes,
verify_signature: bool = False,
- ) -> PluginUploadResponse:
+ ) -> PluginDecodeResponse:
"""
Upload a plugin package and return the plugin unique identifier.
"""
@@ -68,7 +68,7 @@ class PluginInstaller(BasePluginClient):
return self._request_with_plugin_daemon_response(
"POST",
f"plugin/{tenant_id}/management/install/upload/package",
- PluginUploadResponse,
+ PluginDecodeResponse,
files=body,
data=data,
)
@@ -176,6 +176,18 @@ class PluginInstaller(BasePluginClient):
params={"plugin_unique_identifier": plugin_unique_identifier},
)
+ def decode_plugin_from_identifier(self, tenant_id: str, plugin_unique_identifier: str) -> PluginDecodeResponse:
+ """
+ Decode a plugin from an identifier.
+ """
+ return self._request_with_plugin_daemon_response(
+ "GET",
+ f"plugin/{tenant_id}/management/decode/from_identifier",
+ PluginDecodeResponse,
+ data={"plugin_unique_identifier": plugin_unique_identifier},
+ headers={"Content-Type": "application/json"},
+ )
+
def fetch_plugin_installation_by_ids(
self, tenant_id: str, plugin_ids: Sequence[str]
) -> Sequence[PluginInstallation]:
diff --git a/api/core/prompt/utils/extract_thread_messages.py b/api/core/prompt/utils/extract_thread_messages.py
index f7aef76c87..4b883622a7 100644
--- a/api/core/prompt/utils/extract_thread_messages.py
+++ b/api/core/prompt/utils/extract_thread_messages.py
@@ -1,10 +1,11 @@
-from typing import Any
+from collections.abc import Sequence
from constants import UUID_NIL
+from models import Message
-def extract_thread_messages(messages: list[Any]):
- thread_messages = []
+def extract_thread_messages(messages: Sequence[Message]):
+ thread_messages: list[Message] = []
next_message = None
for message in messages:
diff --git a/api/core/prompt/utils/get_thread_messages_length.py b/api/core/prompt/utils/get_thread_messages_length.py
index f49466db6d..de64c27a73 100644
--- a/api/core/prompt/utils/get_thread_messages_length.py
+++ b/api/core/prompt/utils/get_thread_messages_length.py
@@ -1,3 +1,5 @@
+from sqlalchemy import select
+
from core.prompt.utils.extract_thread_messages import extract_thread_messages
from extensions.ext_database import db
from models.model import Message
@@ -8,19 +10,9 @@ def get_thread_messages_length(conversation_id: str) -> int:
Get the number of thread messages based on the parent message id.
"""
# Fetch all messages related to the conversation
- query = (
- db.session.query(
- Message.id,
- Message.parent_message_id,
- Message.answer,
- )
- .filter(
- Message.conversation_id == conversation_id,
- )
- .order_by(Message.created_at.desc())
- )
+ stmt = select(Message).where(Message.conversation_id == conversation_id).order_by(Message.created_at.desc())
- messages = query.all()
+ messages = db.session.scalars(stmt).all()
# Extract thread messages
thread_messages = extract_thread_messages(messages)
diff --git a/api/core/rag/datasource/retrieval_service.py b/api/core/rag/datasource/retrieval_service.py
index 2c5178241c..5a6903d3d5 100644
--- a/api/core/rag/datasource/retrieval_service.py
+++ b/api/core/rag/datasource/retrieval_service.py
@@ -3,7 +3,7 @@ from concurrent.futures import ThreadPoolExecutor
from typing import Optional
from flask import Flask, current_app
-from sqlalchemy.orm import load_only
+from sqlalchemy.orm import Session, load_only
from configs import dify_config
from core.rag.data_post_processor.data_post_processor import DataPostProcessor
@@ -144,7 +144,8 @@ class RetrievalService:
@classmethod
def _get_dataset(cls, dataset_id: str) -> Optional[Dataset]:
- return db.session.query(Dataset).filter(Dataset.id == dataset_id).first()
+ with Session(db.engine) as session:
+ return session.query(Dataset).filter(Dataset.id == dataset_id).first()
@classmethod
def keyword_search(
diff --git a/api/core/rag/datasource/vdb/matrixone/__init__.py b/api/core/rag/datasource/vdb/matrixone/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/core/rag/datasource/vdb/matrixone/matrixone_vector.py b/api/core/rag/datasource/vdb/matrixone/matrixone_vector.py
new file mode 100644
index 0000000000..4894957382
--- /dev/null
+++ b/api/core/rag/datasource/vdb/matrixone/matrixone_vector.py
@@ -0,0 +1,233 @@
+import json
+import logging
+import uuid
+from functools import wraps
+from typing import Any, Optional
+
+from mo_vector.client import MoVectorClient # type: ignore
+from pydantic import BaseModel, model_validator
+
+from configs import dify_config
+from core.rag.datasource.vdb.vector_base import BaseVector
+from core.rag.datasource.vdb.vector_factory import AbstractVectorFactory
+from core.rag.datasource.vdb.vector_type import VectorType
+from core.rag.embedding.embedding_base import Embeddings
+from core.rag.models.document import Document
+from extensions.ext_redis import redis_client
+from models.dataset import Dataset
+
+logger = logging.getLogger(__name__)
+
+
+class MatrixoneConfig(BaseModel):
+ host: str = "localhost"
+ port: int = 6001
+ user: str = "dump"
+ password: str = "111"
+ database: str = "dify"
+ metric: str = "l2"
+
+ @model_validator(mode="before")
+ @classmethod
+ def validate_config(cls, values: dict) -> dict:
+ if not values["host"]:
+ raise ValueError("config host is required")
+ if not values["port"]:
+ raise ValueError("config port is required")
+ if not values["user"]:
+ raise ValueError("config user is required")
+ if not values["password"]:
+ raise ValueError("config password is required")
+ if not values["database"]:
+ raise ValueError("config database is required")
+ return values
+
+
+def ensure_client(func):
+ @wraps(func)
+ def wrapper(self, *args, **kwargs):
+ if self.client is None:
+ self.client = self._get_client(None, False)
+ return func(self, *args, **kwargs)
+
+ return wrapper
+
+
+class MatrixoneVector(BaseVector):
+ """
+ Matrixone vector storage implementation.
+ """
+
+ def __init__(self, collection_name: str, config: MatrixoneConfig):
+ super().__init__(collection_name)
+ self.config = config
+ self.collection_name = collection_name.lower()
+ self.client = None
+
+ @property
+ def collection_name(self):
+ return self._collection_name
+
+ @collection_name.setter
+ def collection_name(self, value):
+ self._collection_name = value
+
+ def get_type(self) -> str:
+ return VectorType.MATRIXONE
+
+ def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs):
+ if self.client is None:
+ self.client = self._get_client(len(embeddings[0]), True)
+ return self.add_texts(texts, embeddings)
+
+ def _get_client(self, dimension: Optional[int] = None, create_table: bool = False) -> MoVectorClient:
+ """
+ Create a new client for the collection.
+
+ The collection will be created if it doesn't exist.
+ """
+ lock_name = f"vector_indexing_lock_{self._collection_name}"
+ with redis_client.lock(lock_name, timeout=20):
+ client = MoVectorClient(
+ connection_string=f"mysql+pymysql://{self.config.user}:{self.config.password}@{self.config.host}:{self.config.port}/{self.config.database}",
+ table_name=self.collection_name,
+ vector_dimension=dimension,
+ create_table=create_table,
+ )
+ collection_exist_cache_key = f"vector_indexing_{self._collection_name}"
+ if redis_client.get(collection_exist_cache_key):
+ return client
+ try:
+ client.create_full_text_index()
+ except Exception as e:
+ logger.exception("Failed to create full text index")
+ redis_client.set(collection_exist_cache_key, 1, ex=3600)
+ return client
+
+ def add_texts(self, documents: list[Document], embeddings: list[list[float]], **kwargs):
+ if self.client is None:
+ self.client = self._get_client(len(embeddings[0]), True)
+ assert self.client is not None
+ ids = []
+ for _, doc in enumerate(documents):
+ if doc.metadata is not None:
+ doc_id = doc.metadata.get("doc_id", str(uuid.uuid4()))
+ ids.append(doc_id)
+ self.client.insert(
+ texts=[doc.page_content for doc in documents],
+ embeddings=embeddings,
+ metadatas=[doc.metadata for doc in documents],
+ ids=ids,
+ )
+ return ids
+
+ @ensure_client
+ def text_exists(self, id: str) -> bool:
+ assert self.client is not None
+ result = self.client.get(ids=[id])
+ return len(result) > 0
+
+ @ensure_client
+ def delete_by_ids(self, ids: list[str]) -> None:
+ assert self.client is not None
+ if not ids:
+ return
+ self.client.delete(ids=ids)
+
+ @ensure_client
+ def get_ids_by_metadata_field(self, key: str, value: str):
+ assert self.client is not None
+ results = self.client.query_by_metadata(filter={key: value})
+ return [result.id for result in results]
+
+ @ensure_client
+ def delete_by_metadata_field(self, key: str, value: str) -> None:
+ assert self.client is not None
+ self.client.delete(filter={key: value})
+
+ @ensure_client
+ def search_by_vector(self, query_vector: list[float], **kwargs: Any) -> list[Document]:
+ assert self.client is not None
+ top_k = kwargs.get("top_k", 5)
+ document_ids_filter = kwargs.get("document_ids_filter")
+ filter = None
+ if document_ids_filter:
+ filter = {"document_id": {"$in": document_ids_filter}}
+
+ results = self.client.query(
+ query_vector=query_vector,
+ k=top_k,
+ filter=filter,
+ )
+
+ docs = []
+ # TODO: add the score threshold to the query
+ for result in results:
+ metadata = result.metadata
+ docs.append(
+ Document(
+ page_content=result.document,
+ metadata=metadata,
+ )
+ )
+ return docs
+
+ @ensure_client
+ def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]:
+ assert self.client is not None
+ top_k = kwargs.get("top_k", 5)
+ document_ids_filter = kwargs.get("document_ids_filter")
+ filter = None
+ if document_ids_filter:
+ filter = {"document_id": {"$in": document_ids_filter}}
+ score_threshold = float(kwargs.get("score_threshold", 0.0))
+
+ results = self.client.full_text_query(
+ keywords=[query],
+ k=top_k,
+ filter=filter,
+ )
+
+ docs = []
+ for result in results:
+ metadata = result.metadata
+ if isinstance(metadata, str):
+ import json
+
+ metadata = json.loads(metadata)
+ score = 1 - result.distance
+ if score >= score_threshold:
+ metadata["score"] = score
+ docs.append(
+ Document(
+ page_content=result.document,
+ metadata=metadata,
+ )
+ )
+ return docs
+
+ @ensure_client
+ def delete(self) -> None:
+ assert self.client is not None
+ self.client.delete()
+
+
+class MatrixoneVectorFactory(AbstractVectorFactory):
+ def init_vector(self, dataset: Dataset, attributes: list, embeddings: Embeddings) -> MatrixoneVector:
+ if dataset.index_struct_dict:
+ class_prefix: str = dataset.index_struct_dict["vector_store"]["class_prefix"]
+ collection_name = class_prefix
+ else:
+ dataset_id = dataset.id
+ collection_name = Dataset.gen_collection_name_by_id(dataset_id)
+ dataset.index_struct = json.dumps(self.gen_index_struct_dict(VectorType.MATRIXONE, collection_name))
+
+ config = MatrixoneConfig(
+ host=dify_config.MATRIXONE_HOST or "localhost",
+ port=dify_config.MATRIXONE_PORT or 6001,
+ user=dify_config.MATRIXONE_USER or "dump",
+ password=dify_config.MATRIXONE_PASSWORD or "111",
+ database=dify_config.MATRIXONE_DATABASE or "dify",
+ metric=dify_config.MATRIXONE_METRIC or "l2",
+ )
+ return MatrixoneVector(collection_name=collection_name, config=config)
diff --git a/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py b/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py
index 2b47d179d2..dd196e1f09 100644
--- a/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py
+++ b/api/core/rag/datasource/vdb/oceanbase/oceanbase_vector.py
@@ -80,6 +80,23 @@ class OceanBaseVector(BaseVector):
self.delete()
+ vals = []
+ params = self._client.perform_raw_text_sql("SHOW PARAMETERS LIKE '%ob_vector_memory_limit_percentage%'")
+ for row in params:
+ val = int(row[6])
+ vals.append(val)
+ if len(vals) == 0:
+ raise ValueError("ob_vector_memory_limit_percentage not found in parameters.")
+ if any(val == 0 for val in vals):
+ try:
+ self._client.perform_raw_text_sql("ALTER SYSTEM SET ob_vector_memory_limit_percentage = 30")
+ except Exception as e:
+ raise Exception(
+ "Failed to set ob_vector_memory_limit_percentage. "
+ + "Maybe the database user has insufficient privilege.",
+ e,
+ )
+
cols = [
Column("id", String(36), primary_key=True, autoincrement=False),
Column("vector", VECTOR(self._vec_dim)),
@@ -110,22 +127,6 @@ class OceanBaseVector(BaseVector):
+ "to support fulltext index and vector index in the same table",
e,
)
- vals = []
- params = self._client.perform_raw_text_sql("SHOW PARAMETERS LIKE '%ob_vector_memory_limit_percentage%'")
- for row in params:
- val = int(row[6])
- vals.append(val)
- if len(vals) == 0:
- raise ValueError("ob_vector_memory_limit_percentage not found in parameters.")
- if any(val == 0 for val in vals):
- try:
- self._client.perform_raw_text_sql("ALTER SYSTEM SET ob_vector_memory_limit_percentage = 30")
- except Exception as e:
- raise Exception(
- "Failed to set ob_vector_memory_limit_percentage. "
- + "Maybe the database user has insufficient privilege.",
- e,
- )
redis_client.set(collection_exist_cache_key, 1, ex=3600)
def _check_hybrid_search_support(self) -> bool:
diff --git a/api/core/rag/datasource/vdb/qdrant/qdrant_vector.py b/api/core/rag/datasource/vdb/qdrant/qdrant_vector.py
index 8ce194c683..05fa73011a 100644
--- a/api/core/rag/datasource/vdb/qdrant/qdrant_vector.py
+++ b/api/core/rag/datasource/vdb/qdrant/qdrant_vector.py
@@ -47,6 +47,7 @@ class QdrantConfig(BaseModel):
grpc_port: int = 6334
prefer_grpc: bool = False
replication_factor: int = 1
+ write_consistency_factor: int = 1
def to_qdrant_params(self):
if self.endpoint and self.endpoint.startswith("path:"):
@@ -127,6 +128,7 @@ class QdrantVector(BaseVector):
hnsw_config=hnsw_config,
timeout=int(self._client_config.timeout),
replication_factor=self._client_config.replication_factor,
+ write_consistency_factor=self._client_config.write_consistency_factor,
)
# create group_id payload index
diff --git a/api/core/rag/datasource/vdb/tablestore/tablestore_vector.py b/api/core/rag/datasource/vdb/tablestore/tablestore_vector.py
index a124faa503..552068c99e 100644
--- a/api/core/rag/datasource/vdb/tablestore/tablestore_vector.py
+++ b/api/core/rag/datasource/vdb/tablestore/tablestore_vector.py
@@ -4,6 +4,7 @@ from typing import Any, Optional
import tablestore # type: ignore
from pydantic import BaseModel, model_validator
+from tablestore import BatchGetRowRequest, TableInBatchGetRowItem
from configs import dify_config
from core.rag.datasource.vdb.field import Field
@@ -50,6 +51,29 @@ class TableStoreVector(BaseVector):
self._index_name = f"{collection_name}_idx"
self._tags_field = f"{Field.METADATA_KEY.value}_tags"
+ def create_collection(self, embeddings: list[list[float]], **kwargs):
+ dimension = len(embeddings[0])
+ self._create_collection(dimension)
+
+ def get_by_ids(self, ids: list[str]) -> list[Document]:
+ docs = []
+ request = BatchGetRowRequest()
+ columns_to_get = [Field.METADATA_KEY.value, Field.CONTENT_KEY.value]
+ rows_to_get = [[("id", _id)] for _id in ids]
+ request.add(TableInBatchGetRowItem(self._table_name, rows_to_get, columns_to_get, None, 1))
+
+ result = self._tablestore_client.batch_get_row(request)
+ table_result = result.get_result_by_table(self._table_name)
+ for item in table_result:
+ if item.is_ok and item.row:
+ kv = {k: v for k, v, t in item.row.attribute_columns}
+ docs.append(
+ Document(
+ page_content=kv[Field.CONTENT_KEY.value], metadata=json.loads(kv[Field.METADATA_KEY.value])
+ )
+ )
+ return docs
+
def get_type(self) -> str:
return VectorType.TABLESTORE
diff --git a/api/core/rag/datasource/vdb/tencent/tencent_vector.py b/api/core/rag/datasource/vdb/tencent/tencent_vector.py
index d2bf3eb92a..75afe0cdb8 100644
--- a/api/core/rag/datasource/vdb/tencent/tencent_vector.py
+++ b/api/core/rag/datasource/vdb/tencent/tencent_vector.py
@@ -122,7 +122,6 @@ class TencentVector(BaseVector):
metric_type,
params,
)
- index_text = vdb_index.FilterIndex(self.field_text, enum.FieldType.String, enum.IndexType.FILTER)
index_metadate = vdb_index.FilterIndex(self.field_metadata, enum.FieldType.Json, enum.IndexType.FILTER)
index_sparse_vector = vdb_index.SparseIndex(
name="sparse_vector",
@@ -130,7 +129,7 @@ class TencentVector(BaseVector):
index_type=enum.IndexType.SPARSE_INVERTED,
metric_type=enum.MetricType.IP,
)
- indexes = [index_id, index_vector, index_text, index_metadate]
+ indexes = [index_id, index_vector, index_metadate]
if self._enable_hybrid_search:
indexes.append(index_sparse_vector)
try:
@@ -149,7 +148,7 @@ class TencentVector(BaseVector):
index_metadate = vdb_index.FilterIndex(
self.field_metadata, enum.FieldType.String, enum.IndexType.FILTER
)
- indexes = [index_id, index_vector, index_text, index_metadate]
+ indexes = [index_id, index_vector, index_metadate]
if self._enable_hybrid_search:
indexes.append(index_sparse_vector)
self._client.create_collection(
diff --git a/api/core/rag/datasource/vdb/vector_factory.py b/api/core/rag/datasource/vdb/vector_factory.py
index 66e002312a..00080b0fae 100644
--- a/api/core/rag/datasource/vdb/vector_factory.py
+++ b/api/core/rag/datasource/vdb/vector_factory.py
@@ -1,3 +1,5 @@
+import logging
+import time
from abc import ABC, abstractmethod
from typing import Any, Optional
@@ -13,6 +15,8 @@ from extensions.ext_database import db
from extensions.ext_redis import redis_client
from models.dataset import Dataset, Whitelist
+logger = logging.getLogger(__name__)
+
class AbstractVectorFactory(ABC):
@abstractmethod
@@ -164,13 +168,29 @@ class Vector:
from core.rag.datasource.vdb.huawei.huawei_cloud_vector import HuaweiCloudVectorFactory
return HuaweiCloudVectorFactory
+ case VectorType.MATRIXONE:
+ from core.rag.datasource.vdb.matrixone.matrixone_vector import MatrixoneVectorFactory
+
+ return MatrixoneVectorFactory
case _:
raise ValueError(f"Vector store {vector_type} is not supported.")
def create(self, texts: Optional[list] = None, **kwargs):
if texts:
- embeddings = self._embeddings.embed_documents([document.page_content for document in texts])
- self._vector_processor.create(texts=texts, embeddings=embeddings, **kwargs)
+ start = time.time()
+ logger.info(f"start embedding {len(texts)} texts {start}")
+ batch_size = 1000
+ total_batches = len(texts) + batch_size - 1
+ for i in range(0, len(texts), batch_size):
+ batch = texts[i : i + batch_size]
+ batch_start = time.time()
+ logger.info(f"Processing batch {i // batch_size + 1}/{total_batches} ({len(batch)} texts)")
+ batch_embeddings = self._embeddings.embed_documents([document.page_content for document in batch])
+ logger.info(
+ f"Embedding batch {i // batch_size + 1}/{total_batches} took {time.time() - batch_start:.3f}s"
+ )
+ self._vector_processor.create(texts=batch, embeddings=batch_embeddings, **kwargs)
+ logger.info(f"Embedding {len(texts)} texts took {time.time() - start:.3f}s")
def add_texts(self, documents: list[Document], **kwargs):
if kwargs.get("duplicate_check", False):
diff --git a/api/core/rag/datasource/vdb/vector_type.py b/api/core/rag/datasource/vdb/vector_type.py
index 7a81565e37..0d70947b72 100644
--- a/api/core/rag/datasource/vdb/vector_type.py
+++ b/api/core/rag/datasource/vdb/vector_type.py
@@ -29,3 +29,4 @@ class VectorType(StrEnum):
OPENGAUSS = "opengauss"
TABLESTORE = "tablestore"
HUAWEI_CLOUD = "huawei_cloud"
+ MATRIXONE = "matrixone"
diff --git a/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py b/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py
index 8fe6199517..7a8efb4068 100644
--- a/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py
+++ b/api/core/rag/datasource/vdb/weaviate/weaviate_vector.py
@@ -41,6 +41,13 @@ class WeaviateVector(BaseVector):
weaviate.connect.connection.has_grpc = False
+ # Fix to minimize the performance impact of the deprecation check in weaviate-client 3.24.0,
+ # by changing the connection timeout to pypi.org from 1 second to 0.001 seconds.
+ # TODO: This can be removed once weaviate-client is updated to 3.26.7 or higher,
+ # which does not contain the deprecation check.
+ if hasattr(weaviate.connect.connection, "PYPI_TIMEOUT"):
+ weaviate.connect.connection.PYPI_TIMEOUT = 0.001
+
try:
client = weaviate.Client(
url=config.endpoint, auth_client_secret=auth_config, timeout_config=(5, 60), startup_period=None
diff --git a/api/core/rag/extractor/firecrawl/firecrawl_app.py b/api/core/rag/extractor/firecrawl/firecrawl_app.py
index 836a1398bf..83a4ac651f 100644
--- a/api/core/rag/extractor/firecrawl/firecrawl_app.py
+++ b/api/core/rag/extractor/firecrawl/firecrawl_app.py
@@ -22,6 +22,7 @@ class FirecrawlApp:
"formats": ["markdown"],
"onlyMainContent": True,
"timeout": 30000,
+ "integration": "dify",
}
if params:
json_data.update(params)
@@ -39,7 +40,7 @@ class FirecrawlApp:
def crawl_url(self, url, params=None) -> str:
# Documentation: https://docs.firecrawl.dev/api-reference/endpoint/crawl-post
headers = self._prepare_headers()
- json_data = {"url": url}
+ json_data = {"url": url, "integration": "dify"}
if params:
json_data.update(params)
response = self._post_request(f"{self.base_url}/v1/crawl", json_data, headers)
@@ -49,7 +50,6 @@ class FirecrawlApp:
return cast(str, job_id)
else:
self._handle_error(response, "start crawl job")
- # FIXME: unreachable code for mypy
return "" # unreachable
def check_crawl_status(self, job_id) -> dict[str, Any]:
@@ -82,7 +82,6 @@ class FirecrawlApp:
)
else:
self._handle_error(response, "check crawl status")
- # FIXME: unreachable code for mypy
return {} # unreachable
def _format_crawl_status_response(
@@ -126,4 +125,31 @@ class FirecrawlApp:
def _handle_error(self, response, action) -> None:
error_message = response.json().get("error", "Unknown error occurred")
- raise Exception(f"Failed to {action}. Status code: {response.status_code}. Error: {error_message}")
+ raise Exception(f"Failed to {action}. Status code: {response.status_code}. Error: {error_message}") # type: ignore[return]
+
+ def search(self, query: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
+ # Documentation: https://docs.firecrawl.dev/api-reference/endpoint/search
+ headers = self._prepare_headers()
+ json_data = {
+ "query": query,
+ "limit": 5,
+ "lang": "en",
+ "country": "us",
+ "timeout": 60000,
+ "ignoreInvalidURLs": False,
+ "scrapeOptions": {},
+ "integration": "dify",
+ }
+ if params:
+ json_data.update(params)
+ response = self._post_request(f"{self.base_url}/v1/search", json_data, headers)
+ if response.status_code == 200:
+ response_data = response.json()
+ if not response_data.get("success"):
+ raise Exception(f"Search failed. Error: {response_data.get('warning', 'Unknown error')}")
+ return cast(dict[str, Any], response_data)
+ elif response.status_code in {402, 409, 500, 429, 408}:
+ self._handle_error(response, "perform search")
+ return {} # Avoid additional exception after handling error
+ else:
+ raise Exception(f"Failed to perform search. Status code: {response.status_code}")
diff --git a/api/core/rag/extractor/helpers.py b/api/core/rag/extractor/helpers.py
index 69ca9d5d63..3d2fb55d9a 100644
--- a/api/core/rag/extractor/helpers.py
+++ b/api/core/rag/extractor/helpers.py
@@ -1,7 +1,6 @@
"""Document loader helpers."""
import concurrent.futures
-from pathlib import Path
from typing import NamedTuple, Optional, cast
@@ -16,7 +15,7 @@ class FileEncoding(NamedTuple):
"""The language of the file."""
-def detect_file_encodings(file_path: str, timeout: int = 5) -> list[FileEncoding]:
+def detect_file_encodings(file_path: str, timeout: int = 5, sample_size: int = 1024 * 1024) -> list[FileEncoding]:
"""Try to detect the file encoding.
Returns a list of `FileEncoding` tuples with the detected encodings ordered
@@ -25,11 +24,16 @@ def detect_file_encodings(file_path: str, timeout: int = 5) -> list[FileEncoding
Args:
file_path: The path to the file to detect the encoding for.
timeout: The timeout in seconds for the encoding detection.
+ sample_size: The number of bytes to read for encoding detection. Default is 1MB.
+ For large files, reading only a sample is sufficient and prevents timeout.
"""
import chardet
def read_and_detect(file_path: str) -> list[dict]:
- rawdata = Path(file_path).read_bytes()
+ with open(file_path, "rb") as f:
+ # Read only a sample of the file for encoding detection
+ # This prevents timeout on large files while still providing accurate encoding detection
+ rawdata = f.read(sample_size)
return cast(list[dict], chardet.detect_all(rawdata))
with concurrent.futures.ThreadPoolExecutor() as executor:
diff --git a/api/core/rag/extractor/markdown_extractor.py b/api/core/rag/extractor/markdown_extractor.py
index 849852ac23..c97765b1dc 100644
--- a/api/core/rag/extractor/markdown_extractor.py
+++ b/api/core/rag/extractor/markdown_extractor.py
@@ -68,22 +68,17 @@ class MarkdownExtractor(BaseExtractor):
continue
header_match = re.match(r"^#+\s", line)
if header_match:
- if current_header is not None:
- markdown_tups.append((current_header, current_text))
-
+ markdown_tups.append((current_header, current_text))
current_header = line
current_text = ""
else:
current_text += line + "\n"
markdown_tups.append((current_header, current_text))
- if current_header is not None:
- # pass linting, assert keys are defined
- markdown_tups = [
- (re.sub(r"#", "", cast(str, key)).strip(), re.sub(r"<.*?>", "", value)) for key, value in markdown_tups
- ]
- else:
- markdown_tups = [(key, re.sub("\n", "", value)) for key, value in markdown_tups]
+ markdown_tups = [
+ (re.sub(r"#", "", cast(str, key)).strip() if key else None, re.sub(r"<.*?>", "", value))
+ for key, value in markdown_tups
+ ]
return markdown_tups
diff --git a/api/core/rag/extractor/notion_extractor.py b/api/core/rag/extractor/notion_extractor.py
index 4e14800d0a..eca955ddd1 100644
--- a/api/core/rag/extractor/notion_extractor.py
+++ b/api/core/rag/extractor/notion_extractor.py
@@ -79,55 +79,71 @@ class NotionExtractor(BaseExtractor):
def _get_notion_database_data(self, database_id: str, query_dict: dict[str, Any] = {}) -> list[Document]:
"""Get all the pages from a Notion database."""
assert self._notion_access_token is not None, "Notion access token is required"
- res = requests.post(
- DATABASE_URL_TMPL.format(database_id=database_id),
- headers={
- "Authorization": "Bearer " + self._notion_access_token,
- "Content-Type": "application/json",
- "Notion-Version": "2022-06-28",
- },
- json=query_dict,
- )
-
- data = res.json()
database_content = []
- if "results" not in data or data["results"] is None:
- return []
- for result in data["results"]:
- properties = result["properties"]
- data = {}
- value: Any
- for property_name, property_value in properties.items():
- type = property_value["type"]
- if type == "multi_select":
- value = []
- multi_select_list = property_value[type]
- for multi_select in multi_select_list:
- value.append(multi_select["name"])
- elif type in {"rich_text", "title"}:
- if len(property_value[type]) > 0:
- value = property_value[type][0]["plain_text"]
+ next_cursor = None
+ has_more = True
+
+ while has_more:
+ current_query = query_dict.copy()
+ if next_cursor:
+ current_query["start_cursor"] = next_cursor
+
+ res = requests.post(
+ DATABASE_URL_TMPL.format(database_id=database_id),
+ headers={
+ "Authorization": "Bearer " + self._notion_access_token,
+ "Content-Type": "application/json",
+ "Notion-Version": "2022-06-28",
+ },
+ json=current_query,
+ )
+
+ response_data = res.json()
+
+ if "results" not in response_data or response_data["results"] is None:
+ break
+
+ for result in response_data["results"]:
+ properties = result["properties"]
+ data = {}
+ value: Any
+ for property_name, property_value in properties.items():
+ type = property_value["type"]
+ if type == "multi_select":
+ value = []
+ multi_select_list = property_value[type]
+ for multi_select in multi_select_list:
+ value.append(multi_select["name"])
+ elif type in {"rich_text", "title"}:
+ if len(property_value[type]) > 0:
+ value = property_value[type][0]["plain_text"]
+ else:
+ value = ""
+ elif type in {"select", "status"}:
+ if property_value[type]:
+ value = property_value[type]["name"]
+ else:
+ value = ""
else:
- value = ""
- elif type in {"select", "status"}:
- if property_value[type]:
- value = property_value[type]["name"]
+ value = property_value[type]
+ data[property_name] = value
+ row_dict = {k: v for k, v in data.items() if v}
+ row_content = ""
+ for key, value in row_dict.items():
+ if isinstance(value, dict):
+ value_dict = {k: v for k, v in value.items() if v}
+ value_content = "".join(f"{k}:{v} " for k, v in value_dict.items())
+ row_content = row_content + f"{key}:{value_content}\n"
else:
- value = ""
- else:
- value = property_value[type]
- data[property_name] = value
- row_dict = {k: v for k, v in data.items() if v}
- row_content = ""
- for key, value in row_dict.items():
- if isinstance(value, dict):
- value_dict = {k: v for k, v in value.items() if v}
- value_content = "".join(f"{k}:{v} " for k, v in value_dict.items())
- row_content = row_content + f"{key}:{value_content}\n"
- else:
- row_content = row_content + f"{key}:{value}\n"
- database_content.append(row_content)
+ row_content = row_content + f"{key}:{value}\n"
+ database_content.append(row_content)
+
+ has_more = response_data.get("has_more", False)
+ next_cursor = response_data.get("next_cursor")
+
+ if not database_content:
+ return []
return [Document(page_content="\n".join(database_content))]
diff --git a/api/core/rag/extractor/text_extractor.py b/api/core/rag/extractor/text_extractor.py
index b2b51d71d7..a00d328cb1 100644
--- a/api/core/rag/extractor/text_extractor.py
+++ b/api/core/rag/extractor/text_extractor.py
@@ -36,8 +36,12 @@ class TextExtractor(BaseExtractor):
break
except UnicodeDecodeError:
continue
+ else:
+ raise RuntimeError(
+ f"Decode failed: {self._file_path}, all detected encodings failed. Original error: {e}"
+ )
else:
- raise RuntimeError(f"Error loading {self._file_path}") from e
+ raise RuntimeError(f"Decode failed: {self._file_path}, specified encoding failed. Original error: {e}")
except Exception as e:
raise RuntimeError(f"Error loading {self._file_path}") from e
diff --git a/api/core/rag/index_processor/processor/paragraph_index_processor.py b/api/core/rag/index_processor/processor/paragraph_index_processor.py
index dca84b9041..9b90bd2bb3 100644
--- a/api/core/rag/index_processor/processor/paragraph_index_processor.py
+++ b/api/core/rag/index_processor/processor/paragraph_index_processor.py
@@ -76,6 +76,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
if dataset.indexing_technique == "high_quality":
vector = Vector(dataset)
vector.create(documents)
+ with_keywords = False
if with_keywords:
keywords_list = kwargs.get("keywords_list")
keyword = Keyword(dataset)
@@ -91,6 +92,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
vector.delete_by_ids(node_ids)
else:
vector.delete()
+ with_keywords = False
if with_keywords:
keyword = Keyword(dataset)
if node_ids:
diff --git a/api/core/rag/index_processor/processor/qa_index_processor.py b/api/core/rag/index_processor/processor/qa_index_processor.py
index e778b2cec4..75f3153697 100644
--- a/api/core/rag/index_processor/processor/qa_index_processor.py
+++ b/api/core/rag/index_processor/processor/qa_index_processor.py
@@ -104,7 +104,7 @@ class QAIndexProcessor(BaseIndexProcessor):
def format_by_template(self, file: FileStorage, **kwargs) -> list[Document]:
# check file type
- if not file.filename or not file.filename.endswith(".csv"):
+ if not file.filename or not file.filename.lower().endswith(".csv"):
raise ValueError("Invalid file type. Only CSV files are allowed")
try:
diff --git a/api/core/rag/retrieval/dataset_retrieval.py b/api/core/rag/retrieval/dataset_retrieval.py
index 6978860529..5c0360b064 100644
--- a/api/core/rag/retrieval/dataset_retrieval.py
+++ b/api/core/rag/retrieval/dataset_retrieval.py
@@ -9,6 +9,7 @@ from typing import Any, Optional, Union, cast
from flask import Flask, current_app
from sqlalchemy import Float, and_, or_, text
from sqlalchemy import cast as sqlalchemy_cast
+from sqlalchemy.orm import Session
from core.app.app_config.entities import (
DatasetEntity,
@@ -496,6 +497,8 @@ class DatasetRetrieval:
all_documents = self.calculate_keyword_score(query, all_documents, top_k)
elif index_type == "high_quality":
all_documents = self.calculate_vector_score(all_documents, top_k, score_threshold)
+ else:
+ all_documents = all_documents[:top_k] if top_k else all_documents
self._on_query(query, dataset_ids, app_id, user_from, user_id)
@@ -596,7 +599,8 @@ class DatasetRetrieval:
metadata_condition: Optional[MetadataCondition] = None,
):
with flask_app.app_context():
- dataset = db.session.query(Dataset).filter(Dataset.id == dataset_id).first()
+ with Session(db.engine) as session:
+ dataset = session.query(Dataset).filter(Dataset.id == dataset_id).first()
if not dataset:
return []
@@ -1008,6 +1012,9 @@ class DatasetRetrieval:
def _process_metadata_filter_func(
self, sequence: int, condition: str, metadata_name: str, value: Optional[Any], filters: list
):
+ if value is None:
+ return
+
key = f"{metadata_name}_{sequence}"
key_value = f"{metadata_name}_{sequence}_value"
match condition:
diff --git a/api/core/repositories/__init__.py b/api/core/repositories/__init__.py
index 6452317120..052ba1c2cb 100644
--- a/api/core/repositories/__init__.py
+++ b/api/core/repositories/__init__.py
@@ -5,8 +5,11 @@ This package contains concrete implementations of the repository interfaces
defined in the core.workflow.repository package.
"""
+from core.repositories.factory import DifyCoreRepositoryFactory, RepositoryImportError
from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
__all__ = [
+ "DifyCoreRepositoryFactory",
+ "RepositoryImportError",
"SQLAlchemyWorkflowNodeExecutionRepository",
]
diff --git a/api/core/repositories/factory.py b/api/core/repositories/factory.py
new file mode 100644
index 0000000000..4118aa61c7
--- /dev/null
+++ b/api/core/repositories/factory.py
@@ -0,0 +1,224 @@
+"""
+Repository factory for dynamically creating repository instances based on configuration.
+
+This module provides a Django-like settings system for repository implementations,
+allowing users to configure different repository backends through string paths.
+"""
+
+import importlib
+import inspect
+import logging
+from typing import Protocol, Union
+
+from sqlalchemy.engine import Engine
+from sqlalchemy.orm import sessionmaker
+
+from configs import dify_config
+from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository
+from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository
+from models import Account, EndUser
+from models.enums import WorkflowRunTriggeredFrom
+from models.workflow import WorkflowNodeExecutionTriggeredFrom
+
+logger = logging.getLogger(__name__)
+
+
+class RepositoryImportError(Exception):
+ """Raised when a repository implementation cannot be imported or instantiated."""
+
+ pass
+
+
+class DifyCoreRepositoryFactory:
+ """
+ Factory for creating repository instances based on configuration.
+
+ This factory supports Django-like settings where repository implementations
+ are specified as module paths (e.g., 'module.submodule.ClassName').
+ """
+
+ @staticmethod
+ def _import_class(class_path: str) -> type:
+ """
+ Import a class from a module path string.
+
+ Args:
+ class_path: Full module path to the class (e.g., 'module.submodule.ClassName')
+
+ Returns:
+ The imported class
+
+ Raises:
+ RepositoryImportError: If the class cannot be imported
+ """
+ try:
+ module_path, class_name = class_path.rsplit(".", 1)
+ module = importlib.import_module(module_path)
+ repo_class = getattr(module, class_name)
+ assert isinstance(repo_class, type)
+ return repo_class
+ except (ValueError, ImportError, AttributeError) as e:
+ raise RepositoryImportError(f"Cannot import repository class '{class_path}': {e}") from e
+
+ @staticmethod
+ def _validate_repository_interface(repository_class: type, expected_interface: type[Protocol]) -> None: # type: ignore
+ """
+ Validate that a class implements the expected repository interface.
+
+ Args:
+ repository_class: The class to validate
+ expected_interface: The expected interface/protocol
+
+ Raises:
+ RepositoryImportError: If the class doesn't implement the interface
+ """
+ # Check if the class has all required methods from the protocol
+ required_methods = [
+ method
+ for method in dir(expected_interface)
+ if not method.startswith("_") and callable(getattr(expected_interface, method, None))
+ ]
+
+ missing_methods = []
+ for method_name in required_methods:
+ if not hasattr(repository_class, method_name):
+ missing_methods.append(method_name)
+
+ if missing_methods:
+ raise RepositoryImportError(
+ f"Repository class '{repository_class.__name__}' does not implement required methods "
+ f"{missing_methods} from interface '{expected_interface.__name__}'"
+ )
+
+ @staticmethod
+ def _validate_constructor_signature(repository_class: type, required_params: list[str]) -> None:
+ """
+ Validate that a repository class constructor accepts required parameters.
+
+ Args:
+ repository_class: The class to validate
+ required_params: List of required parameter names
+
+ Raises:
+ RepositoryImportError: If the constructor doesn't accept required parameters
+ """
+
+ try:
+ # MyPy may flag the line below with the following error:
+ #
+ # > Accessing "__init__" on an instance is unsound, since
+ # > instance.__init__ could be from an incompatible subclass.
+ #
+ # Despite this, we need to ensure that the constructor of `repository_class`
+ # has a compatible signature.
+ signature = inspect.signature(repository_class.__init__) # type: ignore[misc]
+ param_names = list(signature.parameters.keys())
+
+ # Remove 'self' parameter
+ if "self" in param_names:
+ param_names.remove("self")
+
+ missing_params = [param for param in required_params if param not in param_names]
+ if missing_params:
+ raise RepositoryImportError(
+ f"Repository class '{repository_class.__name__}' constructor does not accept required parameters: "
+ f"{missing_params}. Expected parameters: {required_params}"
+ )
+ except Exception as e:
+ raise RepositoryImportError(
+ f"Failed to validate constructor signature for '{repository_class.__name__}': {e}"
+ ) from e
+
+ @classmethod
+ def create_workflow_execution_repository(
+ cls,
+ session_factory: Union[sessionmaker, Engine],
+ user: Union[Account, EndUser],
+ app_id: str,
+ triggered_from: WorkflowRunTriggeredFrom,
+ ) -> WorkflowExecutionRepository:
+ """
+ Create a WorkflowExecutionRepository instance based on configuration.
+
+ Args:
+ session_factory: SQLAlchemy sessionmaker or engine
+ user: Account or EndUser object
+ app_id: Application ID
+ triggered_from: Source of the execution trigger
+
+ Returns:
+ Configured WorkflowExecutionRepository instance
+
+ Raises:
+ RepositoryImportError: If the configured repository cannot be created
+ """
+ class_path = dify_config.CORE_WORKFLOW_EXECUTION_REPOSITORY
+ logger.debug(f"Creating WorkflowExecutionRepository from: {class_path}")
+
+ try:
+ repository_class = cls._import_class(class_path)
+ cls._validate_repository_interface(repository_class, WorkflowExecutionRepository)
+ cls._validate_constructor_signature(
+ repository_class, ["session_factory", "user", "app_id", "triggered_from"]
+ )
+
+ return repository_class( # type: ignore[no-any-return]
+ session_factory=session_factory,
+ user=user,
+ app_id=app_id,
+ triggered_from=triggered_from,
+ )
+ except RepositoryImportError:
+ # Re-raise our custom errors as-is
+ raise
+ except Exception as e:
+ logger.exception("Failed to create WorkflowExecutionRepository")
+ raise RepositoryImportError(f"Failed to create WorkflowExecutionRepository from '{class_path}': {e}") from e
+
+ @classmethod
+ def create_workflow_node_execution_repository(
+ cls,
+ session_factory: Union[sessionmaker, Engine],
+ user: Union[Account, EndUser],
+ app_id: str,
+ triggered_from: WorkflowNodeExecutionTriggeredFrom,
+ ) -> WorkflowNodeExecutionRepository:
+ """
+ Create a WorkflowNodeExecutionRepository instance based on configuration.
+
+ Args:
+ session_factory: SQLAlchemy sessionmaker or engine
+ user: Account or EndUser object
+ app_id: Application ID
+ triggered_from: Source of the execution trigger
+
+ Returns:
+ Configured WorkflowNodeExecutionRepository instance
+
+ Raises:
+ RepositoryImportError: If the configured repository cannot be created
+ """
+ class_path = dify_config.CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY
+ logger.debug(f"Creating WorkflowNodeExecutionRepository from: {class_path}")
+
+ try:
+ repository_class = cls._import_class(class_path)
+ cls._validate_repository_interface(repository_class, WorkflowNodeExecutionRepository)
+ cls._validate_constructor_signature(
+ repository_class, ["session_factory", "user", "app_id", "triggered_from"]
+ )
+
+ return repository_class( # type: ignore[no-any-return]
+ session_factory=session_factory,
+ user=user,
+ app_id=app_id,
+ triggered_from=triggered_from,
+ )
+ except RepositoryImportError:
+ # Re-raise our custom errors as-is
+ raise
+ except Exception as e:
+ logger.exception("Failed to create WorkflowNodeExecutionRepository")
+ raise RepositoryImportError(
+ f"Failed to create WorkflowNodeExecutionRepository from '{class_path}': {e}"
+ ) from e
diff --git a/api/core/repositories/sqlalchemy_workflow_execution_repository.py b/api/core/repositories/sqlalchemy_workflow_execution_repository.py
index 19086cffff..0b3e5eb424 100644
--- a/api/core/repositories/sqlalchemy_workflow_execution_repository.py
+++ b/api/core/repositories/sqlalchemy_workflow_execution_repository.py
@@ -16,6 +16,8 @@ from core.workflow.entities.workflow_execution import (
WorkflowType,
)
from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository
+from core.workflow.workflow_type_encoder import WorkflowRuntimeTypeConverter
+from libs.helper import extract_tenant_id
from models import (
Account,
CreatorUserRole,
@@ -66,7 +68,7 @@ class SQLAlchemyWorkflowExecutionRepository(WorkflowExecutionRepository):
)
# Extract tenant_id from user
- tenant_id: str | None = user.tenant_id if isinstance(user, EndUser) else user.current_tenant_id
+ tenant_id = extract_tenant_id(user)
if not tenant_id:
raise ValueError("User must have a tenant_id or current_tenant_id")
self._tenant_id = tenant_id
@@ -146,26 +148,17 @@ class SQLAlchemyWorkflowExecutionRepository(WorkflowExecutionRepository):
db_model.workflow_id = domain_model.workflow_id
db_model.triggered_from = self._triggered_from
- # Check if this is a new record
- with self._session_factory() as session:
- existing = session.scalar(select(WorkflowRun).where(WorkflowRun.id == domain_model.id_))
- if not existing:
- # For new records, get the next sequence number
- stmt = select(WorkflowRun.sequence_number).where(
- WorkflowRun.app_id == self._app_id,
- WorkflowRun.tenant_id == self._tenant_id,
- )
- max_sequence = session.scalar(stmt.order_by(WorkflowRun.sequence_number.desc()))
- db_model.sequence_number = (max_sequence or 0) + 1
- else:
- # For updates, keep the existing sequence number
- db_model.sequence_number = existing.sequence_number
+ # No sequence number generation needed anymore
db_model.type = domain_model.workflow_type
db_model.version = domain_model.workflow_version
db_model.graph = json.dumps(domain_model.graph) if domain_model.graph else None
db_model.inputs = json.dumps(domain_model.inputs) if domain_model.inputs else None
- db_model.outputs = json.dumps(domain_model.outputs) if domain_model.outputs else None
+ db_model.outputs = (
+ json.dumps(WorkflowRuntimeTypeConverter().to_json_encodable(domain_model.outputs))
+ if domain_model.outputs
+ else None
+ )
db_model.status = domain_model.status
db_model.error = domain_model.error_message if domain_model.error_message else None
db_model.total_tokens = domain_model.total_tokens
diff --git a/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py b/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py
index 2f27442616..a5feeb0d7c 100644
--- a/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py
+++ b/api/core/repositories/sqlalchemy_workflow_node_execution_repository.py
@@ -19,6 +19,8 @@ from core.workflow.entities.workflow_node_execution import (
)
from core.workflow.nodes.enums import NodeType
from core.workflow.repositories.workflow_node_execution_repository import OrderConfig, WorkflowNodeExecutionRepository
+from core.workflow.workflow_type_encoder import WorkflowRuntimeTypeConverter
+from libs.helper import extract_tenant_id
from models import (
Account,
CreatorUserRole,
@@ -69,7 +71,7 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository)
)
# Extract tenant_id from user
- tenant_id: str | None = user.tenant_id if isinstance(user, EndUser) else user.current_tenant_id
+ tenant_id = extract_tenant_id(user)
if not tenant_id:
raise ValueError("User must have a tenant_id or current_tenant_id")
self._tenant_id = tenant_id
@@ -146,6 +148,7 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository)
if not self._creator_user_role:
raise ValueError("created_by_role is required in repository constructor")
+ json_converter = WorkflowRuntimeTypeConverter()
db_model = WorkflowNodeExecutionModel()
db_model.id = domain_model.id
db_model.tenant_id = self._tenant_id
@@ -160,9 +163,17 @@ class SQLAlchemyWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository)
db_model.node_id = domain_model.node_id
db_model.node_type = domain_model.node_type
db_model.title = domain_model.title
- db_model.inputs = json.dumps(domain_model.inputs) if domain_model.inputs else None
- db_model.process_data = json.dumps(domain_model.process_data) if domain_model.process_data else None
- db_model.outputs = json.dumps(domain_model.outputs) if domain_model.outputs else None
+ db_model.inputs = (
+ json.dumps(json_converter.to_json_encodable(domain_model.inputs)) if domain_model.inputs else None
+ )
+ db_model.process_data = (
+ json.dumps(json_converter.to_json_encodable(domain_model.process_data))
+ if domain_model.process_data
+ else None
+ )
+ db_model.outputs = (
+ json.dumps(json_converter.to_json_encodable(domain_model.outputs)) if domain_model.outputs else None
+ )
db_model.status = domain_model.status
db_model.error = domain_model.error
db_model.elapsed_time = domain_model.elapsed_time
diff --git a/api/core/tools/builtin_tool/providers/audio/tools/tts.py b/api/core/tools/builtin_tool/providers/audio/tools/tts.py
index 9b104b00f5..f191968812 100644
--- a/api/core/tools/builtin_tool/providers/audio/tools/tts.py
+++ b/api/core/tools/builtin_tool/providers/audio/tools/tts.py
@@ -31,6 +31,14 @@ class TTSTool(BuiltinTool):
model_type=ModelType.TTS,
model=model,
)
+ if not voice:
+ voices = model_instance.get_tts_voices()
+ if voices:
+ voice = voices[0].get("value")
+ if not voice:
+ raise ValueError("Sorry, no voice available.")
+ else:
+ raise ValueError("Sorry, no voice available.")
tts = model_instance.invoke_tts(
content_text=tool_parameters.get("text"), # type: ignore
user=user_id,
diff --git a/api/core/tools/builtin_tool/providers/code/tools/simple_code.py b/api/core/tools/builtin_tool/providers/code/tools/simple_code.py
index ab0e155b98..b4e650e0ed 100644
--- a/api/core/tools/builtin_tool/providers/code/tools/simple_code.py
+++ b/api/core/tools/builtin_tool/providers/code/tools/simple_code.py
@@ -4,6 +4,7 @@ from typing import Any, Optional
from core.helper.code_executor.code_executor import CodeExecutor, CodeLanguage
from core.tools.builtin_tool.tool import BuiltinTool
from core.tools.entities.tool_entities import ToolInvokeMessage
+from core.tools.errors import ToolInvokeError
class SimpleCode(BuiltinTool):
@@ -25,6 +26,8 @@ class SimpleCode(BuiltinTool):
if language not in {CodeLanguage.PYTHON3, CodeLanguage.JAVASCRIPT}:
raise ValueError(f"Only python3 and javascript are supported, not {language}")
- result = CodeExecutor.execute_code(language, "", code)
-
- yield self.create_text_message(result)
+ try:
+ result = CodeExecutor.execute_code(language, "", code)
+ yield self.create_text_message(result)
+ except Exception as e:
+ raise ToolInvokeError(str(e))
diff --git a/api/core/tools/custom_tool/provider.py b/api/core/tools/custom_tool/provider.py
index 3137d32013..fbe1d79137 100644
--- a/api/core/tools/custom_tool/provider.py
+++ b/api/core/tools/custom_tool/provider.py
@@ -39,19 +39,22 @@ class ApiToolProviderController(ToolProviderController):
type=ProviderConfig.Type.SELECT,
options=[
ProviderConfig.Option(value="none", label=I18nObject(en_US="None", zh_Hans="无")),
- ProviderConfig.Option(value="api_key", label=I18nObject(en_US="api_key", zh_Hans="api_key")),
+ ProviderConfig.Option(value="api_key_header", label=I18nObject(en_US="Header", zh_Hans="请求头")),
+ ProviderConfig.Option(
+ value="api_key_query", label=I18nObject(en_US="Query Param", zh_Hans="查询参数")
+ ),
],
default="none",
help=I18nObject(en_US="The auth type of the api provider", zh_Hans="api provider 的认证类型"),
)
]
- if auth_type == ApiProviderAuthType.API_KEY:
+ if auth_type == ApiProviderAuthType.API_KEY_HEADER:
credentials_schema = [
*credentials_schema,
ProviderConfig(
name="api_key_header",
required=False,
- default="api_key",
+ default="Authorization",
type=ProviderConfig.Type.TEXT_INPUT,
help=I18nObject(en_US="The header name of the api key", zh_Hans="携带 api key 的 header 名称"),
),
@@ -74,6 +77,25 @@ class ApiToolProviderController(ToolProviderController):
],
),
]
+ elif auth_type == ApiProviderAuthType.API_KEY_QUERY:
+ credentials_schema = [
+ *credentials_schema,
+ ProviderConfig(
+ name="api_key_query_param",
+ required=False,
+ default="key",
+ type=ProviderConfig.Type.TEXT_INPUT,
+ help=I18nObject(
+ en_US="The query parameter name of the api key", zh_Hans="携带 api key 的查询参数名称"
+ ),
+ ),
+ ProviderConfig(
+ name="api_key_value",
+ required=True,
+ type=ProviderConfig.Type.SECRET_INPUT,
+ help=I18nObject(en_US="The api key", zh_Hans="api key 的值"),
+ ),
+ ]
elif auth_type == ApiProviderAuthType.NONE:
pass
diff --git a/api/core/tools/custom_tool/tool.py b/api/core/tools/custom_tool/tool.py
index 2f5cc6d4c0..10653b9948 100644
--- a/api/core/tools/custom_tool/tool.py
+++ b/api/core/tools/custom_tool/tool.py
@@ -78,8 +78,8 @@ class ApiTool(Tool):
if "auth_type" not in credentials:
raise ToolProviderCredentialValidationError("Missing auth_type")
- if credentials["auth_type"] == "api_key":
- api_key_header = "api_key"
+ if credentials["auth_type"] in ("api_key_header", "api_key"): # backward compatibility:
+ api_key_header = "Authorization"
if "api_key_header" in credentials:
api_key_header = credentials["api_key_header"]
@@ -100,6 +100,11 @@ class ApiTool(Tool):
headers[api_key_header] = credentials["api_key_value"]
+ elif credentials["auth_type"] == "api_key_query":
+ # For query parameter authentication, we don't add anything to headers
+ # The query parameter will be added in do_http_request method
+ pass
+
needed_parameters = [parameter for parameter in (self.api_bundle.parameters or []) if parameter.required]
for parameter in needed_parameters:
if parameter.required and parameter.name not in parameters:
@@ -154,6 +159,15 @@ class ApiTool(Tool):
cookies = {}
files = []
+ # Add API key to query parameters if auth_type is api_key_query
+ if self.runtime and self.runtime.credentials:
+ credentials = self.runtime.credentials
+ if credentials.get("auth_type") == "api_key_query":
+ api_key_query_param = credentials.get("api_key_query_param", "key")
+ api_key_value = credentials.get("api_key_value")
+ if api_key_value:
+ params[api_key_query_param] = api_key_value
+
# check parameters
for parameter in self.api_bundle.openapi.get("parameters", []):
value = self.get_parameter_value(parameter, parameters)
@@ -213,7 +227,8 @@ class ApiTool(Tool):
elif "default" in property:
body[name] = property["default"]
else:
- body[name] = None
+ # omit optional parameters that weren't provided, instead of setting them to None
+ pass
break
# replace path parameters
diff --git a/api/core/tools/entities/api_entities.py b/api/core/tools/entities/api_entities.py
index b96c994cff..90134ba71d 100644
--- a/api/core/tools/entities/api_entities.py
+++ b/api/core/tools/entities/api_entities.py
@@ -1,4 +1,5 @@
-from typing import Literal, Optional
+from datetime import datetime
+from typing import Any, Literal, Optional
from pydantic import BaseModel, Field, field_validator
@@ -18,7 +19,7 @@ class ToolApiEntity(BaseModel):
output_schema: Optional[dict] = None
-ToolProviderTypeApiLiteral = Optional[Literal["builtin", "api", "workflow"]]
+ToolProviderTypeApiLiteral = Optional[Literal["builtin", "api", "workflow", "mcp"]]
class ToolProviderApiEntity(BaseModel):
@@ -27,6 +28,7 @@ class ToolProviderApiEntity(BaseModel):
name: str # identifier
description: I18nObject
icon: str | dict
+ icon_dark: Optional[str | dict] = Field(default=None, description="The dark icon of the tool")
label: I18nObject # label
type: ToolProviderType
masked_credentials: Optional[dict] = None
@@ -37,6 +39,10 @@ class ToolProviderApiEntity(BaseModel):
plugin_unique_identifier: Optional[str] = Field(default="", description="The unique identifier of the tool")
tools: list[ToolApiEntity] = Field(default_factory=list)
labels: list[str] = Field(default_factory=list)
+ # MCP
+ server_url: Optional[str] = Field(default="", description="The server url of the tool")
+ updated_at: int = Field(default_factory=lambda: int(datetime.now().timestamp()))
+ server_identifier: Optional[str] = Field(default="", description="The server identifier of the MCP tool")
@field_validator("tools", mode="before")
@classmethod
@@ -52,8 +58,13 @@ class ToolProviderApiEntity(BaseModel):
for parameter in tool.get("parameters"):
if parameter.get("type") == ToolParameter.ToolParameterType.SYSTEM_FILES.value:
parameter["type"] = "files"
+ if parameter.get("input_schema") is None:
+ parameter.pop("input_schema", None)
# -------------
-
+ optional_fields = self.optional_field("server_url", self.server_url)
+ if self.type == ToolProviderType.MCP.value:
+ optional_fields.update(self.optional_field("updated_at", self.updated_at))
+ optional_fields.update(self.optional_field("server_identifier", self.server_identifier))
return {
"id": self.id,
"author": self.author,
@@ -62,6 +73,7 @@ class ToolProviderApiEntity(BaseModel):
"plugin_unique_identifier": self.plugin_unique_identifier,
"description": self.description.to_dict(),
"icon": self.icon,
+ "icon_dark": self.icon_dark,
"label": self.label.to_dict(),
"type": self.type.value,
"team_credentials": self.masked_credentials,
@@ -69,4 +81,9 @@ class ToolProviderApiEntity(BaseModel):
"allow_delete": self.allow_delete,
"tools": tools,
"labels": self.labels,
+ **optional_fields,
}
+
+ def optional_field(self, key: str, value: Any) -> dict:
+ """Return dict with key-value if value is truthy, empty dict otherwise."""
+ return {key: value} if value else {}
diff --git a/api/core/tools/entities/tool_entities.py b/api/core/tools/entities/tool_entities.py
index 03047c0545..b5148e245f 100644
--- a/api/core/tools/entities/tool_entities.py
+++ b/api/core/tools/entities/tool_entities.py
@@ -8,6 +8,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_seriali
from core.entities.provider_entities import ProviderConfig
from core.plugin.entities.parameters import (
+ MCPServerParameterType,
PluginParameter,
PluginParameterOption,
PluginParameterType,
@@ -49,6 +50,7 @@ class ToolProviderType(enum.StrEnum):
API = "api"
APP = "app"
DATASET_RETRIEVAL = "dataset-retrieval"
+ MCP = "mcp"
@classmethod
def value_of(cls, value: str) -> "ToolProviderType":
@@ -94,7 +96,8 @@ class ApiProviderAuthType(Enum):
"""
NONE = "none"
- API_KEY = "api_key"
+ API_KEY_HEADER = "api_key_header"
+ API_KEY_QUERY = "api_key_query"
@classmethod
def value_of(cls, value: str) -> "ApiProviderAuthType":
@@ -240,6 +243,11 @@ class ToolParameter(PluginParameter):
FILES = PluginParameterType.FILES.value
APP_SELECTOR = PluginParameterType.APP_SELECTOR.value
MODEL_SELECTOR = PluginParameterType.MODEL_SELECTOR.value
+ DYNAMIC_SELECT = PluginParameterType.DYNAMIC_SELECT.value
+
+ # MCP object and array type parameters
+ ARRAY = MCPServerParameterType.ARRAY.value
+ OBJECT = MCPServerParameterType.OBJECT.value
# deprecated, should not use.
SYSTEM_FILES = PluginParameterType.SYSTEM_FILES.value
@@ -259,6 +267,8 @@ class ToolParameter(PluginParameter):
human_description: Optional[I18nObject] = Field(default=None, description="The description presented to the user")
form: ToolParameterForm = Field(..., description="The form of the parameter, schema/form/llm")
llm_description: Optional[str] = None
+ # MCP object and array type parameters use this field to store the schema
+ input_schema: Optional[dict] = None
@classmethod
def get_simple_instance(
@@ -308,6 +318,7 @@ class ToolProviderIdentity(BaseModel):
name: str = Field(..., description="The name of the tool")
description: I18nObject = Field(..., description="The description of the tool")
icon: str = Field(..., description="The icon of the tool")
+ icon_dark: Optional[str] = Field(default=None, description="The dark icon of the tool")
label: I18nObject = Field(..., description="The label of the tool")
tags: Optional[list[ToolLabelEnum]] = Field(
default=[],
diff --git a/api/core/tools/mcp_tool/provider.py b/api/core/tools/mcp_tool/provider.py
new file mode 100644
index 0000000000..93f003effe
--- /dev/null
+++ b/api/core/tools/mcp_tool/provider.py
@@ -0,0 +1,130 @@
+import json
+from typing import Any
+
+from core.mcp.types import Tool as RemoteMCPTool
+from core.tools.__base.tool_provider import ToolProviderController
+from core.tools.__base.tool_runtime import ToolRuntime
+from core.tools.entities.common_entities import I18nObject
+from core.tools.entities.tool_entities import (
+ ToolDescription,
+ ToolEntity,
+ ToolIdentity,
+ ToolProviderEntityWithPlugin,
+ ToolProviderIdentity,
+ ToolProviderType,
+)
+from core.tools.mcp_tool.tool import MCPTool
+from models.tools import MCPToolProvider
+from services.tools.tools_transform_service import ToolTransformService
+
+
+class MCPToolProviderController(ToolProviderController):
+ provider_id: str
+ entity: ToolProviderEntityWithPlugin
+
+ def __init__(self, entity: ToolProviderEntityWithPlugin, provider_id: str, tenant_id: str, server_url: str) -> None:
+ super().__init__(entity)
+ self.entity = entity
+ self.tenant_id = tenant_id
+ self.provider_id = provider_id
+ self.server_url = server_url
+
+ @property
+ def provider_type(self) -> ToolProviderType:
+ """
+ returns the type of the provider
+
+ :return: type of the provider
+ """
+ return ToolProviderType.MCP
+
+ @classmethod
+ def _from_db(cls, db_provider: MCPToolProvider) -> "MCPToolProviderController":
+ """
+ from db provider
+ """
+ tools = []
+ tools_data = json.loads(db_provider.tools)
+ remote_mcp_tools = [RemoteMCPTool(**tool) for tool in tools_data]
+ user = db_provider.load_user()
+ tools = [
+ ToolEntity(
+ identity=ToolIdentity(
+ author=user.name if user else "Anonymous",
+ name=remote_mcp_tool.name,
+ label=I18nObject(en_US=remote_mcp_tool.name, zh_Hans=remote_mcp_tool.name),
+ provider=db_provider.server_identifier,
+ icon=db_provider.icon,
+ ),
+ parameters=ToolTransformService.convert_mcp_schema_to_parameter(remote_mcp_tool.inputSchema),
+ description=ToolDescription(
+ human=I18nObject(
+ en_US=remote_mcp_tool.description or "", zh_Hans=remote_mcp_tool.description or ""
+ ),
+ llm=remote_mcp_tool.description or "",
+ ),
+ output_schema=None,
+ has_runtime_parameters=len(remote_mcp_tool.inputSchema) > 0,
+ )
+ for remote_mcp_tool in remote_mcp_tools
+ ]
+
+ return cls(
+ entity=ToolProviderEntityWithPlugin(
+ identity=ToolProviderIdentity(
+ author=user.name if user else "Anonymous",
+ name=db_provider.name,
+ label=I18nObject(en_US=db_provider.name, zh_Hans=db_provider.name),
+ description=I18nObject(en_US="", zh_Hans=""),
+ icon=db_provider.icon,
+ ),
+ plugin_id=None,
+ credentials_schema=[],
+ tools=tools,
+ ),
+ provider_id=db_provider.server_identifier or "",
+ tenant_id=db_provider.tenant_id or "",
+ server_url=db_provider.decrypted_server_url,
+ )
+
+ def _validate_credentials(self, user_id: str, credentials: dict[str, Any]) -> None:
+ """
+ validate the credentials of the provider
+ """
+ pass
+
+ def get_tool(self, tool_name: str) -> MCPTool: # type: ignore
+ """
+ return tool with given name
+ """
+ tool_entity = next(
+ (tool_entity for tool_entity in self.entity.tools if tool_entity.identity.name == tool_name), None
+ )
+
+ if not tool_entity:
+ raise ValueError(f"Tool with name {tool_name} not found")
+
+ return MCPTool(
+ entity=tool_entity,
+ runtime=ToolRuntime(tenant_id=self.tenant_id),
+ tenant_id=self.tenant_id,
+ icon=self.entity.identity.icon,
+ server_url=self.server_url,
+ provider_id=self.provider_id,
+ )
+
+ def get_tools(self) -> list[MCPTool]: # type: ignore
+ """
+ get all tools
+ """
+ return [
+ MCPTool(
+ entity=tool_entity,
+ runtime=ToolRuntime(tenant_id=self.tenant_id),
+ tenant_id=self.tenant_id,
+ icon=self.entity.identity.icon,
+ server_url=self.server_url,
+ provider_id=self.provider_id,
+ )
+ for tool_entity in self.entity.tools
+ ]
diff --git a/api/core/tools/mcp_tool/tool.py b/api/core/tools/mcp_tool/tool.py
new file mode 100644
index 0000000000..d1bacbc735
--- /dev/null
+++ b/api/core/tools/mcp_tool/tool.py
@@ -0,0 +1,92 @@
+import base64
+import json
+from collections.abc import Generator
+from typing import Any, Optional
+
+from core.mcp.error import MCPAuthError, MCPConnectionError
+from core.mcp.mcp_client import MCPClient
+from core.mcp.types import ImageContent, TextContent
+from core.tools.__base.tool import Tool
+from core.tools.__base.tool_runtime import ToolRuntime
+from core.tools.entities.tool_entities import ToolEntity, ToolInvokeMessage, ToolParameter, ToolProviderType
+
+
+class MCPTool(Tool):
+ tenant_id: str
+ icon: str
+ runtime_parameters: Optional[list[ToolParameter]]
+ server_url: str
+ provider_id: str
+
+ def __init__(
+ self, entity: ToolEntity, runtime: ToolRuntime, tenant_id: str, icon: str, server_url: str, provider_id: str
+ ) -> None:
+ super().__init__(entity, runtime)
+ self.tenant_id = tenant_id
+ self.icon = icon
+ self.runtime_parameters = None
+ self.server_url = server_url
+ self.provider_id = provider_id
+
+ def tool_provider_type(self) -> ToolProviderType:
+ return ToolProviderType.MCP
+
+ def _invoke(
+ self,
+ user_id: str,
+ tool_parameters: dict[str, Any],
+ conversation_id: Optional[str] = None,
+ app_id: Optional[str] = None,
+ message_id: Optional[str] = None,
+ ) -> Generator[ToolInvokeMessage, None, None]:
+ from core.tools.errors import ToolInvokeError
+
+ try:
+ with MCPClient(self.server_url, self.provider_id, self.tenant_id, authed=True) as mcp_client:
+ tool_parameters = self._handle_none_parameter(tool_parameters)
+ result = mcp_client.invoke_tool(tool_name=self.entity.identity.name, tool_args=tool_parameters)
+ except MCPAuthError as e:
+ raise ToolInvokeError("Please auth the tool first") from e
+ except MCPConnectionError as e:
+ raise ToolInvokeError(f"Failed to connect to MCP server: {e}") from e
+ except Exception as e:
+ raise ToolInvokeError(f"Failed to invoke tool: {e}") from e
+
+ for content in result.content:
+ if isinstance(content, TextContent):
+ try:
+ content_json = json.loads(content.text)
+ if isinstance(content_json, dict):
+ yield self.create_json_message(content_json)
+ elif isinstance(content_json, list):
+ for item in content_json:
+ yield self.create_json_message(item)
+ else:
+ yield self.create_text_message(content.text)
+ except json.JSONDecodeError:
+ yield self.create_text_message(content.text)
+
+ elif isinstance(content, ImageContent):
+ yield self.create_blob_message(
+ blob=base64.b64decode(content.data), meta={"mime_type": content.mimeType}
+ )
+
+ def fork_tool_runtime(self, runtime: ToolRuntime) -> "MCPTool":
+ return MCPTool(
+ entity=self.entity,
+ runtime=runtime,
+ tenant_id=self.tenant_id,
+ icon=self.icon,
+ server_url=self.server_url,
+ provider_id=self.provider_id,
+ )
+
+ def _handle_none_parameter(self, parameter: dict[str, Any]) -> dict[str, Any]:
+ """
+ in mcp tool invoke, if the parameter is empty, it will be set to None
+ """
+ return {
+ key: value
+ for key, value in parameter.items()
+ if value is not None and not (isinstance(value, str) and value.strip() == "")
+ }
diff --git a/api/core/tools/signature.py b/api/core/tools/signature.py
index e80005d7bf..5cdf473542 100644
--- a/api/core/tools/signature.py
+++ b/api/core/tools/signature.py
@@ -9,9 +9,10 @@ from configs import dify_config
def sign_tool_file(tool_file_id: str, extension: str) -> str:
"""
- sign file to get a temporary url
+ sign file to get a temporary url for plugin access
"""
- base_url = dify_config.FILES_URL
+ # Use internal URL for plugin/tool file access in Docker environments
+ base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL
file_preview_url = f"{base_url}/files/tools/{tool_file_id}{extension}"
timestamp = str(int(time.time()))
diff --git a/api/core/tools/tool_file_manager.py b/api/core/tools/tool_file_manager.py
index b849f51064..ece02f9d59 100644
--- a/api/core/tools/tool_file_manager.py
+++ b/api/core/tools/tool_file_manager.py
@@ -35,9 +35,10 @@ class ToolFileManager:
@staticmethod
def sign_file(tool_file_id: str, extension: str) -> str:
"""
- sign file to get a temporary url
+ sign file to get a temporary url for plugin access
"""
- base_url = dify_config.FILES_URL
+ # Use internal URL for plugin/tool file access in Docker environments
+ base_url = dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL
file_preview_url = f"{base_url}/files/tools/{tool_file_id}{extension}"
timestamp = str(int(time.time()))
diff --git a/api/core/tools/tool_manager.py b/api/core/tools/tool_manager.py
index 0bfe6329b1..22a9853b41 100644
--- a/api/core/tools/tool_manager.py
+++ b/api/core/tools/tool_manager.py
@@ -4,7 +4,7 @@ import mimetypes
from collections.abc import Generator
from os import listdir, path
from threading import Lock
-from typing import TYPE_CHECKING, Any, Union, cast
+from typing import TYPE_CHECKING, Any, Literal, Optional, Union, cast
from yarl import URL
@@ -13,9 +13,13 @@ from core.plugin.entities.plugin import ToolProviderID
from core.plugin.impl.tool import PluginToolManager
from core.tools.__base.tool_provider import ToolProviderController
from core.tools.__base.tool_runtime import ToolRuntime
+from core.tools.mcp_tool.provider import MCPToolProviderController
+from core.tools.mcp_tool.tool import MCPTool
from core.tools.plugin_tool.provider import PluginToolProviderController
from core.tools.plugin_tool.tool import PluginTool
from core.tools.workflow_as_tool.provider import WorkflowToolProviderController
+from core.workflow.entities.variable_pool import VariablePool
+from services.tools.mcp_tools_mange_service import MCPToolManageService
if TYPE_CHECKING:
from core.workflow.nodes.tool.entities import ToolEntity
@@ -49,7 +53,7 @@ from core.tools.utils.configuration import (
)
from core.tools.workflow_as_tool.tool import WorkflowTool
from extensions.ext_database import db
-from models.tools import ApiToolProvider, BuiltinToolProvider, WorkflowToolProvider
+from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider
from services.tools.tools_transform_service import ToolTransformService
logger = logging.getLogger(__name__)
@@ -156,7 +160,7 @@ class ToolManager:
tenant_id: str,
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
tool_invoke_from: ToolInvokeFrom = ToolInvokeFrom.AGENT,
- ) -> Union[BuiltinTool, PluginTool, ApiTool, WorkflowTool]:
+ ) -> Union[BuiltinTool, PluginTool, ApiTool, WorkflowTool, MCPTool]:
"""
get the tool runtime
@@ -292,6 +296,8 @@ class ToolManager:
raise NotImplementedError("app provider not implemented")
elif provider_type == ToolProviderType.PLUGIN:
return cls.get_plugin_provider(provider_id, tenant_id).get_tool(tool_name)
+ elif provider_type == ToolProviderType.MCP:
+ return cls.get_mcp_provider_controller(tenant_id, provider_id).get_tool(tool_name)
else:
raise ToolProviderNotFoundError(f"provider type {provider_type.value} not found")
@@ -302,6 +308,7 @@ class ToolManager:
app_id: str,
agent_tool: AgentToolEntity,
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
+ variable_pool: Optional[VariablePool] = None,
) -> Tool:
"""
get the agent tool runtime
@@ -316,24 +323,9 @@ class ToolManager:
)
runtime_parameters = {}
parameters = tool_entity.get_merged_runtime_parameters()
- for parameter in parameters:
- # check file types
- if (
- parameter.type
- in {
- ToolParameter.ToolParameterType.SYSTEM_FILES,
- ToolParameter.ToolParameterType.FILE,
- ToolParameter.ToolParameterType.FILES,
- }
- and parameter.required
- ):
- raise ValueError(f"file type parameter {parameter.name} not supported in agent")
-
- if parameter.form == ToolParameter.ToolParameterForm.FORM:
- # save tool parameter to tool entity memory
- value = parameter.init_frontend_parameter(agent_tool.tool_parameters.get(parameter.name))
- runtime_parameters[parameter.name] = value
-
+ runtime_parameters = cls._convert_tool_parameters_type(
+ parameters, variable_pool, agent_tool.tool_parameters, typ="agent"
+ )
# decrypt runtime parameters
encryption_manager = ToolParameterConfigurationManager(
tenant_id=tenant_id,
@@ -357,10 +349,12 @@ class ToolManager:
node_id: str,
workflow_tool: "ToolEntity",
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
+ variable_pool: Optional[VariablePool] = None,
) -> Tool:
"""
get the workflow tool runtime
"""
+
tool_runtime = cls.get_tool_runtime(
provider_type=workflow_tool.provider_type,
provider_id=workflow_tool.provider_id,
@@ -369,15 +363,11 @@ class ToolManager:
invoke_from=invoke_from,
tool_invoke_from=ToolInvokeFrom.WORKFLOW,
)
- runtime_parameters = {}
- parameters = tool_runtime.get_merged_runtime_parameters()
-
- for parameter in parameters:
- # save tool parameter to tool entity memory
- if parameter.form == ToolParameter.ToolParameterForm.FORM:
- value = parameter.init_frontend_parameter(workflow_tool.tool_configurations.get(parameter.name))
- runtime_parameters[parameter.name] = value
+ parameters = tool_runtime.get_merged_runtime_parameters()
+ runtime_parameters = cls._convert_tool_parameters_type(
+ parameters, variable_pool, workflow_tool.tool_configurations, typ="workflow"
+ )
# decrypt runtime parameters
encryption_manager = ToolParameterConfigurationManager(
tenant_id=tenant_id,
@@ -569,7 +559,7 @@ class ToolManager:
filters = []
if not typ:
- filters.extend(["builtin", "api", "workflow"])
+ filters.extend(["builtin", "api", "workflow", "mcp"])
else:
filters.append(typ)
@@ -663,6 +653,10 @@ class ToolManager:
labels=labels.get(provider_controller.provider_id, []),
)
result_providers[f"workflow_provider.{user_provider.name}"] = user_provider
+ if "mcp" in filters:
+ mcp_providers = MCPToolManageService.retrieve_mcp_tools(tenant_id, for_list=True)
+ for mcp_provider in mcp_providers:
+ result_providers[f"mcp_provider.{mcp_provider.name}"] = mcp_provider
return BuiltinToolProviderSort.sort(list(result_providers.values()))
@@ -690,14 +684,47 @@ class ToolManager:
if provider is None:
raise ToolProviderNotFoundError(f"api provider {provider_id} not found")
+ auth_type = ApiProviderAuthType.NONE
+ provider_auth_type = provider.credentials.get("auth_type")
+ if provider_auth_type in ("api_key_header", "api_key"): # backward compatibility
+ auth_type = ApiProviderAuthType.API_KEY_HEADER
+ elif provider_auth_type == "api_key_query":
+ auth_type = ApiProviderAuthType.API_KEY_QUERY
+
controller = ApiToolProviderController.from_db(
provider,
- ApiProviderAuthType.API_KEY if provider.credentials["auth_type"] == "api_key" else ApiProviderAuthType.NONE,
+ auth_type,
)
controller.load_bundled_tools(provider.tools)
return controller, provider.credentials
+ @classmethod
+ def get_mcp_provider_controller(cls, tenant_id: str, provider_id: str) -> MCPToolProviderController:
+ """
+ get the api provider
+
+ :param tenant_id: the id of the tenant
+ :param provider_id: the id of the provider
+
+ :return: the provider controller, the credentials
+ """
+ provider: MCPToolProvider | None = (
+ db.session.query(MCPToolProvider)
+ .filter(
+ MCPToolProvider.server_identifier == provider_id,
+ MCPToolProvider.tenant_id == tenant_id,
+ )
+ .first()
+ )
+
+ if provider is None:
+ raise ToolProviderNotFoundError(f"mcp provider {provider_id} not found")
+
+ controller = MCPToolProviderController._from_db(provider)
+
+ return controller
+
@classmethod
def user_get_api_provider(cls, provider: str, tenant_id: str) -> dict:
"""
@@ -725,9 +752,16 @@ class ToolManager:
credentials = {}
# package tool provider controller
+ auth_type = ApiProviderAuthType.NONE
+ credentials_auth_type = credentials.get("auth_type")
+ if credentials_auth_type in ("api_key_header", "api_key"): # backward compatibility
+ auth_type = ApiProviderAuthType.API_KEY_HEADER
+ elif credentials_auth_type == "api_key_query":
+ auth_type = ApiProviderAuthType.API_KEY_QUERY
+
controller = ApiToolProviderController.from_db(
provider_obj,
- ApiProviderAuthType.API_KEY if credentials["auth_type"] == "api_key" else ApiProviderAuthType.NONE,
+ auth_type,
)
# init tool configuration
tool_configuration = ProviderConfigEncrypter(
@@ -826,6 +860,22 @@ class ToolManager:
except Exception:
return {"background": "#252525", "content": "\ud83d\ude01"}
+ @classmethod
+ def generate_mcp_tool_icon_url(cls, tenant_id: str, provider_id: str) -> dict[str, str] | str:
+ try:
+ mcp_provider: MCPToolProvider | None = (
+ db.session.query(MCPToolProvider)
+ .filter(MCPToolProvider.tenant_id == tenant_id, MCPToolProvider.server_identifier == provider_id)
+ .first()
+ )
+
+ if mcp_provider is None:
+ raise ToolProviderNotFoundError(f"mcp provider {provider_id} not found")
+
+ return mcp_provider.provider_icon
+ except Exception:
+ return {"background": "#252525", "content": "\ud83d\ude01"}
+
@classmethod
def get_tool_icon(
cls,
@@ -863,8 +913,61 @@ class ToolManager:
except Exception:
return {"background": "#252525", "content": "\ud83d\ude01"}
raise ValueError(f"plugin provider {provider_id} not found")
+ elif provider_type == ToolProviderType.MCP:
+ return cls.generate_mcp_tool_icon_url(tenant_id, provider_id)
else:
raise ValueError(f"provider type {provider_type} not found")
+ @classmethod
+ def _convert_tool_parameters_type(
+ cls,
+ parameters: list[ToolParameter],
+ variable_pool: Optional[VariablePool],
+ tool_configurations: dict[str, Any],
+ typ: Literal["agent", "workflow", "tool"] = "workflow",
+ ) -> dict[str, Any]:
+ """
+ Convert tool parameters type
+ """
+ from core.workflow.nodes.tool.entities import ToolNodeData
+ from core.workflow.nodes.tool.exc import ToolParameterError
+
+ runtime_parameters = {}
+ for parameter in parameters:
+ if (
+ parameter.type
+ in {
+ ToolParameter.ToolParameterType.SYSTEM_FILES,
+ ToolParameter.ToolParameterType.FILE,
+ ToolParameter.ToolParameterType.FILES,
+ }
+ and parameter.required
+ and typ == "agent"
+ ):
+ raise ValueError(f"file type parameter {parameter.name} not supported in agent")
+ # save tool parameter to tool entity memory
+ if parameter.form == ToolParameter.ToolParameterForm.FORM:
+ if variable_pool:
+ config = tool_configurations.get(parameter.name, {})
+ if not (config and isinstance(config, dict) and config.get("value") is not None):
+ continue
+ tool_input = ToolNodeData.ToolInput(**tool_configurations.get(parameter.name, {}))
+ if tool_input.type == "variable":
+ variable = variable_pool.get(tool_input.value)
+ if variable is None:
+ raise ToolParameterError(f"Variable {tool_input.value} does not exist")
+ parameter_value = variable.value
+ elif tool_input.type in {"mixed", "constant"}:
+ segment_group = variable_pool.convert_template(str(tool_input.value))
+ parameter_value = segment_group.text
+ else:
+ raise ToolParameterError(f"Unknown tool input type '{tool_input.type}'")
+ runtime_parameters[parameter.name] = parameter_value
+
+ else:
+ value = parameter.init_frontend_parameter(tool_configurations.get(parameter.name))
+ runtime_parameters[parameter.name] = value
+ return runtime_parameters
+
ToolManager.load_hardcoded_providers_cache()
diff --git a/api/core/tools/utils/configuration.py b/api/core/tools/utils/configuration.py
index 6a5fba65bd..251fedf56e 100644
--- a/api/core/tools/utils/configuration.py
+++ b/api/core/tools/utils/configuration.py
@@ -72,20 +72,21 @@ class ProviderConfigEncrypter(BaseModel):
return data
- def decrypt(self, data: dict[str, str]) -> dict[str, str]:
+ def decrypt(self, data: dict[str, str], use_cache: bool = True) -> dict[str, str]:
"""
decrypt tool credentials with tenant id
return a deep copy of credentials with decrypted values
"""
- cache = ToolProviderCredentialsCache(
- tenant_id=self.tenant_id,
- identity_id=f"{self.provider_type}.{self.provider_identity}",
- cache_type=ToolProviderCredentialsCacheType.PROVIDER,
- )
- cached_credentials = cache.get()
- if cached_credentials:
- return cached_credentials
+ if use_cache:
+ cache = ToolProviderCredentialsCache(
+ tenant_id=self.tenant_id,
+ identity_id=f"{self.provider_type}.{self.provider_identity}",
+ cache_type=ToolProviderCredentialsCacheType.PROVIDER,
+ )
+ cached_credentials = cache.get()
+ if cached_credentials:
+ return cached_credentials
data = self._deep_copy(data)
# get fields need to be decrypted
fields = dict[str, BasicProviderConfig]()
@@ -104,7 +105,8 @@ class ProviderConfigEncrypter(BaseModel):
except Exception:
pass
- cache.set(data)
+ if use_cache:
+ cache.set(data)
return data
def delete_tool_credentials_cache(self):
diff --git a/api/core/tools/utils/parser.py b/api/core/tools/utils/parser.py
index 3f844e8234..a3c84615ca 100644
--- a/api/core/tools/utils/parser.py
+++ b/api/core/tools/utils/parser.py
@@ -1,5 +1,4 @@
import re
-import uuid
from json import dumps as json_dumps
from json import loads as json_loads
from json.decoder import JSONDecodeError
@@ -154,7 +153,7 @@ class ApiBasedToolSchemaParser:
# remove special characters like / to ensure the operation id is valid ^[a-zA-Z0-9_-]{1,64}$
path = re.sub(r"[^a-zA-Z0-9_-]", "", path)
if not path:
- path = str(uuid.uuid4())
+ path = ""
interface["operation"]["operationId"] = f"{path}_{interface['method']}"
diff --git a/api/core/tools/workflow_as_tool/tool.py b/api/core/tools/workflow_as_tool/tool.py
index 57c93d1d45..10bf8ca640 100644
--- a/api/core/tools/workflow_as_tool/tool.py
+++ b/api/core/tools/workflow_as_tool/tool.py
@@ -8,7 +8,12 @@ from flask_login import current_user
from core.file import FILE_MODEL_IDENTITY, File, FileTransferMethod
from core.tools.__base.tool import Tool
from core.tools.__base.tool_runtime import ToolRuntime
-from core.tools.entities.tool_entities import ToolEntity, ToolInvokeMessage, ToolParameter, ToolProviderType
+from core.tools.entities.tool_entities import (
+ ToolEntity,
+ ToolInvokeMessage,
+ ToolParameter,
+ ToolProviderType,
+)
from core.tools.errors import ToolInvokeError
from extensions.ext_database import db
from factories.file_factory import build_from_mapping
diff --git a/api/core/variables/segments.py b/api/core/variables/segments.py
index 64ba16c367..6cf09e0372 100644
--- a/api/core/variables/segments.py
+++ b/api/core/variables/segments.py
@@ -75,6 +75,20 @@ class StringSegment(Segment):
class FloatSegment(Segment):
value_type: SegmentType = SegmentType.NUMBER
value: float
+ # NOTE(QuantumGhost): seems that the equality for FloatSegment with `NaN` value has some problems.
+ # The following tests cannot pass.
+ #
+ # def test_float_segment_and_nan():
+ # nan = float("nan")
+ # assert nan != nan
+ #
+ # f1 = FloatSegment(value=float("nan"))
+ # f2 = FloatSegment(value=float("nan"))
+ # assert f1 != f2
+ #
+ # f3 = FloatSegment(value=nan)
+ # f4 = FloatSegment(value=nan)
+ # assert f3 != f4
class IntegerSegment(Segment):
diff --git a/api/core/variables/types.py b/api/core/variables/types.py
index 4387e9693e..68d3d82883 100644
--- a/api/core/variables/types.py
+++ b/api/core/variables/types.py
@@ -18,3 +18,17 @@ class SegmentType(StrEnum):
NONE = "none"
GROUP = "group"
+
+ def is_array_type(self):
+ return self in _ARRAY_TYPES
+
+
+_ARRAY_TYPES = frozenset(
+ [
+ SegmentType.ARRAY_ANY,
+ SegmentType.ARRAY_STRING,
+ SegmentType.ARRAY_NUMBER,
+ SegmentType.ARRAY_OBJECT,
+ SegmentType.ARRAY_FILE,
+ ]
+)
diff --git a/api/core/variables/utils.py b/api/core/variables/utils.py
index e5d222af7d..692db3502e 100644
--- a/api/core/variables/utils.py
+++ b/api/core/variables/utils.py
@@ -1,8 +1,26 @@
+import json
from collections.abc import Iterable, Sequence
+from .segment_group import SegmentGroup
+from .segments import ArrayFileSegment, FileSegment, Segment
+
def to_selector(node_id: str, name: str, paths: Iterable[str] = ()) -> Sequence[str]:
selectors = [node_id, name]
if paths:
selectors.extend(paths)
return selectors
+
+
+class SegmentJSONEncoder(json.JSONEncoder):
+ def default(self, o):
+ if isinstance(o, ArrayFileSegment):
+ return [v.model_dump() for v in o.value]
+ elif isinstance(o, FileSegment):
+ return o.value.model_dump()
+ elif isinstance(o, SegmentGroup):
+ return [self.default(seg) for seg in o.value]
+ elif isinstance(o, Segment):
+ return o.value
+ else:
+ super().default(o)
diff --git a/api/core/workflow/callbacks/workflow_logging_callback.py b/api/core/workflow/callbacks/workflow_logging_callback.py
index e6813a3997..12b5203ca3 100644
--- a/api/core/workflow/callbacks/workflow_logging_callback.py
+++ b/api/core/workflow/callbacks/workflow_logging_callback.py
@@ -232,14 +232,14 @@ class WorkflowLoggingCallback(WorkflowCallback):
Publish loop started
"""
self.print_text("\n[LoopRunStartedEvent]", color="blue")
- self.print_text(f"Loop Node ID: {event.loop_id}", color="blue")
+ self.print_text(f"Loop Node ID: {event.loop_node_id}", color="blue")
def on_workflow_loop_next(self, event: LoopRunNextEvent) -> None:
"""
Publish loop next
"""
self.print_text("\n[LoopRunNextEvent]", color="blue")
- self.print_text(f"Loop Node ID: {event.loop_id}", color="blue")
+ self.print_text(f"Loop Node ID: {event.loop_node_id}", color="blue")
self.print_text(f"Loop Index: {event.index}", color="blue")
def on_workflow_loop_completed(self, event: LoopRunSucceededEvent | LoopRunFailedEvent) -> None:
@@ -250,7 +250,7 @@ class WorkflowLoggingCallback(WorkflowCallback):
"\n[LoopRunSucceededEvent]" if isinstance(event, LoopRunSucceededEvent) else "\n[LoopRunFailedEvent]",
color="blue",
)
- self.print_text(f"Node ID: {event.loop_id}", color="blue")
+ self.print_text(f"Loop Node ID: {event.loop_node_id}", color="blue")
def print_text(self, text: str, color: Optional[str] = None, end: str = "\n") -> None:
"""Print text with highlighting and no end characters."""
diff --git a/api/core/workflow/conversation_variable_updater.py b/api/core/workflow/conversation_variable_updater.py
new file mode 100644
index 0000000000..84e99bb582
--- /dev/null
+++ b/api/core/workflow/conversation_variable_updater.py
@@ -0,0 +1,39 @@
+import abc
+from typing import Protocol
+
+from core.variables import Variable
+
+
+class ConversationVariableUpdater(Protocol):
+ """
+ ConversationVariableUpdater defines an abstraction for updating conversation variable values.
+
+ It is intended for use by `v1.VariableAssignerNode` and `v2.VariableAssignerNode` when updating
+ conversation variables.
+
+ Implementations may choose to batch updates. If batching is used, the `flush` method
+ should be implemented to persist buffered changes, and `update`
+ should handle buffering accordingly.
+
+ Note: Since implementations may buffer updates, instances of ConversationVariableUpdater
+ are not thread-safe. Each VariableAssignerNode should create its own instance during execution.
+ """
+
+ @abc.abstractmethod
+ def update(self, conversation_id: str, variable: "Variable") -> None:
+ """
+ Updates the value of the specified conversation variable in the underlying storage.
+
+ :param conversation_id: The ID of the conversation to update. Typically references `ConversationVariable.id`.
+ :param variable: The `Variable` instance containing the updated value.
+ """
+ pass
+
+ @abc.abstractmethod
+ def flush(self):
+ """
+ Flushes all pending updates to the underlying storage system.
+
+ If the implementation does not buffer updates, this method can be a no-op.
+ """
+ pass
diff --git a/api/core/workflow/entities/variable_pool.py b/api/core/workflow/entities/variable_pool.py
index af26864c01..80dda2632d 100644
--- a/api/core/workflow/entities/variable_pool.py
+++ b/api/core/workflow/entities/variable_pool.py
@@ -7,12 +7,12 @@ from pydantic import BaseModel, Field
from core.file import File, FileAttribute, file_manager
from core.variables import Segment, SegmentGroup, Variable
+from core.variables.consts import MIN_SELECTORS_LENGTH
from core.variables.segments import FileSegment, NoneSegment
+from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
+from core.workflow.enums import SystemVariableKey
from factories import variable_factory
-from ..constants import CONVERSATION_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
-from ..enums import SystemVariableKey
-
VariableValue = Union[str, int, float, dict, list, File]
VARIABLE_PATTERN = re.compile(r"\{\{#([a-zA-Z0-9_]{1,50}(?:\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10})#\}\}")
@@ -30,9 +30,11 @@ class VariablePool(BaseModel):
# TODO: This user inputs is not used for pool.
user_inputs: Mapping[str, Any] = Field(
description="User inputs",
+ default_factory=dict,
)
system_variables: Mapping[SystemVariableKey, Any] = Field(
description="System variables",
+ default_factory=dict,
)
environment_variables: Sequence[Variable] = Field(
description="Environment variables.",
@@ -43,28 +45,7 @@ class VariablePool(BaseModel):
default_factory=list,
)
- def __init__(
- self,
- *,
- system_variables: Mapping[SystemVariableKey, Any] | None = None,
- user_inputs: Mapping[str, Any] | None = None,
- environment_variables: Sequence[Variable] | None = None,
- conversation_variables: Sequence[Variable] | None = None,
- **kwargs,
- ):
- environment_variables = environment_variables or []
- conversation_variables = conversation_variables or []
- user_inputs = user_inputs or {}
- system_variables = system_variables or {}
-
- super().__init__(
- system_variables=system_variables,
- user_inputs=user_inputs,
- environment_variables=environment_variables,
- conversation_variables=conversation_variables,
- **kwargs,
- )
-
+ def model_post_init(self, context: Any, /) -> None:
for key, value in self.system_variables.items():
self.add((SYSTEM_VARIABLE_NODE_ID, key.value), value)
# Add environment variables to the variable pool
@@ -91,12 +72,12 @@ class VariablePool(BaseModel):
Returns:
None
"""
- if len(selector) < 2:
+ if len(selector) < MIN_SELECTORS_LENGTH:
raise ValueError("Invalid selector")
if isinstance(value, Variable):
variable = value
- if isinstance(value, Segment):
+ elif isinstance(value, Segment):
variable = variable_factory.segment_to_variable(segment=value, selector=selector)
else:
segment = variable_factory.build_segment(value)
@@ -118,7 +99,7 @@ class VariablePool(BaseModel):
Raises:
ValueError: If the selector is invalid.
"""
- if len(selector) < 2:
+ if len(selector) < MIN_SELECTORS_LENGTH:
return None
hash_key = hash(tuple(selector[1:]))
diff --git a/api/core/workflow/entities/workflow_node_execution.py b/api/core/workflow/entities/workflow_node_execution.py
index 773f5b777b..09a408f4d7 100644
--- a/api/core/workflow/entities/workflow_node_execution.py
+++ b/api/core/workflow/entities/workflow_node_execution.py
@@ -66,11 +66,21 @@ class WorkflowNodeExecution(BaseModel):
but they are not stored in the model.
"""
- # Core identification fields
- id: str # Unique identifier for this execution record
- node_execution_id: Optional[str] = None # Optional secondary ID for cross-referencing
+ # --------- Core identification fields ---------
+
+ # Unique identifier for this execution record, used when persisting to storage.
+ # Value is a UUID string (e.g., '09b3e04c-f9ae-404c-ad82-290b8d7bd382').
+ id: str
+
+ # Optional secondary ID for cross-referencing purposes.
+ #
+ # NOTE: For referencing the persisted record, use `id` rather than `node_execution_id`.
+ # While `node_execution_id` may sometimes be a UUID string, this is not guaranteed.
+ # In most scenarios, `id` should be used as the primary identifier.
+ node_execution_id: Optional[str] = None
workflow_id: str # ID of the workflow this node belongs to
workflow_execution_id: Optional[str] = None # ID of the specific workflow run (null for single-step debugging)
+ # --------- Core identification fields ends ---------
# Execution positioning and flow
index: int # Sequence number for ordering in trace visualization
diff --git a/api/core/workflow/graph_engine/entities/event.py b/api/core/workflow/graph_engine/entities/event.py
index 9a4939502e..e57e9e4d64 100644
--- a/api/core/workflow/graph_engine/entities/event.py
+++ b/api/core/workflow/graph_engine/entities/event.py
@@ -66,6 +66,8 @@ class BaseNodeEvent(GraphEngineEvent):
"""iteration id if node is in iteration"""
in_loop_id: Optional[str] = None
"""loop id if node is in loop"""
+ # The version of the node, or "1" if not specified.
+ node_version: str = "1"
class NodeRunStartedEvent(BaseNodeEvent):
diff --git a/api/core/workflow/graph_engine/entities/graph.py b/api/core/workflow/graph_engine/entities/graph.py
index 8e5b1e7142..362777a199 100644
--- a/api/core/workflow/graph_engine/entities/graph.py
+++ b/api/core/workflow/graph_engine/entities/graph.py
@@ -334,7 +334,7 @@ class Graph(BaseModel):
parallel = GraphParallel(
start_from_node_id=start_node_id,
- parent_parallel_id=parent_parallel.id if parent_parallel else None,
+ parent_parallel_id=parent_parallel_id,
parent_parallel_start_node_id=parent_parallel.start_from_node_id if parent_parallel else None,
)
parallel_mapping[parallel.id] = parallel
diff --git a/api/core/workflow/graph_engine/graph_engine.py b/api/core/workflow/graph_engine/graph_engine.py
index 9870df749d..4e552981ae 100644
--- a/api/core/workflow/graph_engine/graph_engine.py
+++ b/api/core/workflow/graph_engine/graph_engine.py
@@ -9,7 +9,7 @@ from copy import copy, deepcopy
from datetime import UTC, datetime
from typing import Any, Optional, cast
-from flask import Flask, current_app, has_request_context
+from flask import Flask, current_app
from configs import dify_config
from core.app.apps.base_app_queue_manager import GenerateTaskStoppedError
@@ -53,6 +53,8 @@ from core.workflow.nodes.end.end_stream_processor import EndStreamProcessor
from core.workflow.nodes.enums import ErrorStrategy, FailBranchSourceHandle
from core.workflow.nodes.event import RunCompletedEvent, RunRetrieverResourceEvent, RunStreamChunkEvent
from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING
+from core.workflow.utils import variable_utils
+from libs.flask_utils import preserve_flask_contexts
from models.enums import UserFrom
from models.workflow import WorkflowType
@@ -101,7 +103,7 @@ class GraphEngine:
call_depth: int,
graph: Graph,
graph_config: Mapping[str, Any],
- variable_pool: VariablePool,
+ graph_runtime_state: GraphRuntimeState,
max_execution_steps: int,
max_execution_time: int,
thread_pool_id: Optional[str] = None,
@@ -138,7 +140,7 @@ class GraphEngine:
call_depth=call_depth,
)
- self.graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
+ self.graph_runtime_state = graph_runtime_state
self.max_execution_steps = max_execution_steps
self.max_execution_time = max_execution_time
@@ -314,6 +316,7 @@ class GraphEngine:
parallel_start_node_id=parallel_start_node_id,
parent_parallel_id=parent_parallel_id,
parent_parallel_start_node_id=parent_parallel_start_node_id,
+ node_version=node_instance.version(),
)
raise e
@@ -538,24 +541,9 @@ class GraphEngine:
"""
Run parallel nodes
"""
- for var, val in context.items():
- var.set(val)
-
- # FIXME(-LAN-): Save current user before entering new app context
- from flask import g
- saved_user = None
- if has_request_context() and hasattr(g, "_login_user"):
- saved_user = g._login_user
-
- with flask_app.app_context():
+ with preserve_flask_contexts(flask_app, context_vars=context):
try:
- # Restore user in new app context
- if saved_user is not None:
- from flask import g
-
- g._login_user = saved_user
-
q.put(
ParallelBranchRunStartedEvent(
parallel_id=parallel_id,
@@ -642,6 +630,7 @@ class GraphEngine:
parent_parallel_id=parent_parallel_id,
parent_parallel_start_node_id=parent_parallel_start_node_id,
agent_strategy=agent_strategy,
+ node_version=node_instance.version(),
)
max_retries = node_instance.node_data.retry_config.max_retries
@@ -654,26 +643,19 @@ class GraphEngine:
retry_start_at = datetime.now(UTC).replace(tzinfo=None)
# yield control to other threads
time.sleep(0.001)
- generator = node_instance.run()
- for item in generator:
- if isinstance(item, GraphEngineEvent):
- if isinstance(item, BaseIterationEvent):
- # add parallel info to iteration event
- item.parallel_id = parallel_id
- item.parallel_start_node_id = parallel_start_node_id
- item.parent_parallel_id = parent_parallel_id
- item.parent_parallel_start_node_id = parent_parallel_start_node_id
- elif isinstance(item, BaseLoopEvent):
- # add parallel info to loop event
- item.parallel_id = parallel_id
- item.parallel_start_node_id = parallel_start_node_id
- item.parent_parallel_id = parent_parallel_id
- item.parent_parallel_start_node_id = parent_parallel_start_node_id
-
- yield item
+ event_stream = node_instance.run()
+ for event in event_stream:
+ if isinstance(event, GraphEngineEvent):
+ # add parallel info to iteration event
+ if isinstance(event, BaseIterationEvent | BaseLoopEvent):
+ event.parallel_id = parallel_id
+ event.parallel_start_node_id = parallel_start_node_id
+ event.parent_parallel_id = parent_parallel_id
+ event.parent_parallel_start_node_id = parent_parallel_start_node_id
+ yield event
else:
- if isinstance(item, RunCompletedEvent):
- run_result = item.run_result
+ if isinstance(event, RunCompletedEvent):
+ run_result = event.run_result
if run_result.status == WorkflowNodeExecutionStatus.FAILED:
if (
retries == max_retries
@@ -699,6 +681,7 @@ class GraphEngine:
error=run_result.error or "Unknown error",
retry_index=retries,
start_at=retry_start_at,
+ node_version=node_instance.version(),
)
time.sleep(retry_interval)
break
@@ -709,7 +692,7 @@ class GraphEngine:
# if run failed, handle error
run_result = self._handle_continue_on_error(
node_instance,
- item.run_result,
+ event.run_result,
self.graph_runtime_state.variable_pool,
handle_exceptions=handle_exceptions,
)
@@ -734,6 +717,7 @@ class GraphEngine:
parallel_start_node_id=parallel_start_node_id,
parent_parallel_id=parent_parallel_id,
parent_parallel_start_node_id=parent_parallel_start_node_id,
+ node_version=node_instance.version(),
)
should_continue_retry = False
else:
@@ -748,6 +732,7 @@ class GraphEngine:
parallel_start_node_id=parallel_start_node_id,
parent_parallel_id=parent_parallel_id,
parent_parallel_start_node_id=parent_parallel_start_node_id,
+ node_version=node_instance.version(),
)
should_continue_retry = False
elif run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED:
@@ -808,37 +793,40 @@ class GraphEngine:
parallel_start_node_id=parallel_start_node_id,
parent_parallel_id=parent_parallel_id,
parent_parallel_start_node_id=parent_parallel_start_node_id,
+ node_version=node_instance.version(),
)
should_continue_retry = False
break
- elif isinstance(item, RunStreamChunkEvent):
+ elif isinstance(event, RunStreamChunkEvent):
yield NodeRunStreamChunkEvent(
id=node_instance.id,
node_id=node_instance.node_id,
node_type=node_instance.node_type,
node_data=node_instance.node_data,
- chunk_content=item.chunk_content,
- from_variable_selector=item.from_variable_selector,
+ chunk_content=event.chunk_content,
+ from_variable_selector=event.from_variable_selector,
route_node_state=route_node_state,
parallel_id=parallel_id,
parallel_start_node_id=parallel_start_node_id,
parent_parallel_id=parent_parallel_id,
parent_parallel_start_node_id=parent_parallel_start_node_id,
+ node_version=node_instance.version(),
)
- elif isinstance(item, RunRetrieverResourceEvent):
+ elif isinstance(event, RunRetrieverResourceEvent):
yield NodeRunRetrieverResourceEvent(
id=node_instance.id,
node_id=node_instance.node_id,
node_type=node_instance.node_type,
node_data=node_instance.node_data,
- retriever_resources=item.retriever_resources,
- context=item.context,
+ retriever_resources=event.retriever_resources,
+ context=event.context,
route_node_state=route_node_state,
parallel_id=parallel_id,
parallel_start_node_id=parallel_start_node_id,
parent_parallel_id=parent_parallel_id,
parent_parallel_start_node_id=parent_parallel_start_node_id,
+ node_version=node_instance.version(),
)
except GenerateTaskStoppedError:
# trigger node run failed event
@@ -855,6 +843,7 @@ class GraphEngine:
parallel_start_node_id=parallel_start_node_id,
parent_parallel_id=parent_parallel_id,
parent_parallel_start_node_id=parent_parallel_start_node_id,
+ node_version=node_instance.version(),
)
return
except Exception as e:
@@ -869,16 +858,12 @@ class GraphEngine:
:param variable_value: variable value
:return:
"""
- self.graph_runtime_state.variable_pool.add([node_id] + variable_key_list, variable_value)
-
- # if variable_value is a dict, then recursively append variables
- if isinstance(variable_value, dict):
- for key, value in variable_value.items():
- # construct new key list
- new_key_list = variable_key_list + [key]
- self._append_variables_recursively(
- node_id=node_id, variable_key_list=new_key_list, variable_value=value
- )
+ variable_utils.append_variables_recursively(
+ self.graph_runtime_state.variable_pool,
+ node_id,
+ variable_key_list,
+ variable_value,
+ )
def _is_timed_out(self, start_at: float, max_execution_time: int) -> bool:
"""
diff --git a/api/core/workflow/nodes/agent/agent_node.py b/api/core/workflow/nodes/agent/agent_node.py
index faa8f90bea..678b99d546 100644
--- a/api/core/workflow/nodes/agent/agent_node.py
+++ b/api/core/workflow/nodes/agent/agent_node.py
@@ -1,19 +1,22 @@
import json
+import uuid
from collections.abc import Generator, Mapping, Sequence
from typing import Any, Optional, cast
+from packaging.version import Version
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.agent.entities import AgentToolEntity
from core.agent.plugin_entities import AgentStrategyParameter
+from core.agent.strategy.plugin import PluginAgentStrategy
from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_manager import ModelInstance, ModelManager
from core.model_runtime.entities.model_entities import AIModelEntity, ModelType
from core.plugin.impl.exc import PluginDaemonClientSideError
from core.plugin.impl.plugin import PluginInstaller
from core.provider_manager import ProviderManager
-from core.tools.entities.tool_entities import ToolParameter, ToolProviderType
+from core.tools.entities.tool_entities import ToolInvokeMessage, ToolParameter, ToolProviderType
from core.tools.tool_manager import ToolManager
from core.variables.segments import StringSegment
from core.workflow.entities.node_entities import NodeRunResult
@@ -39,6 +42,10 @@ class AgentNode(ToolNode):
_node_data_cls = AgentNodeData # type: ignore
_node_type = NodeType.AGENT
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self) -> Generator:
"""
Run the agent node
@@ -68,12 +75,14 @@ class AgentNode(ToolNode):
agent_parameters=agent_parameters,
variable_pool=self.graph_runtime_state.variable_pool,
node_data=node_data,
+ strategy=strategy,
)
parameters_for_log = self._generate_agent_parameters(
agent_parameters=agent_parameters,
variable_pool=self.graph_runtime_state.variable_pool,
node_data=node_data,
for_log=True,
+ strategy=strategy,
)
# get conversation id
@@ -98,6 +107,32 @@ class AgentNode(ToolNode):
try:
# convert tool messages
+ agent_thoughts: list = []
+
+ thought_log_message = ToolInvokeMessage(
+ type=ToolInvokeMessage.MessageType.LOG,
+ message=ToolInvokeMessage.LogMessage(
+ id=str(uuid.uuid4()),
+ label=f"Agent Strategy: {cast(AgentNodeData, self.node_data).agent_strategy_name}",
+ parent_id=None,
+ error=None,
+ status=ToolInvokeMessage.LogMessage.LogStatus.START,
+ data={
+ "strategy": cast(AgentNodeData, self.node_data).agent_strategy_name,
+ "parameters": parameters_for_log,
+ "thought_process": "Agent strategy execution started",
+ },
+ metadata={
+ "icon": self.agent_strategy_icon,
+ "agent_strategy": cast(AgentNodeData, self.node_data).agent_strategy_name,
+ },
+ ),
+ )
+
+ def enhanced_message_stream():
+ yield thought_log_message
+
+ yield from message_stream
yield from self._transform_message(
message_stream,
@@ -106,6 +141,7 @@ class AgentNode(ToolNode):
"agent_strategy": cast(AgentNodeData, self.node_data).agent_strategy_name,
},
parameters_for_log,
+ agent_thoughts,
)
except PluginDaemonClientSideError as e:
yield RunCompletedEvent(
@@ -123,6 +159,7 @@ class AgentNode(ToolNode):
variable_pool: VariablePool,
node_data: AgentNodeData,
for_log: bool = False,
+ strategy: PluginAgentStrategy,
) -> dict[str, Any]:
"""
Generate parameters based on the given tool parameters, variable pool, and node data.
@@ -154,7 +191,10 @@ class AgentNode(ToolNode):
# variable_pool.convert_template expects a string template,
# but if passing a dict, convert to JSON string first before rendering
try:
- parameter_value = json.dumps(agent_input.value, ensure_ascii=False)
+ if not isinstance(agent_input.value, str):
+ parameter_value = json.dumps(agent_input.value, ensure_ascii=False)
+ else:
+ parameter_value = str(agent_input.value)
except TypeError:
parameter_value = str(agent_input.value)
segment_group = variable_pool.convert_template(parameter_value)
@@ -162,7 +202,8 @@ class AgentNode(ToolNode):
# variable_pool.convert_template returns a string,
# so we need to convert it back to a dictionary
try:
- parameter_value = json.loads(parameter_value)
+ if not isinstance(agent_input.value, str):
+ parameter_value = json.loads(parameter_value)
except json.JSONDecodeError:
parameter_value = parameter_value
else:
@@ -171,7 +212,7 @@ class AgentNode(ToolNode):
if parameter.type == "array[tools]":
value = cast(list[dict[str, Any]], value)
value = [tool for tool in value if tool.get("enabled", False)]
-
+ value = self._filter_mcp_type_tool(strategy, value)
for tool in value:
if "schemas" in tool:
tool.pop("schemas")
@@ -208,13 +249,13 @@ class AgentNode(ToolNode):
)
extra = tool.get("extra", {})
-
+ runtime_variable_pool = variable_pool if self.node_data.version != "1" else None
tool_runtime = ToolManager.get_agent_tool_runtime(
- self.tenant_id, self.app_id, entity, self.invoke_from
+ self.tenant_id, self.app_id, entity, self.invoke_from, runtime_variable_pool
)
if tool_runtime.entity.description:
tool_runtime.entity.description.llm = (
- extra.get("descrption", "") or tool_runtime.entity.description.llm
+ extra.get("description", "") or tool_runtime.entity.description.llm
)
for tool_runtime_params in tool_runtime.entity.parameters:
tool_runtime_params.form = (
@@ -362,3 +403,16 @@ class AgentNode(ToolNode):
except ValueError:
model_schema.features.remove(feature)
return model_schema
+
+ def _filter_mcp_type_tool(self, strategy: PluginAgentStrategy, tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """
+ Filter MCP type tool
+ :param strategy: plugin agent strategy
+ :param tool: tool
+ :return: filtered tool dict
+ """
+ meta_version = strategy.meta_version
+ if meta_version and Version(meta_version) > Version("0.0.1"):
+ return tools
+ else:
+ return [tool for tool in tools if tool.get("type") != ToolProviderType.MCP.value]
diff --git a/api/core/workflow/nodes/answer/answer_node.py b/api/core/workflow/nodes/answer/answer_node.py
index 9b1ccde2a5..0877257d61 100644
--- a/api/core/workflow/nodes/answer/answer_node.py
+++ b/api/core/workflow/nodes/answer/answer_node.py
@@ -18,7 +18,11 @@ from core.workflow.utils.variable_template_parser import VariableTemplateParser
class AnswerNode(BaseNode[AnswerNodeData]):
_node_data_cls = AnswerNodeData
- _node_type: NodeType = NodeType.ANSWER
+ _node_type = NodeType.ANSWER
+
+ @classmethod
+ def version(cls) -> str:
+ return "1"
def _run(self) -> NodeRunResult:
"""
@@ -55,7 +59,7 @@ class AnswerNode(BaseNode[AnswerNodeData]):
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
- outputs={"answer": answer, "files": files, "outputs": outputs},
+ outputs={"answer": answer, "files": ArrayFileSegment(value=files), "outputs": outputs},
)
@classmethod
diff --git a/api/core/workflow/nodes/answer/answer_stream_processor.py b/api/core/workflow/nodes/answer/answer_stream_processor.py
index afd7e6f593..e607f51a49 100644
--- a/api/core/workflow/nodes/answer/answer_stream_processor.py
+++ b/api/core/workflow/nodes/answer/answer_stream_processor.py
@@ -2,7 +2,6 @@ import logging
from collections.abc import Generator
from typing import cast
-from core.file import FILE_MODEL_IDENTITY, File
from core.workflow.entities.variable_pool import VariablePool
from core.workflow.graph_engine.entities.event import (
GraphEngineEvent,
@@ -119,6 +118,7 @@ class AnswerStreamProcessor(StreamProcessor):
parallel_id=event.parallel_id,
parallel_start_node_id=event.parallel_start_node_id,
from_variable_selector=[answer_node_id, "answer"],
+ node_version=event.node_version,
)
else:
route_chunk = cast(VarGenerateRouteChunk, route_chunk)
@@ -144,6 +144,7 @@ class AnswerStreamProcessor(StreamProcessor):
route_node_state=event.route_node_state,
parallel_id=event.parallel_id,
parallel_start_node_id=event.parallel_start_node_id,
+ node_version=event.node_version,
)
self.route_position[answer_node_id] += 1
@@ -209,44 +210,3 @@ class AnswerStreamProcessor(StreamProcessor):
stream_out_answer_node_ids.append(answer_node_id)
return stream_out_answer_node_ids
-
- @classmethod
- def _fetch_files_from_variable_value(cls, value: dict | list) -> list[dict]:
- """
- Fetch files from variable value
- :param value: variable value
- :return:
- """
- if not value:
- return []
-
- files = []
- if isinstance(value, list):
- for item in value:
- file_var = cls._get_file_var_from_value(item)
- if file_var:
- files.append(file_var)
- elif isinstance(value, dict):
- file_var = cls._get_file_var_from_value(value)
- if file_var:
- files.append(file_var)
-
- return files
-
- @classmethod
- def _get_file_var_from_value(cls, value: dict | list):
- """
- Get file var from value
- :param value: variable value
- :return:
- """
- if not value:
- return None
-
- if isinstance(value, dict):
- if "dify_model_identity" in value and value["dify_model_identity"] == FILE_MODEL_IDENTITY:
- return value
- elif isinstance(value, File):
- return value.to_dict()
-
- return None
diff --git a/api/core/workflow/nodes/answer/base_stream_processor.py b/api/core/workflow/nodes/answer/base_stream_processor.py
index 6671ff0746..09d5464d7a 100644
--- a/api/core/workflow/nodes/answer/base_stream_processor.py
+++ b/api/core/workflow/nodes/answer/base_stream_processor.py
@@ -57,7 +57,6 @@ class StreamProcessor(ABC):
# The branch_identify parameter is added to ensure that
# only nodes in the correct logical branch are included.
- reachable_node_ids.append(edge.target_node_id)
ids = self._fetch_node_ids_in_reachable_branch(edge.target_node_id, run_result.edge_source_handle)
reachable_node_ids.extend(ids)
else:
@@ -74,6 +73,8 @@ class StreamProcessor(ABC):
self._remove_node_ids_in_unreachable_branch(node_id, reachable_node_ids)
def _fetch_node_ids_in_reachable_branch(self, node_id: str, branch_identify: Optional[str] = None) -> list[str]:
+ if node_id not in self.rest_node_ids:
+ self.rest_node_ids.append(node_id)
node_ids = []
for edge in self.graph.edge_mapping.get(node_id, []):
if edge.target_node_id == self.graph.root_node_id:
diff --git a/api/core/workflow/nodes/base/node.py b/api/core/workflow/nodes/base/node.py
index 7da0c19740..6973401429 100644
--- a/api/core/workflow/nodes/base/node.py
+++ b/api/core/workflow/nodes/base/node.py
@@ -1,7 +1,7 @@
import logging
from abc import abstractmethod
from collections.abc import Generator, Mapping, Sequence
-from typing import TYPE_CHECKING, Any, Generic, Optional, TypeVar, Union, cast
+from typing import TYPE_CHECKING, Any, ClassVar, Generic, Optional, TypeVar, Union, cast
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
@@ -23,7 +23,7 @@ GenericNodeData = TypeVar("GenericNodeData", bound=BaseNodeData)
class BaseNode(Generic[GenericNodeData]):
_node_data_cls: type[GenericNodeData]
- _node_type: NodeType
+ _node_type: ClassVar[NodeType]
def __init__(
self,
@@ -90,8 +90,38 @@ class BaseNode(Generic[GenericNodeData]):
graph_config: Mapping[str, Any],
config: Mapping[str, Any],
) -> Mapping[str, Sequence[str]]:
- """
- Extract variable selector to variable mapping
+ """Extracts references variable selectors from node configuration.
+
+ The `config` parameter represents the configuration for a specific node type and corresponds
+ to the `data` field in the node definition object.
+
+ The returned mapping has the following structure:
+
+ {'1747829548239.#1747829667553.result#': ['1747829667553', 'result']}
+
+ For loop and iteration nodes, the mapping may look like this:
+
+ {
+ "1748332301644.input_selector": ["1748332363630", "result"],
+ "1748332325079.1748332325079.#sys.workflow_id#": ["sys", "workflow_id"],
+ }
+
+ where `1748332301644` is the ID of the loop / iteration node,
+ and `1748332325079` is the ID of the node inside the loop or iteration node.
+
+ Here, the key consists of two parts: the current node ID (provided as the `node_id`
+ parameter to `_extract_variable_selector_to_variable_mapping`) and the variable selector,
+ enclosed in `#` symbols. These two parts are separated by a dot (`.`).
+
+ The value is a list of string representing the variable selector, where the first element is the node ID
+ of the referenced variable, and the second element is the variable name within that node.
+
+ The meaning of the above response is:
+
+ The node with ID `1747829548239` references the variable `result` from the node with
+ ID `1747829667553`. For example, if `1747829548239` is a LLM node, its prompt may contain a
+ reference to the `result` output variable of node `1747829667553`.
+
:param graph_config: graph config
:param config: node config
:return:
@@ -101,9 +131,10 @@ class BaseNode(Generic[GenericNodeData]):
raise ValueError("Node ID is required when extracting variable selector to variable mapping.")
node_data = cls._node_data_cls(**config.get("data", {}))
- return cls._extract_variable_selector_to_variable_mapping(
+ data = cls._extract_variable_selector_to_variable_mapping(
graph_config=graph_config, node_id=node_id, node_data=cast(GenericNodeData, node_data)
)
+ return data
@classmethod
def _extract_variable_selector_to_variable_mapping(
@@ -139,6 +170,16 @@ class BaseNode(Generic[GenericNodeData]):
"""
return self._node_type
+ @classmethod
+ @abstractmethod
+ def version(cls) -> str:
+ """`node_version` returns the version of current node type."""
+ # NOTE(QuantumGhost): This should be in sync with `NODE_TYPE_CLASSES_MAPPING`.
+ #
+ # If you have introduced a new node type, please add it to `NODE_TYPE_CLASSES_MAPPING`
+ # in `api/core/workflow/nodes/__init__.py`.
+ raise NotImplementedError("subclasses of BaseNode must implement `version` method.")
+
@property
def should_continue_on_error(self) -> bool:
"""judge if should continue on error
diff --git a/api/core/workflow/nodes/code/code_node.py b/api/core/workflow/nodes/code/code_node.py
index 61c08a7d71..22ed9e2651 100644
--- a/api/core/workflow/nodes/code/code_node.py
+++ b/api/core/workflow/nodes/code/code_node.py
@@ -40,6 +40,10 @@ class CodeNode(BaseNode[CodeNodeData]):
return code_provider.get_default_config()
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self) -> NodeRunResult:
# Get code language
code_language = self.node_data.code_language
@@ -126,6 +130,9 @@ class CodeNode(BaseNode[CodeNodeData]):
prefix: str = "",
depth: int = 1,
):
+ # TODO(QuantumGhost): Replace native Python lists with `Array*Segment` classes.
+ # Note that `_transform_result` may produce lists containing `None` values,
+ # which don't conform to the type requirements of `Array*Segment` classes.
if depth > dify_config.CODE_MAX_DEPTH:
raise DepthLimitError(f"Depth limit {dify_config.CODE_MAX_DEPTH} reached, object too deep.")
diff --git a/api/core/workflow/nodes/document_extractor/node.py b/api/core/workflow/nodes/document_extractor/node.py
index 429fed2d04..8e6150f9cc 100644
--- a/api/core/workflow/nodes/document_extractor/node.py
+++ b/api/core/workflow/nodes/document_extractor/node.py
@@ -24,7 +24,7 @@ from configs import dify_config
from core.file import File, FileTransferMethod, file_manager
from core.helper import ssrf_proxy
from core.variables import ArrayFileSegment
-from core.variables.segments import FileSegment
+from core.variables.segments import ArrayStringSegment, FileSegment
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
from core.workflow.nodes.base import BaseNode
@@ -45,6 +45,10 @@ class DocumentExtractorNode(BaseNode[DocumentExtractorNodeData]):
_node_data_cls = DocumentExtractorNodeData
_node_type = NodeType.DOCUMENT_EXTRACTOR
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self):
variable_selector = self.node_data.variable_selector
variable = self.graph_runtime_state.variable_pool.get(variable_selector)
@@ -67,7 +71,7 @@ class DocumentExtractorNode(BaseNode[DocumentExtractorNodeData]):
status=WorkflowNodeExecutionStatus.SUCCEEDED,
inputs=inputs,
process_data=process_data,
- outputs={"text": extracted_text_list},
+ outputs={"text": ArrayStringSegment(value=extracted_text_list)},
)
elif isinstance(value, File):
extracted_text = _extract_text_from_file(value)
@@ -447,7 +451,7 @@ def _extract_text_from_excel(file_content: bytes) -> str:
df = df.applymap(lambda x: " ".join(str(x).splitlines()) if isinstance(x, str) else x) # type: ignore
# Combine multi-line text in column names into a single line
- df.columns = pd.Index([" ".join(col.splitlines()) for col in df.columns])
+ df.columns = pd.Index([" ".join(str(col).splitlines()) for col in df.columns])
# Manually construct the Markdown table
markdown_table += _construct_markdown_table(df) + "\n\n"
diff --git a/api/core/workflow/nodes/end/end_node.py b/api/core/workflow/nodes/end/end_node.py
index 0e9756b243..17a0b3adeb 100644
--- a/api/core/workflow/nodes/end/end_node.py
+++ b/api/core/workflow/nodes/end/end_node.py
@@ -9,6 +9,10 @@ class EndNode(BaseNode[EndNodeData]):
_node_data_cls = EndNodeData
_node_type = NodeType.END
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self) -> NodeRunResult:
"""
Run node
diff --git a/api/core/workflow/nodes/end/end_stream_processor.py b/api/core/workflow/nodes/end/end_stream_processor.py
index 3ae5af7137..a6fb2ffc18 100644
--- a/api/core/workflow/nodes/end/end_stream_processor.py
+++ b/api/core/workflow/nodes/end/end_stream_processor.py
@@ -139,6 +139,7 @@ class EndStreamProcessor(StreamProcessor):
route_node_state=event.route_node_state,
parallel_id=event.parallel_id,
parallel_start_node_id=event.parallel_start_node_id,
+ node_version=event.node_version,
)
self.route_position[end_node_id] += 1
diff --git a/api/core/workflow/nodes/event/event.py b/api/core/workflow/nodes/event/event.py
index b72d111f49..3ebe80f245 100644
--- a/api/core/workflow/nodes/event/event.py
+++ b/api/core/workflow/nodes/event/event.py
@@ -6,7 +6,6 @@ from pydantic import BaseModel, Field
from core.model_runtime.entities.llm_entities import LLMUsage
from core.rag.entities.citation_metadata import RetrievalSourceMetadata
from core.workflow.entities.node_entities import NodeRunResult
-from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
class RunCompletedEvent(BaseModel):
@@ -39,11 +38,3 @@ class RunRetryEvent(BaseModel):
error: str = Field(..., description="error")
retry_index: int = Field(..., description="Retry attempt number")
start_at: datetime = Field(..., description="Retry start time")
-
-
-class SingleStepRetryEvent(NodeRunResult):
- """Single step retry event"""
-
- status: WorkflowNodeExecutionStatus = WorkflowNodeExecutionStatus.RETRY
-
- elapsed_time: float = Field(..., description="elapsed time")
diff --git a/api/core/workflow/nodes/http_request/executor.py b/api/core/workflow/nodes/http_request/executor.py
index 2c83b00d4a..8ac1ae8526 100644
--- a/api/core/workflow/nodes/http_request/executor.py
+++ b/api/core/workflow/nodes/http_request/executor.py
@@ -8,6 +8,7 @@ from typing import Any, Literal
from urllib.parse import urlencode, urlparse
import httpx
+from json_repair import repair_json
from configs import dify_config
from core.file import file_manager
@@ -178,7 +179,8 @@ class Executor:
raise RequestBodyError("json body type should have exactly one item")
json_string = self.variable_pool.convert_template(data[0].value).text
try:
- json_object = json.loads(json_string, strict=False)
+ repaired = repair_json(json_string)
+ json_object = json.loads(repaired, strict=False)
except json.JSONDecodeError as e:
raise RequestBodyError(f"Failed to parse JSON: {json_string}") from e
self.json = json_object
@@ -333,7 +335,7 @@ class Executor:
try:
response = getattr(ssrf_proxy, self.method.lower())(**request_args)
except (ssrf_proxy.MaxRetriesExceededError, httpx.RequestError) as e:
- raise HttpRequestNodeError(str(e))
+ raise HttpRequestNodeError(str(e)) from e
# FIXME: fix type ignore, this maybe httpx type issue
return response # type: ignore
diff --git a/api/core/workflow/nodes/http_request/node.py b/api/core/workflow/nodes/http_request/node.py
index 6b1ac57c06..971e0f73e7 100644
--- a/api/core/workflow/nodes/http_request/node.py
+++ b/api/core/workflow/nodes/http_request/node.py
@@ -6,6 +6,7 @@ from typing import Any, Optional
from configs import dify_config
from core.file import File, FileTransferMethod
from core.tools.tool_file_manager import ToolFileManager
+from core.variables.segments import ArrayFileSegment
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.entities.variable_entities import VariableSelector
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
@@ -60,6 +61,10 @@ class HttpRequestNode(BaseNode[HttpRequestNodeData]):
},
}
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self) -> NodeRunResult:
process_data = {}
try:
@@ -92,7 +97,7 @@ class HttpRequestNode(BaseNode[HttpRequestNodeData]):
status=WorkflowNodeExecutionStatus.SUCCEEDED,
outputs={
"status_code": response.status_code,
- "body": response.text if not files else "",
+ "body": response.text if not files.value else "",
"headers": response.headers,
"files": files,
},
@@ -166,7 +171,7 @@ class HttpRequestNode(BaseNode[HttpRequestNodeData]):
return mapping
- def extract_files(self, url: str, response: Response) -> list[File]:
+ def extract_files(self, url: str, response: Response) -> ArrayFileSegment:
"""
Extract files from response by checking both Content-Type header and URL
"""
@@ -178,7 +183,7 @@ class HttpRequestNode(BaseNode[HttpRequestNodeData]):
content_disposition_type = None
if not is_file:
- return files
+ return ArrayFileSegment(value=[])
if parsed_content_disposition:
content_disposition_filename = parsed_content_disposition.get_filename()
@@ -211,4 +216,4 @@ class HttpRequestNode(BaseNode[HttpRequestNodeData]):
)
files.append(file)
- return files
+ return ArrayFileSegment(value=files)
diff --git a/api/core/workflow/nodes/if_else/if_else_node.py b/api/core/workflow/nodes/if_else/if_else_node.py
index 976922f75d..22b748030c 100644
--- a/api/core/workflow/nodes/if_else/if_else_node.py
+++ b/api/core/workflow/nodes/if_else/if_else_node.py
@@ -1,4 +1,5 @@
-from typing import Literal
+from collections.abc import Mapping, Sequence
+from typing import Any, Literal
from typing_extensions import deprecated
@@ -16,6 +17,10 @@ class IfElseNode(BaseNode[IfElseNodeData]):
_node_data_cls = IfElseNodeData
_node_type = NodeType.IF_ELSE
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self) -> NodeRunResult:
"""
Run node
@@ -87,6 +92,22 @@ class IfElseNode(BaseNode[IfElseNodeData]):
return data
+ @classmethod
+ def _extract_variable_selector_to_variable_mapping(
+ cls,
+ *,
+ graph_config: Mapping[str, Any],
+ node_id: str,
+ node_data: IfElseNodeData,
+ ) -> Mapping[str, Sequence[str]]:
+ var_mapping: dict[str, list[str]] = {}
+ for case in node_data.cases or []:
+ for condition in case.conditions:
+ key = "{}.#{}#".format(node_id, ".".join(condition.variable_selector))
+ var_mapping[key] = condition.variable_selector
+
+ return var_mapping
+
@deprecated("This function is deprecated. You should use the new cases structure.")
def _should_not_use_old_function(
diff --git a/api/core/workflow/nodes/iteration/iteration_node.py b/api/core/workflow/nodes/iteration/iteration_node.py
index 2592823540..8b566c83cd 100644
--- a/api/core/workflow/nodes/iteration/iteration_node.py
+++ b/api/core/workflow/nodes/iteration/iteration_node.py
@@ -1,5 +1,6 @@
import contextvars
import logging
+import time
import uuid
from collections.abc import Generator, Mapping, Sequence
from concurrent.futures import Future, wait
@@ -7,10 +8,11 @@ from datetime import UTC, datetime
from queue import Empty, Queue
from typing import TYPE_CHECKING, Any, Optional, cast
-from flask import Flask, current_app, has_request_context
+from flask import Flask, current_app
from configs import dify_config
from core.variables import ArrayVariable, IntegerVariable, NoneVariable
+from core.variables.segments import ArrayAnySegment, ArraySegment
from core.workflow.entities.node_entities import (
NodeRunResult,
)
@@ -37,6 +39,8 @@ from core.workflow.nodes.base import BaseNode
from core.workflow.nodes.enums import NodeType
from core.workflow.nodes.event import NodeEvent, RunCompletedEvent
from core.workflow.nodes.iteration.entities import ErrorHandleMode, IterationNodeData
+from factories.variable_factory import build_segment
+from libs.flask_utils import preserve_flask_contexts
from .exc import (
InvalidIteratorValueError,
@@ -71,6 +75,10 @@ class IterationNode(BaseNode[IterationNodeData]):
},
}
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self) -> Generator[NodeEvent | InNodeEvent, None, None]:
"""
Run the node.
@@ -84,10 +92,17 @@ class IterationNode(BaseNode[IterationNodeData]):
raise InvalidIteratorValueError(f"invalid iterator value: {variable}, please provide a list.")
if isinstance(variable, NoneVariable) or len(variable.value) == 0:
+ # Try our best to preserve the type informat.
+ if isinstance(variable, ArraySegment):
+ output = variable.model_copy(update={"value": []})
+ else:
+ output = ArrayAnySegment(value=[])
yield RunCompletedEvent(
run_result=NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
- outputs={"output": []},
+ # TODO(QuantumGhost): is it possible to compute the type of `output`
+ # from graph definition?
+ outputs={"output": output},
)
)
return
@@ -119,8 +134,11 @@ class IterationNode(BaseNode[IterationNodeData]):
variable_pool.add([self.node_id, "item"], iterator_list_value[0])
# init graph engine
+ from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState
from core.workflow.graph_engine.graph_engine import GraphEngine, GraphEngineThreadPool
+ graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
+
graph_engine = GraphEngine(
tenant_id=self.tenant_id,
app_id=self.app_id,
@@ -132,7 +150,7 @@ class IterationNode(BaseNode[IterationNodeData]):
call_depth=self.workflow_call_depth,
graph=iteration_graph,
graph_config=graph_config,
- variable_pool=variable_pool,
+ graph_runtime_state=graph_runtime_state,
max_execution_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS,
max_execution_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME,
thread_pool_id=self.thread_pool_id,
@@ -230,6 +248,7 @@ class IterationNode(BaseNode[IterationNodeData]):
# Flatten the list of lists
if isinstance(outputs, list) and all(isinstance(output, list) for output in outputs):
outputs = [item for sublist in outputs for item in sublist]
+ output_segment = build_segment(outputs)
yield IterationRunSucceededEvent(
iteration_id=self.id,
@@ -246,7 +265,7 @@ class IterationNode(BaseNode[IterationNodeData]):
yield RunCompletedEvent(
run_result=NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
- outputs={"output": outputs},
+ outputs={"output": output_segment},
metadata={
WorkflowNodeExecutionMetadataKey.ITERATION_DURATION_MAP: iter_run_map,
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: graph_engine.graph_runtime_state.total_tokens,
@@ -502,18 +521,52 @@ class IterationNode(BaseNode[IterationNodeData]):
)
return
elif self.node_data.error_handle_mode == ErrorHandleMode.TERMINATED:
- yield IterationRunFailedEvent(
- iteration_id=self.id,
- iteration_node_id=self.node_id,
- iteration_node_type=self.node_type,
- iteration_node_data=self.node_data,
- start_at=start_at,
- inputs=inputs,
- outputs={"output": None},
- steps=len(iterator_list_value),
- metadata={"total_tokens": graph_engine.graph_runtime_state.total_tokens},
- error=event.error,
+ yield NodeInIterationFailedEvent(
+ **metadata_event.model_dump(),
+ )
+ outputs[current_index] = None
+
+ # clean nodes resources
+ for node_id in iteration_graph.node_ids:
+ variable_pool.remove([node_id])
+
+ # iteration run failed
+ if self.node_data.is_parallel:
+ yield IterationRunFailedEvent(
+ iteration_id=self.id,
+ iteration_node_id=self.node_id,
+ iteration_node_type=self.node_type,
+ iteration_node_data=self.node_data,
+ parallel_mode_run_id=parallel_mode_run_id,
+ start_at=start_at,
+ inputs=inputs,
+ outputs={"output": outputs},
+ steps=len(iterator_list_value),
+ metadata={"total_tokens": graph_engine.graph_runtime_state.total_tokens},
+ error=event.error,
+ )
+ else:
+ yield IterationRunFailedEvent(
+ iteration_id=self.id,
+ iteration_node_id=self.node_id,
+ iteration_node_type=self.node_type,
+ iteration_node_data=self.node_data,
+ start_at=start_at,
+ inputs=inputs,
+ outputs={"output": outputs},
+ steps=len(iterator_list_value),
+ metadata={"total_tokens": graph_engine.graph_runtime_state.total_tokens},
+ error=event.error,
+ )
+
+ # stop the iterator
+ yield RunCompletedEvent(
+ run_result=NodeRunResult(
+ status=WorkflowNodeExecutionStatus.FAILED,
+ error=event.error,
+ )
)
+ return
yield metadata_event
current_output_segment = variable_pool.get(self.node_data.output_selector)
@@ -583,23 +636,8 @@ class IterationNode(BaseNode[IterationNodeData]):
"""
run single iteration in parallel mode
"""
- for var, val in context.items():
- var.set(val)
-
- # FIXME(-LAN-): Save current user before entering new app context
- from flask import g
-
- saved_user = None
- if has_request_context() and hasattr(g, "_login_user"):
- saved_user = g._login_user
-
- with flask_app.app_context():
- # Restore user in new app context
- if saved_user is not None:
- from flask import g
-
- g._login_user = saved_user
+ with preserve_flask_contexts(flask_app, context_vars=context):
parallel_mode_run_id = uuid.uuid4().hex
graph_engine_copy = graph_engine.create_copy()
variable_pool_copy = graph_engine_copy.graph_runtime_state.variable_pool
diff --git a/api/core/workflow/nodes/iteration/iteration_start_node.py b/api/core/workflow/nodes/iteration/iteration_start_node.py
index bee481ebdb..9900aa225d 100644
--- a/api/core/workflow/nodes/iteration/iteration_start_node.py
+++ b/api/core/workflow/nodes/iteration/iteration_start_node.py
@@ -13,6 +13,10 @@ class IterationStartNode(BaseNode[IterationStartNodeData]):
_node_data_cls = IterationStartNodeData
_node_type = NodeType.ITERATION_START
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self) -> NodeRunResult:
"""
Run the node.
diff --git a/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py b/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py
index 5cf5848d54..f05d93d83e 100644
--- a/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py
+++ b/api/core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py
@@ -24,6 +24,7 @@ from core.rag.entities.metadata_entities import Condition, MetadataCondition
from core.rag.retrieval.dataset_retrieval import DatasetRetrieval
from core.rag.retrieval.retrieval_methods import RetrievalMethod
from core.variables import StringSegment
+from core.variables.segments import ArrayObjectSegment
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
from core.workflow.nodes.enums import NodeType
@@ -70,6 +71,10 @@ class KnowledgeRetrievalNode(LLMNode):
_node_data_cls = KnowledgeRetrievalNodeData # type: ignore
_node_type = NodeType.KNOWLEDGE_RETRIEVAL
+ @classmethod
+ def version(cls):
+ return "1"
+
def _run(self) -> NodeRunResult: # type: ignore
node_data = cast(KnowledgeRetrievalNodeData, self.node_data)
# extract variables
@@ -115,9 +120,12 @@ class KnowledgeRetrievalNode(LLMNode):
# retrieve knowledge
try:
results = self._fetch_dataset_retriever(node_data=node_data, query=query)
- outputs = {"result": results}
+ outputs = {"result": ArrayObjectSegment(value=results)}
return NodeRunResult(
- status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=variables, process_data=None, outputs=outputs
+ status=WorkflowNodeExecutionStatus.SUCCEEDED,
+ inputs=variables,
+ process_data=None,
+ outputs=outputs, # type: ignore
)
except KnowledgeRetrievalNodeError as e:
@@ -136,6 +144,8 @@ class KnowledgeRetrievalNode(LLMNode):
error=str(e),
error_type=type(e).__name__,
)
+ finally:
+ db.session.close()
def _fetch_dataset_retriever(self, node_data: KnowledgeRetrievalNodeData, query: str) -> list[dict[str, Any]]:
available_datasets = []
@@ -163,6 +173,9 @@ class KnowledgeRetrievalNode(LLMNode):
.all()
)
+ # avoid blocking at retrieval
+ db.session.close()
+
for dataset in results:
# pass if dataset is not available
if not dataset:
@@ -482,6 +495,9 @@ class KnowledgeRetrievalNode(LLMNode):
def _process_metadata_filter_func(
self, sequence: int, condition: str, metadata_name: str, value: Optional[Any], filters: list
):
+ if value is None:
+ return
+
key = f"{metadata_name}_{sequence}"
key_value = f"{metadata_name}_{sequence}_value"
match condition:
diff --git a/api/core/workflow/nodes/list_operator/node.py b/api/core/workflow/nodes/list_operator/node.py
index e698d3f5d8..3c9ba44cf1 100644
--- a/api/core/workflow/nodes/list_operator/node.py
+++ b/api/core/workflow/nodes/list_operator/node.py
@@ -3,6 +3,7 @@ from typing import Any, Literal, Union
from core.file import File
from core.variables import ArrayFileSegment, ArrayNumberSegment, ArrayStringSegment
+from core.variables.segments import ArrayAnySegment, ArraySegment
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
from core.workflow.nodes.base import BaseNode
@@ -16,6 +17,10 @@ class ListOperatorNode(BaseNode[ListOperatorNodeData]):
_node_data_cls = ListOperatorNodeData
_node_type = NodeType.LIST_OPERATOR
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self):
inputs: dict[str, list] = {}
process_data: dict[str, list] = {}
@@ -30,7 +35,11 @@ class ListOperatorNode(BaseNode[ListOperatorNodeData]):
if not variable.value:
inputs = {"variable": []}
process_data = {"variable": []}
- outputs = {"result": [], "first_record": None, "last_record": None}
+ if isinstance(variable, ArraySegment):
+ result = variable.model_copy(update={"value": []})
+ else:
+ result = ArrayAnySegment(value=[])
+ outputs = {"result": result, "first_record": None, "last_record": None}
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
inputs=inputs,
@@ -71,7 +80,7 @@ class ListOperatorNode(BaseNode[ListOperatorNodeData]):
variable = self._apply_slice(variable)
outputs = {
- "result": variable.value,
+ "result": variable,
"first_record": variable.value[0] if variable.value else None,
"last_record": variable.value[-1] if variable.value else None,
}
diff --git a/api/core/workflow/nodes/llm/file_saver.py b/api/core/workflow/nodes/llm/file_saver.py
index c85baade03..a4b45ce652 100644
--- a/api/core/workflow/nodes/llm/file_saver.py
+++ b/api/core/workflow/nodes/llm/file_saver.py
@@ -119,9 +119,6 @@ class FileSaverImpl(LLMFileSaver):
size=len(data),
related_id=tool_file.id,
url=url,
- # TODO(QuantumGhost): how should I set the following key?
- # What's the difference between `remote_url` and `url`?
- # What's the purpose of `storage_key` and `dify_model_identity`?
storage_key=tool_file.file_key,
)
diff --git a/api/core/workflow/nodes/llm/node.py b/api/core/workflow/nodes/llm/node.py
index d27124d62c..9bfb402dc8 100644
--- a/api/core/workflow/nodes/llm/node.py
+++ b/api/core/workflow/nodes/llm/node.py
@@ -5,11 +5,11 @@ import logging
from collections.abc import Generator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Optional, cast
-import json_repair
-
from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity
from core.file import FileType, file_manager
from core.helper.code_executor import CodeExecutor, CodeLanguage
+from core.llm_generator.output_parser.errors import OutputParserError
+from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output
from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_manager import ModelInstance, ModelManager
from core.model_runtime.entities import (
@@ -18,7 +18,13 @@ from core.model_runtime.entities import (
PromptMessageContentType,
TextPromptMessageContent,
)
-from core.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMUsage
+from core.model_runtime.entities.llm_entities import (
+ LLMResult,
+ LLMResultChunk,
+ LLMResultChunkWithStructuredOutput,
+ LLMStructuredOutput,
+ LLMUsage,
+)
from core.model_runtime.entities.message_entities import (
AssistantPromptMessage,
PromptMessageContentUnionTypes,
@@ -31,7 +37,6 @@ from core.model_runtime.entities.model_entities import (
ModelFeature,
ModelPropertyKey,
ModelType,
- ParameterRule,
)
from core.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel
from core.model_runtime.utils.encoders import jsonable_encoder
@@ -62,11 +67,6 @@ from core.workflow.nodes.event import (
RunRetrieverResourceEvent,
RunStreamChunkEvent,
)
-from core.workflow.utils.structured_output.entities import (
- ResponseFormat,
- SpecialModelType,
-)
-from core.workflow.utils.structured_output.prompt import STRUCTURED_OUTPUT_PROMPT
from core.workflow.utils.variable_template_parser import VariableTemplateParser
from . import llm_utils
@@ -138,13 +138,11 @@ class LLMNode(BaseNode[LLMNodeData]):
)
self._llm_file_saver = llm_file_saver
- def _run(self) -> Generator[NodeEvent | InNodeEvent, None, None]:
- def process_structured_output(text: str) -> Optional[dict[str, Any]]:
- """Process structured output if enabled"""
- if not self.node_data.structured_output_enabled or not self.node_data.structured_output:
- return None
- return self._parse_structured_output(text)
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+ def _run(self) -> Generator[NodeEvent | InNodeEvent, None, None]:
node_inputs: Optional[dict[str, Any]] = None
process_data = None
result_text = ""
@@ -223,15 +221,6 @@ class LLMNode(BaseNode[LLMNodeData]):
jinja2_variables=self.node_data.prompt_config.jinja2_variables,
)
- process_data = {
- "model_mode": model_config.mode,
- "prompts": PromptMessageUtil.prompt_messages_to_prompt_for_saving(
- model_mode=model_config.mode, prompt_messages=prompt_messages
- ),
- "model_provider": model_config.provider,
- "model_name": model_config.model,
- }
-
# handle invoke result
generator = self._invoke_llm(
node_data_model=self.node_data.model,
@@ -240,6 +229,8 @@ class LLMNode(BaseNode[LLMNodeData]):
stop=stop,
)
+ structured_output: LLMStructuredOutput | None = None
+
for event in generator:
if isinstance(event, RunStreamChunkEvent):
yield event
@@ -250,12 +241,25 @@ class LLMNode(BaseNode[LLMNodeData]):
# deduct quota
llm_utils.deduct_llm_quota(tenant_id=self.tenant_id, model_instance=model_instance, usage=usage)
break
+ elif isinstance(event, LLMStructuredOutput):
+ structured_output = event
+
+ process_data = {
+ "model_mode": model_config.mode,
+ "prompts": PromptMessageUtil.prompt_messages_to_prompt_for_saving(
+ model_mode=model_config.mode, prompt_messages=prompt_messages
+ ),
+ "usage": jsonable_encoder(usage),
+ "finish_reason": finish_reason,
+ "model_provider": model_config.provider,
+ "model_name": model_config.model,
+ }
+
outputs = {"text": result_text, "usage": jsonable_encoder(usage), "finish_reason": finish_reason}
- structured_output = process_structured_output(result_text)
if structured_output:
- outputs["structured_output"] = structured_output
+ outputs["structured_output"] = structured_output.structured_output
if self._file_outputs is not None:
- outputs["files"] = self._file_outputs
+ outputs["files"] = ArrayFileSegment(value=self._file_outputs)
yield RunCompletedEvent(
run_result=NodeRunResult(
@@ -298,20 +302,40 @@ class LLMNode(BaseNode[LLMNodeData]):
model_instance: ModelInstance,
prompt_messages: Sequence[PromptMessage],
stop: Optional[Sequence[str]] = None,
- ) -> Generator[NodeEvent, None, None]:
- invoke_result = model_instance.invoke_llm(
- prompt_messages=list(prompt_messages),
- model_parameters=node_data_model.completion_params,
- stop=list(stop or []),
- stream=True,
- user=self.user_id,
+ ) -> Generator[NodeEvent | LLMStructuredOutput, None, None]:
+ model_schema = model_instance.model_type_instance.get_model_schema(
+ node_data_model.name, model_instance.credentials
)
+ if not model_schema:
+ raise ValueError(f"Model schema not found for {node_data_model.name}")
+
+ if self.node_data.structured_output_enabled:
+ output_schema = self._fetch_structured_output_schema()
+ invoke_result = invoke_llm_with_structured_output(
+ provider=model_instance.provider,
+ model_schema=model_schema,
+ model_instance=model_instance,
+ prompt_messages=prompt_messages,
+ json_schema=output_schema,
+ model_parameters=node_data_model.completion_params,
+ stop=list(stop or []),
+ stream=True,
+ user=self.user_id,
+ )
+ else:
+ invoke_result = model_instance.invoke_llm(
+ prompt_messages=list(prompt_messages),
+ model_parameters=node_data_model.completion_params,
+ stop=list(stop or []),
+ stream=True,
+ user=self.user_id,
+ )
return self._handle_invoke_result(invoke_result=invoke_result)
def _handle_invoke_result(
- self, invoke_result: LLMResult | Generator[LLMResultChunk, None, None]
- ) -> Generator[NodeEvent, None, None]:
+ self, invoke_result: LLMResult | Generator[LLMResultChunk | LLMStructuredOutput, None, None]
+ ) -> Generator[NodeEvent | LLMStructuredOutput, None, None]:
# For blocking mode
if isinstance(invoke_result, LLMResult):
event = self._handle_blocking_result(invoke_result=invoke_result)
@@ -325,23 +349,32 @@ class LLMNode(BaseNode[LLMNodeData]):
usage = LLMUsage.empty_usage()
finish_reason = None
full_text_buffer = io.StringIO()
- for result in invoke_result:
- contents = result.delta.message.content
- for text_part in self._save_multimodal_output_and_convert_result_to_markdown(contents):
- full_text_buffer.write(text_part)
- yield RunStreamChunkEvent(chunk_content=text_part, from_variable_selector=[self.node_id, "text"])
-
- # Update the whole metadata
- if not model and result.model:
- model = result.model
- if len(prompt_messages) == 0:
- # TODO(QuantumGhost): it seems that this update has no visable effect.
- # What's the purpose of the line below?
- prompt_messages = list(result.prompt_messages)
- if usage.prompt_tokens == 0 and result.delta.usage:
- usage = result.delta.usage
- if finish_reason is None and result.delta.finish_reason:
- finish_reason = result.delta.finish_reason
+ # Consume the invoke result and handle generator exception
+ try:
+ for result in invoke_result:
+ if isinstance(result, LLMResultChunkWithStructuredOutput):
+ yield result
+ if isinstance(result, LLMResultChunk):
+ contents = result.delta.message.content
+ for text_part in self._save_multimodal_output_and_convert_result_to_markdown(contents):
+ full_text_buffer.write(text_part)
+ yield RunStreamChunkEvent(
+ chunk_content=text_part, from_variable_selector=[self.node_id, "text"]
+ )
+
+ # Update the whole metadata
+ if not model and result.model:
+ model = result.model
+ if len(prompt_messages) == 0:
+ # TODO(QuantumGhost): it seems that this update has no visable effect.
+ # What's the purpose of the line below?
+ prompt_messages = list(result.prompt_messages)
+ if usage.prompt_tokens == 0 and result.delta.usage:
+ usage = result.delta.usage
+ if finish_reason is None and result.delta.finish_reason:
+ finish_reason = result.delta.finish_reason
+ except OutputParserError as e:
+ raise LLMNodeError(f"Failed to parse structured output: {e}")
yield ModelInvokeCompletedEvent(text=full_text_buffer.getvalue(), usage=usage, finish_reason=finish_reason)
@@ -518,12 +551,6 @@ class LLMNode(BaseNode[LLMNodeData]):
if not model_schema:
raise ModelNotExistError(f"Model {node_data_model.name} not exist.")
- if self.node_data.structured_output_enabled:
- if model_schema.support_structure_output:
- completion_params = self._handle_native_json_schema(completion_params, model_schema.parameter_rules)
- else:
- # Set appropriate response format based on model capabilities
- self._set_response_format(completion_params, model_schema.parameter_rules)
model_config_with_cred.parameters = completion_params
# NOTE(-LAN-): This line modify the `self.node_data.model`, which is used in `_invoke_llm()`.
node_data_model.completion_params = completion_params
@@ -715,32 +742,8 @@ class LLMNode(BaseNode[LLMNodeData]):
)
if not model_schema:
raise ModelNotExistError(f"Model {model_config.model} not exist.")
- if self.node_data.structured_output_enabled:
- if not model_schema.support_structure_output:
- filtered_prompt_messages = self._handle_prompt_based_schema(
- prompt_messages=filtered_prompt_messages,
- )
return filtered_prompt_messages, model_config.stop
- def _parse_structured_output(self, result_text: str) -> dict[str, Any]:
- structured_output: dict[str, Any] = {}
- try:
- parsed = json.loads(result_text)
- if not isinstance(parsed, dict):
- raise LLMNodeError(f"Failed to parse structured output: {result_text}")
- structured_output = parsed
- except json.JSONDecodeError as e:
- # if the result_text is not a valid json, try to repair it
- parsed = json_repair.loads(result_text)
- if not isinstance(parsed, dict):
- # handle reasoning model like deepseek-r1 got '\n\n\n' prefix
- if isinstance(parsed, list):
- parsed = next((item for item in parsed if isinstance(item, dict)), {})
- else:
- raise LLMNodeError(f"Failed to parse structured output: {result_text}")
- structured_output = parsed
- return structured_output
-
@classmethod
def _extract_variable_selector_to_variable_mapping(
cls,
@@ -930,104 +933,6 @@ class LLMNode(BaseNode[LLMNodeData]):
self._file_outputs.append(saved_file)
return saved_file
- def _handle_native_json_schema(self, model_parameters: dict, rules: list[ParameterRule]) -> dict:
- """
- Handle structured output for models with native JSON schema support.
-
- :param model_parameters: Model parameters to update
- :param rules: Model parameter rules
- :return: Updated model parameters with JSON schema configuration
- """
- # Process schema according to model requirements
- schema = self._fetch_structured_output_schema()
- schema_json = self._prepare_schema_for_model(schema)
-
- # Set JSON schema in parameters
- model_parameters["json_schema"] = json.dumps(schema_json, ensure_ascii=False)
-
- # Set appropriate response format if required by the model
- for rule in rules:
- if rule.name == "response_format" and ResponseFormat.JSON_SCHEMA.value in rule.options:
- model_parameters["response_format"] = ResponseFormat.JSON_SCHEMA.value
-
- return model_parameters
-
- def _handle_prompt_based_schema(self, prompt_messages: Sequence[PromptMessage]) -> list[PromptMessage]:
- """
- Handle structured output for models without native JSON schema support.
- This function modifies the prompt messages to include schema-based output requirements.
-
- Args:
- prompt_messages: Original sequence of prompt messages
-
- Returns:
- list[PromptMessage]: Updated prompt messages with structured output requirements
- """
- # Convert schema to string format
- schema_str = json.dumps(self._fetch_structured_output_schema(), ensure_ascii=False)
-
- # Find existing system prompt with schema placeholder
- system_prompt = next(
- (prompt for prompt in prompt_messages if isinstance(prompt, SystemPromptMessage)),
- None,
- )
- structured_output_prompt = STRUCTURED_OUTPUT_PROMPT.replace("{{schema}}", schema_str)
- # Prepare system prompt content
- system_prompt_content = (
- structured_output_prompt + "\n\n" + system_prompt.content
- if system_prompt and isinstance(system_prompt.content, str)
- else structured_output_prompt
- )
- system_prompt = SystemPromptMessage(content=system_prompt_content)
-
- # Extract content from the last user message
-
- filtered_prompts = [prompt for prompt in prompt_messages if not isinstance(prompt, SystemPromptMessage)]
- updated_prompt = [system_prompt] + filtered_prompts
-
- return updated_prompt
-
- def _set_response_format(self, model_parameters: dict, rules: list) -> None:
- """
- Set the appropriate response format parameter based on model rules.
-
- :param model_parameters: Model parameters to update
- :param rules: Model parameter rules
- """
- for rule in rules:
- if rule.name == "response_format":
- if ResponseFormat.JSON.value in rule.options:
- model_parameters["response_format"] = ResponseFormat.JSON.value
- elif ResponseFormat.JSON_OBJECT.value in rule.options:
- model_parameters["response_format"] = ResponseFormat.JSON_OBJECT.value
-
- def _prepare_schema_for_model(self, schema: dict) -> dict:
- """
- Prepare JSON schema based on model requirements.
-
- Different models have different requirements for JSON schema formatting.
- This function handles these differences.
-
- :param schema: The original JSON schema
- :return: Processed schema compatible with the current model
- """
-
- # Deep copy to avoid modifying the original schema
- processed_schema = schema.copy()
-
- # Convert boolean types to string types (common requirement)
- convert_boolean_to_string(processed_schema)
-
- # Apply model-specific transformations
- if SpecialModelType.GEMINI in self.node_data.model.name:
- remove_additional_properties(processed_schema)
- return processed_schema
- elif SpecialModelType.OLLAMA in self.node_data.model.provider:
- return processed_schema
- else:
- # Default format with name field
- return {"schema": processed_schema, "name": "llm_response"}
-
def _fetch_model_schema(self, provider: str) -> AIModelEntity | None:
"""
Fetch model schema
@@ -1239,49 +1144,3 @@ def _handle_completion_template(
)
prompt_messages.append(prompt_message)
return prompt_messages
-
-
-def remove_additional_properties(schema: dict) -> None:
- """
- Remove additionalProperties fields from JSON schema.
- Used for models like Gemini that don't support this property.
-
- :param schema: JSON schema to modify in-place
- """
- if not isinstance(schema, dict):
- return
-
- # Remove additionalProperties at current level
- schema.pop("additionalProperties", None)
-
- # Process nested structures recursively
- for value in schema.values():
- if isinstance(value, dict):
- remove_additional_properties(value)
- elif isinstance(value, list):
- for item in value:
- if isinstance(item, dict):
- remove_additional_properties(item)
-
-
-def convert_boolean_to_string(schema: dict) -> None:
- """
- Convert boolean type specifications to string in JSON schema.
-
- :param schema: JSON schema to modify in-place
- """
- if not isinstance(schema, dict):
- return
-
- # Check for boolean type at current level
- if schema.get("type") == "boolean":
- schema["type"] = "string"
-
- # Process nested dictionaries and lists recursively
- for value in schema.values():
- if isinstance(value, dict):
- convert_boolean_to_string(value)
- elif isinstance(value, list):
- for item in value:
- if isinstance(item, dict):
- convert_boolean_to_string(item)
diff --git a/api/core/workflow/nodes/loop/loop_end_node.py b/api/core/workflow/nodes/loop/loop_end_node.py
index 327b9e234b..b144021bab 100644
--- a/api/core/workflow/nodes/loop/loop_end_node.py
+++ b/api/core/workflow/nodes/loop/loop_end_node.py
@@ -13,6 +13,10 @@ class LoopEndNode(BaseNode[LoopEndNodeData]):
_node_data_cls = LoopEndNodeData
_node_type = NodeType.LOOP_END
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self) -> NodeRunResult:
"""
Run the node.
diff --git a/api/core/workflow/nodes/loop/loop_node.py b/api/core/workflow/nodes/loop/loop_node.py
index fafa205386..11fd7b6c2d 100644
--- a/api/core/workflow/nodes/loop/loop_node.py
+++ b/api/core/workflow/nodes/loop/loop_node.py
@@ -1,5 +1,6 @@
import json
import logging
+import time
from collections.abc import Generator, Mapping, Sequence
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, Literal, cast
@@ -54,6 +55,10 @@ class LoopNode(BaseNode[LoopNodeData]):
_node_data_cls = LoopNodeData
_node_type = NodeType.LOOP
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self) -> Generator[NodeEvent | InNodeEvent, None, None]:
"""Run the node."""
# Get inputs
@@ -97,8 +102,11 @@ class LoopNode(BaseNode[LoopNodeData]):
loop_variable_selectors[loop_variable.label] = variable_selector
inputs[loop_variable.label] = processed_segment.value
+ from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState
from core.workflow.graph_engine.graph_engine import GraphEngine
+ graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
+
graph_engine = GraphEngine(
tenant_id=self.tenant_id,
app_id=self.app_id,
@@ -110,7 +118,7 @@ class LoopNode(BaseNode[LoopNodeData]):
call_depth=self.workflow_call_depth,
graph=loop_graph,
graph_config=self.graph_config,
- variable_pool=variable_pool,
+ graph_runtime_state=graph_runtime_state,
max_execution_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS,
max_execution_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME,
thread_pool_id=self.thread_pool_id,
@@ -482,6 +490,13 @@ class LoopNode(BaseNode[LoopNodeData]):
variable_mapping.update(sub_node_variable_mapping)
+ for loop_variable in node_data.loop_variables or []:
+ if loop_variable.value_type == "variable":
+ assert loop_variable.value is not None, "Loop variable value must be provided for variable type"
+ # add loop variable to variable mapping
+ selector = loop_variable.value
+ variable_mapping[f"{node_id}.{loop_variable.label}"] = selector
+
# remove variable out from loop
variable_mapping = {
key: value for key, value in variable_mapping.items() if value[0] not in loop_graph.node_ids
diff --git a/api/core/workflow/nodes/loop/loop_start_node.py b/api/core/workflow/nodes/loop/loop_start_node.py
index 5a15f36044..f5e38b7516 100644
--- a/api/core/workflow/nodes/loop/loop_start_node.py
+++ b/api/core/workflow/nodes/loop/loop_start_node.py
@@ -13,6 +13,10 @@ class LoopStartNode(BaseNode[LoopStartNodeData]):
_node_data_cls = LoopStartNodeData
_node_type = NodeType.LOOP_START
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self) -> NodeRunResult:
"""
Run the node.
diff --git a/api/core/workflow/nodes/node_mapping.py b/api/core/workflow/nodes/node_mapping.py
index 1f1be59542..ccfaec4a8c 100644
--- a/api/core/workflow/nodes/node_mapping.py
+++ b/api/core/workflow/nodes/node_mapping.py
@@ -25,6 +25,11 @@ from core.workflow.nodes.variable_assigner.v2 import VariableAssignerNode as Var
LATEST_VERSION = "latest"
+# NOTE(QuantumGhost): This should be in sync with subclasses of BaseNode.
+# Specifically, if you have introduced new node types, you should add them here.
+#
+# TODO(QuantumGhost): This could be automated with either metaclass or `__init_subclass__`
+# hook. Try to avoid duplication of node information.
NODE_TYPE_CLASSES_MAPPING: Mapping[NodeType, Mapping[str, type[BaseNode]]] = {
NodeType.START: {
LATEST_VERSION: StartNode,
@@ -68,6 +73,7 @@ NODE_TYPE_CLASSES_MAPPING: Mapping[NodeType, Mapping[str, type[BaseNode]]] = {
},
NodeType.TOOL: {
LATEST_VERSION: ToolNode,
+ "2": ToolNode,
"1": ToolNode,
},
NodeType.VARIABLE_AGGREGATOR: {
@@ -117,6 +123,7 @@ NODE_TYPE_CLASSES_MAPPING: Mapping[NodeType, Mapping[str, type[BaseNode]]] = {
},
NodeType.AGENT: {
LATEST_VERSION: AgentNode,
+ "2": AgentNode,
"1": AgentNode,
},
}
diff --git a/api/core/workflow/nodes/parameter_extractor/entities.py b/api/core/workflow/nodes/parameter_extractor/entities.py
index 369eb13b04..916778d167 100644
--- a/api/core/workflow/nodes/parameter_extractor/entities.py
+++ b/api/core/workflow/nodes/parameter_extractor/entities.py
@@ -7,6 +7,10 @@ from core.workflow.nodes.base import BaseNodeData
from core.workflow.nodes.llm import ModelConfig, VisionConfig
+class _ParameterConfigError(Exception):
+ pass
+
+
class ParameterConfig(BaseModel):
"""
Parameter Config.
@@ -27,6 +31,19 @@ class ParameterConfig(BaseModel):
raise ValueError("Invalid parameter name, __reason and __is_success are reserved")
return str(value)
+ def is_array_type(self) -> bool:
+ return self.type in ("array[string]", "array[number]", "array[object]")
+
+ def element_type(self) -> Literal["string", "number", "object"]:
+ if self.type == "array[number]":
+ return "number"
+ elif self.type == "array[string]":
+ return "string"
+ elif self.type == "array[object]":
+ return "object"
+ else:
+ raise _ParameterConfigError(f"{self.type} is not array type.")
+
class ParameterExtractorNodeData(BaseNodeData):
"""
diff --git a/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py b/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py
index 2552784762..25a534256b 100644
--- a/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py
+++ b/api/core/workflow/nodes/parameter_extractor/parameter_extractor_node.py
@@ -25,6 +25,7 @@ from core.prompt.advanced_prompt_transform import AdvancedPromptTransform
from core.prompt.entities.advanced_prompt_entities import ChatModelMessage, CompletionModelPromptTemplate
from core.prompt.simple_prompt_transform import ModelMode
from core.prompt.utils.prompt_message_util import PromptMessageUtil
+from core.variables.types import SegmentType
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.entities.variable_pool import VariablePool
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
@@ -32,6 +33,7 @@ from core.workflow.nodes.base.node import BaseNode
from core.workflow.nodes.enums import NodeType
from core.workflow.nodes.llm import ModelConfig, llm_utils
from core.workflow.utils import variable_template_parser
+from factories.variable_factory import build_segment_with_type
from .entities import ParameterExtractorNodeData
from .exc import (
@@ -109,6 +111,10 @@ class ParameterExtractorNode(BaseNode):
}
}
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self):
"""
Run the node.
@@ -247,7 +253,12 @@ class ParameterExtractorNode(BaseNode):
status=WorkflowNodeExecutionStatus.SUCCEEDED,
inputs=inputs,
process_data=process_data,
- outputs={"__is_success": 1 if not error else 0, "__reason": error, **result},
+ outputs={
+ "__is_success": 1 if not error else 0,
+ "__reason": error,
+ "__usage": jsonable_encoder(usage),
+ **result,
+ },
metadata={
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: usage.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: usage.total_price,
@@ -584,28 +595,30 @@ class ParameterExtractorNode(BaseNode):
elif parameter.type in {"string", "select"}:
if isinstance(result[parameter.name], str):
transformed_result[parameter.name] = result[parameter.name]
- elif parameter.type.startswith("array"):
+ elif parameter.is_array_type():
if isinstance(result[parameter.name], list):
- nested_type = parameter.type[6:-1]
- transformed_result[parameter.name] = []
+ nested_type = parameter.element_type()
+ assert nested_type is not None
+ segment_value = build_segment_with_type(segment_type=SegmentType(parameter.type), value=[])
+ transformed_result[parameter.name] = segment_value
for item in result[parameter.name]:
if nested_type == "number":
if isinstance(item, int | float):
- transformed_result[parameter.name].append(item)
+ segment_value.value.append(item)
elif isinstance(item, str):
try:
if "." in item:
- transformed_result[parameter.name].append(float(item))
+ segment_value.value.append(float(item))
else:
- transformed_result[parameter.name].append(int(item))
+ segment_value.value.append(int(item))
except ValueError:
pass
elif nested_type == "string":
if isinstance(item, str):
- transformed_result[parameter.name].append(item)
+ segment_value.value.append(item)
elif nested_type == "object":
if isinstance(item, dict):
- transformed_result[parameter.name].append(item)
+ segment_value.value.append(item)
if parameter.name not in transformed_result:
if parameter.type == "number":
@@ -615,7 +628,9 @@ class ParameterExtractorNode(BaseNode):
elif parameter.type in {"string", "select"}:
transformed_result[parameter.name] = ""
elif parameter.type.startswith("array"):
- transformed_result[parameter.name] = []
+ transformed_result[parameter.name] = build_segment_with_type(
+ segment_type=SegmentType(parameter.type), value=[]
+ )
return transformed_result
diff --git a/api/core/workflow/nodes/question_classifier/question_classifier_node.py b/api/core/workflow/nodes/question_classifier/question_classifier_node.py
index 1f50700c7e..74024ed90c 100644
--- a/api/core/workflow/nodes/question_classifier/question_classifier_node.py
+++ b/api/core/workflow/nodes/question_classifier/question_classifier_node.py
@@ -40,6 +40,10 @@ class QuestionClassifierNode(LLMNode):
_node_data_cls = QuestionClassifierNodeData # type: ignore
_node_type = NodeType.QUESTION_CLASSIFIER
+ @classmethod
+ def version(cls):
+ return "1"
+
def _run(self):
node_data = cast(QuestionClassifierNodeData, self.node_data)
variable_pool = self.graph_runtime_state.variable_pool
@@ -141,7 +145,11 @@ class QuestionClassifierNode(LLMNode):
"model_provider": model_config.provider,
"model_name": model_config.model,
}
- outputs = {"class_name": category_name, "class_id": category_id}
+ outputs = {
+ "class_name": category_name,
+ "class_id": category_id,
+ "usage": jsonable_encoder(usage),
+ }
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
diff --git a/api/core/workflow/nodes/start/start_node.py b/api/core/workflow/nodes/start/start_node.py
index 8839aec9d6..5ee9bc331f 100644
--- a/api/core/workflow/nodes/start/start_node.py
+++ b/api/core/workflow/nodes/start/start_node.py
@@ -10,6 +10,10 @@ class StartNode(BaseNode[StartNodeData]):
_node_data_cls = StartNodeData
_node_type = NodeType.START
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self) -> NodeRunResult:
node_inputs = dict(self.graph_runtime_state.variable_pool.user_inputs)
system_inputs = self.graph_runtime_state.variable_pool.system_variables
@@ -18,5 +22,6 @@ class StartNode(BaseNode[StartNodeData]):
# Set system variables as node outputs.
for var in system_inputs:
node_inputs[SYSTEM_VARIABLE_NODE_ID + "." + var] = system_inputs[var]
+ outputs = dict(node_inputs)
- return NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=node_inputs, outputs=node_inputs)
+ return NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED, inputs=node_inputs, outputs=outputs)
diff --git a/api/core/workflow/nodes/template_transform/template_transform_node.py b/api/core/workflow/nodes/template_transform/template_transform_node.py
index 476cf7eee4..ba573074c3 100644
--- a/api/core/workflow/nodes/template_transform/template_transform_node.py
+++ b/api/core/workflow/nodes/template_transform/template_transform_node.py
@@ -28,6 +28,10 @@ class TemplateTransformNode(BaseNode[TemplateTransformNodeData]):
"config": {"variables": [{"variable": "arg1", "value_selector": []}], "template": "{{ arg1 }}"},
}
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self) -> NodeRunResult:
# Get variables
variables = {}
diff --git a/api/core/workflow/nodes/tool/entities.py b/api/core/workflow/nodes/tool/entities.py
index 21023d4ab7..691f6e0196 100644
--- a/api/core/workflow/nodes/tool/entities.py
+++ b/api/core/workflow/nodes/tool/entities.py
@@ -41,6 +41,10 @@ class ToolNodeData(BaseNodeData, ToolEntity):
def check_type(cls, value, validation_info: ValidationInfo):
typ = value
value = validation_info.data.get("value")
+
+ if value is None:
+ return typ
+
if typ == "mixed" and not isinstance(value, str):
raise ValueError("value must be a string")
elif typ == "variable":
@@ -54,3 +58,22 @@ class ToolNodeData(BaseNodeData, ToolEntity):
return typ
tool_parameters: dict[str, ToolInput]
+
+ @field_validator("tool_parameters", mode="before")
+ @classmethod
+ def filter_none_tool_inputs(cls, value):
+ if not isinstance(value, dict):
+ return value
+
+ return {
+ key: tool_input
+ for key, tool_input in value.items()
+ if tool_input is not None and cls._has_valid_value(tool_input)
+ }
+
+ @staticmethod
+ def _has_valid_value(tool_input):
+ """Check if the value is valid"""
+ if isinstance(tool_input, dict):
+ return tool_input.get("value") is not None
+ return getattr(tool_input, "value", None) is not None
diff --git a/api/core/workflow/nodes/tool/tool_node.py b/api/core/workflow/nodes/tool/tool_node.py
index aaecc7b989..48627a229d 100644
--- a/api/core/workflow/nodes/tool/tool_node.py
+++ b/api/core/workflow/nodes/tool/tool_node.py
@@ -1,18 +1,19 @@
from collections.abc import Generator, Mapping, Sequence
-from typing import Any, cast
+from typing import Any, Optional, cast
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler
from core.file import File, FileTransferMethod
+from core.model_runtime.entities.llm_entities import LLMUsage
from core.plugin.impl.exc import PluginDaemonClientSideError
from core.plugin.impl.plugin import PluginInstaller
from core.tools.entities.tool_entities import ToolInvokeMessage, ToolParameter
from core.tools.errors import ToolInvokeError
from core.tools.tool_engine import ToolEngine
from core.tools.utils.message_transformer import ToolFileMessageTransformer
-from core.variables.segments import ArrayAnySegment
+from core.variables.segments import ArrayAnySegment, ArrayFileSegment
from core.variables.variables import ArrayAnyVariable
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.entities.variable_pool import VariablePool
@@ -44,6 +45,10 @@ class ToolNode(BaseNode[ToolNodeData]):
_node_data_cls = ToolNodeData
_node_type = NodeType.TOOL
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self) -> Generator:
"""
Run the tool node
@@ -62,8 +67,9 @@ class ToolNode(BaseNode[ToolNodeData]):
try:
from core.tools.tool_manager import ToolManager
+ variable_pool = self.graph_runtime_state.variable_pool if self.node_data.version != "1" else None
tool_runtime = ToolManager.get_workflow_tool_runtime(
- self.tenant_id, self.app_id, self.node_id, self.node_data, self.invoke_from
+ self.tenant_id, self.app_id, self.node_id, self.node_data, self.invoke_from, variable_pool
)
except ToolNodeError as e:
yield RunCompletedEvent(
@@ -90,7 +96,6 @@ class ToolNode(BaseNode[ToolNodeData]):
node_data=self.node_data,
for_log=True,
)
-
# get conversation id
conversation_id = self.graph_runtime_state.variable_pool.get(["sys", SystemVariableKey.CONVERSATION_ID])
@@ -163,7 +168,9 @@ class ToolNode(BaseNode[ToolNodeData]):
if tool_input.type == "variable":
variable = variable_pool.get(tool_input.value)
if variable is None:
- raise ToolParameterError(f"Variable {tool_input.value} does not exist")
+ if parameter.required:
+ raise ToolParameterError(f"Variable {tool_input.value} does not exist")
+ continue
parameter_value = variable.value
elif tool_input.type in {"mixed", "constant"}:
segment_group = variable_pool.convert_template(str(tool_input.value))
@@ -184,6 +191,7 @@ class ToolNode(BaseNode[ToolNodeData]):
messages: Generator[ToolInvokeMessage, None, None],
tool_info: Mapping[str, Any],
parameters_for_log: dict[str, Any],
+ agent_thoughts: Optional[list] = None,
) -> Generator:
"""
Convert ToolInvokeMessages into tuple[plain_text, files]
@@ -202,7 +210,7 @@ class ToolNode(BaseNode[ToolNodeData]):
agent_logs: list[AgentLogEvent] = []
agent_execution_metadata: Mapping[WorkflowNodeExecutionMetadataKey, Any] = {}
-
+ llm_usage: LLMUsage | None = None
variables: dict[str, Any] = {}
for message in message_stream:
@@ -270,13 +278,15 @@ class ToolNode(BaseNode[ToolNodeData]):
elif message.type == ToolInvokeMessage.MessageType.JSON:
assert isinstance(message.message, ToolInvokeMessage.JsonMessage)
if self.node_type == NodeType.AGENT:
- msg_metadata = message.message.json_object.pop("execution_metadata", {})
+ msg_metadata: dict[str, Any] = message.message.json_object.pop("execution_metadata", {})
+ llm_usage = LLMUsage.from_metadata(msg_metadata)
agent_execution_metadata = {
- key: value
+ WorkflowNodeExecutionMetadataKey(key): value
for key, value in msg_metadata.items()
if key in WorkflowNodeExecutionMetadataKey.__members__.values()
}
- json.append(message.message.json_object)
+ if message.message.json_object is not None:
+ json.append(message.message.json_object)
elif message.type == ToolInvokeMessage.MessageType.LINK:
assert isinstance(message.message, ToolInvokeMessage.TextMessage)
stream_text = f"Link: {message.message.text}\n"
@@ -300,6 +310,7 @@ class ToolNode(BaseNode[ToolNodeData]):
variables[variable_name] = variable_value
elif message.type == ToolInvokeMessage.MessageType.FILE:
assert message.meta is not None
+ assert isinstance(message.meta, File)
files.append(message.meta["file"])
elif message.type == ToolInvokeMessage.MessageType.LOG:
assert isinstance(message.message, ToolInvokeMessage.LogMessage)
@@ -318,6 +329,7 @@ class ToolNode(BaseNode[ToolNodeData]):
icon = current_plugin.declaration.icon
except StopIteration:
pass
+ icon_dark = None
try:
builtin_tool = next(
provider
@@ -328,10 +340,12 @@ class ToolNode(BaseNode[ToolNodeData]):
if provider.name == dict_metadata["provider"]
)
icon = builtin_tool.icon
+ icon_dark = builtin_tool.icon_dark
except StopIteration:
pass
dict_metadata["icon"] = icon
+ dict_metadata["icon_dark"] = icon_dark
message.message.metadata = dict_metadata
agent_log = AgentLogEvent(
id=message.message.id,
@@ -360,16 +374,41 @@ class ToolNode(BaseNode[ToolNodeData]):
yield agent_log
+ # Add agent_logs to outputs['json'] to ensure frontend can access thinking process
+ json_output: list[dict[str, Any]] = []
+
+ # Step 1: append each agent log as its own dict.
+ if agent_logs:
+ for log in agent_logs:
+ json_output.append(
+ {
+ "id": log.id,
+ "parent_id": log.parent_id,
+ "error": log.error,
+ "status": log.status,
+ "data": log.data,
+ "label": log.label,
+ "metadata": log.metadata,
+ "node_id": log.node_id,
+ }
+ )
+ # Step 2: normalize JSON into {"data": [...]}.change json to list[dict]
+ if json:
+ json_output.extend(json)
+ else:
+ json_output.append({"data": []})
+
yield RunCompletedEvent(
run_result=NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
- outputs={"text": text, "files": files, "json": json, **variables},
+ outputs={"text": text, "files": ArrayFileSegment(value=files), "json": json_output, **variables},
metadata={
**agent_execution_metadata,
WorkflowNodeExecutionMetadataKey.TOOL_INFO: tool_info,
WorkflowNodeExecutionMetadataKey.AGENT_LOG: agent_logs,
},
inputs=parameters_for_log,
+ llm_usage=llm_usage,
)
)
diff --git a/api/core/workflow/nodes/variable_aggregator/variable_aggregator_node.py b/api/core/workflow/nodes/variable_aggregator/variable_aggregator_node.py
index db3e25b015..96bb3e793a 100644
--- a/api/core/workflow/nodes/variable_aggregator/variable_aggregator_node.py
+++ b/api/core/workflow/nodes/variable_aggregator/variable_aggregator_node.py
@@ -1,3 +1,6 @@
+from collections.abc import Mapping
+
+from core.variables.segments import Segment
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
from core.workflow.nodes.base import BaseNode
@@ -9,16 +12,20 @@ class VariableAggregatorNode(BaseNode[VariableAssignerNodeData]):
_node_data_cls = VariableAssignerNodeData
_node_type = NodeType.VARIABLE_AGGREGATOR
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
def _run(self) -> NodeRunResult:
# Get variables
- outputs = {}
+ outputs: dict[str, Segment | Mapping[str, Segment]] = {}
inputs = {}
if not self.node_data.advanced_settings or not self.node_data.advanced_settings.group_enabled:
for selector in self.node_data.variables:
variable = self.graph_runtime_state.variable_pool.get(selector)
if variable is not None:
- outputs = {"output": variable.to_object()}
+ outputs = {"output": variable}
inputs = {".".join(selector[1:]): variable.to_object()}
break
@@ -28,7 +35,7 @@ class VariableAggregatorNode(BaseNode[VariableAssignerNodeData]):
variable = self.graph_runtime_state.variable_pool.get(selector)
if variable is not None:
- outputs[group.group_name] = {"output": variable.to_object()}
+ outputs[group.group_name] = {"output": variable}
inputs[".".join(selector[1:])] = variable.to_object()
break
diff --git a/api/core/workflow/nodes/variable_assigner/common/helpers.py b/api/core/workflow/nodes/variable_assigner/common/helpers.py
index 8031b57fa8..0d2822233e 100644
--- a/api/core/workflow/nodes/variable_assigner/common/helpers.py
+++ b/api/core/workflow/nodes/variable_assigner/common/helpers.py
@@ -1,19 +1,55 @@
-from sqlalchemy import select
-from sqlalchemy.orm import Session
+from collections.abc import Mapping, MutableMapping, Sequence
+from typing import Any, TypeVar
-from core.variables import Variable
-from core.workflow.nodes.variable_assigner.common.exc import VariableOperatorNodeError
-from extensions.ext_database import db
-from models import ConversationVariable
+from pydantic import BaseModel
+from core.variables import Segment
+from core.variables.consts import MIN_SELECTORS_LENGTH
+from core.variables.types import SegmentType
-def update_conversation_variable(conversation_id: str, variable: Variable):
- stmt = select(ConversationVariable).where(
- ConversationVariable.id == variable.id, ConversationVariable.conversation_id == conversation_id
+# Use double underscore (`__`) prefix for internal variables
+# to minimize risk of collision with user-defined variable names.
+_UPDATED_VARIABLES_KEY = "__updated_variables"
+
+
+class UpdatedVariable(BaseModel):
+ name: str
+ selector: Sequence[str]
+ value_type: SegmentType
+ new_value: Any
+
+
+_T = TypeVar("_T", bound=MutableMapping[str, Any])
+
+
+def variable_to_processed_data(selector: Sequence[str], seg: Segment) -> UpdatedVariable:
+ if len(selector) < MIN_SELECTORS_LENGTH:
+ raise Exception("selector too short")
+ node_id, var_name = selector[:2]
+ return UpdatedVariable(
+ name=var_name,
+ selector=list(selector[:2]),
+ value_type=seg.value_type,
+ new_value=seg.value,
)
- with Session(db.engine) as session:
- row = session.scalar(stmt)
- if not row:
- raise VariableOperatorNodeError("conversation variable not found in the database")
- row.data = variable.model_dump_json()
- session.commit()
+
+
+def set_updated_variables(m: _T, updates: Sequence[UpdatedVariable]) -> _T:
+ m[_UPDATED_VARIABLES_KEY] = updates
+ return m
+
+
+def get_updated_variables(m: Mapping[str, Any]) -> Sequence[UpdatedVariable] | None:
+ updated_values = m.get(_UPDATED_VARIABLES_KEY, None)
+ if updated_values is None:
+ return None
+ result = []
+ for items in updated_values:
+ if isinstance(items, UpdatedVariable):
+ result.append(items)
+ elif isinstance(items, dict):
+ items = UpdatedVariable.model_validate(items)
+ result.append(items)
+ else:
+ raise TypeError(f"Invalid updated variable: {items}, type={type(items)}")
+ return result
diff --git a/api/core/workflow/nodes/variable_assigner/common/impl.py b/api/core/workflow/nodes/variable_assigner/common/impl.py
new file mode 100644
index 0000000000..8f7a44bb62
--- /dev/null
+++ b/api/core/workflow/nodes/variable_assigner/common/impl.py
@@ -0,0 +1,38 @@
+from sqlalchemy import Engine, select
+from sqlalchemy.orm import Session
+
+from core.variables.variables import Variable
+from models.engine import db
+from models.workflow import ConversationVariable
+
+from .exc import VariableOperatorNodeError
+
+
+class ConversationVariableUpdaterImpl:
+ _engine: Engine | None
+
+ def __init__(self, engine: Engine | None = None) -> None:
+ self._engine = engine
+
+ def _get_engine(self) -> Engine:
+ if self._engine:
+ return self._engine
+ return db.engine
+
+ def update(self, conversation_id: str, variable: Variable):
+ stmt = select(ConversationVariable).where(
+ ConversationVariable.id == variable.id, ConversationVariable.conversation_id == conversation_id
+ )
+ with Session(self._get_engine()) as session:
+ row = session.scalar(stmt)
+ if not row:
+ raise VariableOperatorNodeError("conversation variable not found in the database")
+ row.data = variable.model_dump_json()
+ session.commit()
+
+ def flush(self):
+ pass
+
+
+def conversation_variable_updater_factory() -> ConversationVariableUpdaterImpl:
+ return ConversationVariableUpdaterImpl()
diff --git a/api/core/workflow/nodes/variable_assigner/v1/node.py b/api/core/workflow/nodes/variable_assigner/v1/node.py
index 835e1d77b5..be5083c9c1 100644
--- a/api/core/workflow/nodes/variable_assigner/v1/node.py
+++ b/api/core/workflow/nodes/variable_assigner/v1/node.py
@@ -1,4 +1,9 @@
+from collections.abc import Callable, Mapping, Sequence
+from typing import TYPE_CHECKING, Any, Optional, TypeAlias
+
from core.variables import SegmentType, Variable
+from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID
+from core.workflow.conversation_variable_updater import ConversationVariableUpdater
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
from core.workflow.nodes.base import BaseNode
@@ -7,16 +12,71 @@ from core.workflow.nodes.variable_assigner.common import helpers as common_helpe
from core.workflow.nodes.variable_assigner.common.exc import VariableOperatorNodeError
from factories import variable_factory
+from ..common.impl import conversation_variable_updater_factory
from .node_data import VariableAssignerData, WriteMode
+if TYPE_CHECKING:
+ from core.workflow.graph_engine import Graph, GraphInitParams, GraphRuntimeState
+
+
+_CONV_VAR_UPDATER_FACTORY: TypeAlias = Callable[[], ConversationVariableUpdater]
+
class VariableAssignerNode(BaseNode[VariableAssignerData]):
_node_data_cls = VariableAssignerData
_node_type = NodeType.VARIABLE_ASSIGNER
+ _conv_var_updater_factory: _CONV_VAR_UPDATER_FACTORY
+
+ def __init__(
+ self,
+ id: str,
+ config: Mapping[str, Any],
+ graph_init_params: "GraphInitParams",
+ graph: "Graph",
+ graph_runtime_state: "GraphRuntimeState",
+ previous_node_id: Optional[str] = None,
+ thread_pool_id: Optional[str] = None,
+ conv_var_updater_factory: _CONV_VAR_UPDATER_FACTORY = conversation_variable_updater_factory,
+ ) -> None:
+ super().__init__(
+ id=id,
+ config=config,
+ graph_init_params=graph_init_params,
+ graph=graph,
+ graph_runtime_state=graph_runtime_state,
+ previous_node_id=previous_node_id,
+ thread_pool_id=thread_pool_id,
+ )
+ self._conv_var_updater_factory = conv_var_updater_factory
+
+ @classmethod
+ def version(cls) -> str:
+ return "1"
+
+ @classmethod
+ def _extract_variable_selector_to_variable_mapping(
+ cls,
+ *,
+ graph_config: Mapping[str, Any],
+ node_id: str,
+ node_data: VariableAssignerData,
+ ) -> Mapping[str, Sequence[str]]:
+ mapping = {}
+ assigned_variable_node_id = node_data.assigned_variable_selector[0]
+ if assigned_variable_node_id == CONVERSATION_VARIABLE_NODE_ID:
+ selector_key = ".".join(node_data.assigned_variable_selector)
+ key = f"{node_id}.#{selector_key}#"
+ mapping[key] = node_data.assigned_variable_selector
+
+ selector_key = ".".join(node_data.input_variable_selector)
+ key = f"{node_id}.#{selector_key}#"
+ mapping[key] = node_data.input_variable_selector
+ return mapping
def _run(self) -> NodeRunResult:
+ assigned_variable_selector = self.node_data.assigned_variable_selector
# Should be String, Number, Object, ArrayString, ArrayNumber, ArrayObject
- original_variable = self.graph_runtime_state.variable_pool.get(self.node_data.assigned_variable_selector)
+ original_variable = self.graph_runtime_state.variable_pool.get(assigned_variable_selector)
if not isinstance(original_variable, Variable):
raise VariableOperatorNodeError("assigned variable not found")
@@ -44,20 +104,28 @@ class VariableAssignerNode(BaseNode[VariableAssignerData]):
raise VariableOperatorNodeError(f"unsupported write mode: {self.node_data.write_mode}")
# Over write the variable.
- self.graph_runtime_state.variable_pool.add(self.node_data.assigned_variable_selector, updated_variable)
+ self.graph_runtime_state.variable_pool.add(assigned_variable_selector, updated_variable)
# TODO: Move database operation to the pipeline.
# Update conversation variable.
conversation_id = self.graph_runtime_state.variable_pool.get(["sys", "conversation_id"])
if not conversation_id:
raise VariableOperatorNodeError("conversation_id not found")
- common_helpers.update_conversation_variable(conversation_id=conversation_id.text, variable=updated_variable)
+ conv_var_updater = self._conv_var_updater_factory()
+ conv_var_updater.update(conversation_id=conversation_id.text, variable=updated_variable)
+ conv_var_updater.flush()
+ updated_variables = [common_helpers.variable_to_processed_data(assigned_variable_selector, updated_variable)]
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
inputs={
"value": income_value.to_object(),
},
+ # NOTE(QuantumGhost): although only one variable is updated in `v1.VariableAssignerNode`,
+ # we still set `output_variables` as a list to ensure the schema of output is
+ # compatible with `v2.VariableAssignerNode`.
+ process_data=common_helpers.set_updated_variables({}, updated_variables),
+ outputs={},
)
diff --git a/api/core/workflow/nodes/variable_assigner/v2/entities.py b/api/core/workflow/nodes/variable_assigner/v2/entities.py
index 01df33b6d4..d93affcd15 100644
--- a/api/core/workflow/nodes/variable_assigner/v2/entities.py
+++ b/api/core/workflow/nodes/variable_assigner/v2/entities.py
@@ -12,6 +12,12 @@ class VariableOperationItem(BaseModel):
variable_selector: Sequence[str]
input_type: InputType
operation: Operation
+ # NOTE(QuantumGhost): The `value` field serves multiple purposes depending on context:
+ #
+ # 1. For CONSTANT input_type: Contains the literal value to be used in the operation.
+ # 2. For VARIABLE input_type: Initially contains the selector of the source variable.
+ # 3. During the variable updating procedure: The `value` field is reassigned to hold
+ # the resolved actual value that will be applied to the target variable.
value: Any | None = None
diff --git a/api/core/workflow/nodes/variable_assigner/v2/exc.py b/api/core/workflow/nodes/variable_assigner/v2/exc.py
index b67af6d73c..fd6c304a9a 100644
--- a/api/core/workflow/nodes/variable_assigner/v2/exc.py
+++ b/api/core/workflow/nodes/variable_assigner/v2/exc.py
@@ -29,3 +29,8 @@ class InvalidInputValueError(VariableOperatorNodeError):
class ConversationIDNotFoundError(VariableOperatorNodeError):
def __init__(self):
super().__init__("conversation_id not found")
+
+
+class InvalidDataError(VariableOperatorNodeError):
+ def __init__(self, message: str) -> None:
+ super().__init__(message)
diff --git a/api/core/workflow/nodes/variable_assigner/v2/node.py b/api/core/workflow/nodes/variable_assigner/v2/node.py
index 8759a55b34..9292da6f1c 100644
--- a/api/core/workflow/nodes/variable_assigner/v2/node.py
+++ b/api/core/workflow/nodes/variable_assigner/v2/node.py
@@ -1,34 +1,84 @@
import json
-from collections.abc import Sequence
-from typing import Any, cast
+from collections.abc import Callable, Mapping, MutableMapping, Sequence
+from typing import Any, TypeAlias, cast
from core.app.entities.app_invoke_entities import InvokeFrom
from core.variables import SegmentType, Variable
+from core.variables.consts import MIN_SELECTORS_LENGTH
from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID
+from core.workflow.conversation_variable_updater import ConversationVariableUpdater
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
from core.workflow.nodes.base import BaseNode
from core.workflow.nodes.enums import NodeType
from core.workflow.nodes.variable_assigner.common import helpers as common_helpers
from core.workflow.nodes.variable_assigner.common.exc import VariableOperatorNodeError
+from core.workflow.nodes.variable_assigner.common.impl import conversation_variable_updater_factory
from . import helpers
from .constants import EMPTY_VALUE_MAPPING
-from .entities import VariableAssignerNodeData
+from .entities import VariableAssignerNodeData, VariableOperationItem
from .enums import InputType, Operation
from .exc import (
ConversationIDNotFoundError,
InputTypeNotSupportedError,
+ InvalidDataError,
InvalidInputValueError,
OperationNotSupportedError,
VariableNotFoundError,
)
+_CONV_VAR_UPDATER_FACTORY: TypeAlias = Callable[[], ConversationVariableUpdater]
+
+
+def _target_mapping_from_item(mapping: MutableMapping[str, Sequence[str]], node_id: str, item: VariableOperationItem):
+ selector_node_id = item.variable_selector[0]
+ if selector_node_id != CONVERSATION_VARIABLE_NODE_ID:
+ return
+ selector_str = ".".join(item.variable_selector)
+ key = f"{node_id}.#{selector_str}#"
+ mapping[key] = item.variable_selector
+
+
+def _source_mapping_from_item(mapping: MutableMapping[str, Sequence[str]], node_id: str, item: VariableOperationItem):
+ # Keep this in sync with the logic in _run methods...
+ if item.input_type != InputType.VARIABLE:
+ return
+ selector = item.value
+ if not isinstance(selector, list):
+ raise InvalidDataError(f"selector is not a list, {node_id=}, {item=}")
+ if len(selector) < MIN_SELECTORS_LENGTH:
+ raise InvalidDataError(f"selector too short, {node_id=}, {item=}")
+ selector_str = ".".join(selector)
+ key = f"{node_id}.#{selector_str}#"
+ mapping[key] = selector
+
class VariableAssignerNode(BaseNode[VariableAssignerNodeData]):
_node_data_cls = VariableAssignerNodeData
_node_type = NodeType.VARIABLE_ASSIGNER
+ def _conv_var_updater_factory(self) -> ConversationVariableUpdater:
+ return conversation_variable_updater_factory()
+
+ @classmethod
+ def version(cls) -> str:
+ return "2"
+
+ @classmethod
+ def _extract_variable_selector_to_variable_mapping(
+ cls,
+ *,
+ graph_config: Mapping[str, Any],
+ node_id: str,
+ node_data: VariableAssignerNodeData,
+ ) -> Mapping[str, Sequence[str]]:
+ var_mapping: dict[str, Sequence[str]] = {}
+ for item in node_data.items:
+ _target_mapping_from_item(var_mapping, node_id, item)
+ _source_mapping_from_item(var_mapping, node_id, item)
+ return var_mapping
+
def _run(self) -> NodeRunResult:
inputs = self.node_data.model_dump()
process_data: dict[str, Any] = {}
@@ -114,6 +164,7 @@ class VariableAssignerNode(BaseNode[VariableAssignerNodeData]):
# remove the duplicated items first.
updated_variable_selectors = list(set(map(tuple, updated_variable_selectors)))
+ conv_var_updater = self._conv_var_updater_factory()
# Update variables
for selector in updated_variable_selectors:
variable = self.graph_runtime_state.variable_pool.get(selector)
@@ -128,15 +179,23 @@ class VariableAssignerNode(BaseNode[VariableAssignerNodeData]):
raise ConversationIDNotFoundError
else:
conversation_id = conversation_id.value
- common_helpers.update_conversation_variable(
+ conv_var_updater.update(
conversation_id=cast(str, conversation_id),
variable=variable,
)
-
+ conv_var_updater.flush()
+ updated_variables = [
+ common_helpers.variable_to_processed_data(selector, seg)
+ for selector in updated_variable_selectors
+ if (seg := self.graph_runtime_state.variable_pool.get(selector)) is not None
+ ]
+
+ process_data = common_helpers.set_updated_variables(process_data, updated_variables)
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
inputs=inputs,
process_data=process_data,
+ outputs={},
)
def _handle_item(
diff --git a/api/core/workflow/repositories/draft_variable_repository.py b/api/core/workflow/repositories/draft_variable_repository.py
new file mode 100644
index 0000000000..cadc23f845
--- /dev/null
+++ b/api/core/workflow/repositories/draft_variable_repository.py
@@ -0,0 +1,32 @@
+import abc
+from collections.abc import Mapping
+from typing import Any, Protocol
+
+from sqlalchemy.orm import Session
+
+from core.workflow.nodes.enums import NodeType
+
+
+class DraftVariableSaver(Protocol):
+ @abc.abstractmethod
+ def save(self, process_data: Mapping[str, Any] | None, outputs: Mapping[str, Any] | None):
+ pass
+
+
+class DraftVariableSaverFactory(Protocol):
+ @abc.abstractmethod
+ def __call__(
+ self,
+ session: Session,
+ app_id: str,
+ node_id: str,
+ node_type: NodeType,
+ node_execution_id: str,
+ enclosing_node_id: str | None = None,
+ ) -> "DraftVariableSaver":
+ pass
+
+
+class NoopDraftVariableSaver(DraftVariableSaver):
+ def save(self, process_data: Mapping[str, Any] | None, outputs: Mapping[str, Any] | None):
+ pass
diff --git a/api/core/workflow/utils/structured_output/entities.py b/api/core/workflow/utils/structured_output/entities.py
deleted file mode 100644
index 6491042bfe..0000000000
--- a/api/core/workflow/utils/structured_output/entities.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from enum import StrEnum
-
-
-class ResponseFormat(StrEnum):
- """Constants for model response formats"""
-
- JSON_SCHEMA = "json_schema" # model's structured output mode. some model like gemini, gpt-4o, support this mode.
- JSON = "JSON" # model's json mode. some model like claude support this mode.
- JSON_OBJECT = "json_object" # json mode's another alias. some model like deepseek-chat, qwen use this alias.
-
-
-class SpecialModelType(StrEnum):
- """Constants for identifying model types"""
-
- GEMINI = "gemini"
- OLLAMA = "ollama"
diff --git a/api/core/workflow/utils/structured_output/prompt.py b/api/core/workflow/utils/structured_output/prompt.py
deleted file mode 100644
index 06d9b2056e..0000000000
--- a/api/core/workflow/utils/structured_output/prompt.py
+++ /dev/null
@@ -1,17 +0,0 @@
-STRUCTURED_OUTPUT_PROMPT = """You’re a helpful AI assistant. You could answer questions and output in JSON format.
-constraints:
- - You must output in JSON format.
- - Do not output boolean value, use string type instead.
- - Do not output integer or float value, use number type instead.
-eg:
- Here is the JSON schema:
- {"additionalProperties": false, "properties": {"age": {"type": "number"}, "name": {"type": "string"}}, "required": ["name", "age"], "type": "object"}
-
- Here is the user's question:
- My name is John Doe and I am 30 years old.
-
- output:
- {"name": "John Doe", "age": 30}
-Here is the JSON schema:
-{{schema}}
-""" # noqa: E501
diff --git a/api/core/workflow/utils/variable_utils.py b/api/core/workflow/utils/variable_utils.py
new file mode 100644
index 0000000000..868868315b
--- /dev/null
+++ b/api/core/workflow/utils/variable_utils.py
@@ -0,0 +1,29 @@
+from core.variables.segments import ObjectSegment, Segment
+from core.workflow.entities.variable_pool import VariablePool, VariableValue
+
+
+def append_variables_recursively(
+ pool: VariablePool, node_id: str, variable_key_list: list[str], variable_value: VariableValue | Segment
+):
+ """
+ Append variables recursively
+ :param pool: variable pool to append variables to
+ :param node_id: node id
+ :param variable_key_list: variable key list
+ :param variable_value: variable value
+ :return:
+ """
+ pool.add([node_id] + variable_key_list, variable_value)
+
+ # if variable_value is a dict, then recursively append variables
+ if isinstance(variable_value, ObjectSegment):
+ variable_dict = variable_value.value
+ elif isinstance(variable_value, dict):
+ variable_dict = variable_value
+ else:
+ return
+
+ for key, value in variable_dict.items():
+ # construct new key list
+ new_key_list = variable_key_list + [key]
+ append_variables_recursively(pool, node_id=node_id, variable_key_list=new_key_list, variable_value=value)
diff --git a/api/core/workflow/variable_loader.py b/api/core/workflow/variable_loader.py
new file mode 100644
index 0000000000..1e13871d0a
--- /dev/null
+++ b/api/core/workflow/variable_loader.py
@@ -0,0 +1,84 @@
+import abc
+from collections.abc import Mapping, Sequence
+from typing import Any, Protocol
+
+from core.variables import Variable
+from core.variables.consts import MIN_SELECTORS_LENGTH
+from core.workflow.entities.variable_pool import VariablePool
+from core.workflow.utils import variable_utils
+
+
+class VariableLoader(Protocol):
+ """Interface for loading variables based on selectors.
+
+ A `VariableLoader` is responsible for retrieving additional variables required during the execution
+ of a single node, which are not provided as user inputs.
+
+ NOTE(QuantumGhost): Typically, all variables loaded by a `VariableLoader` should belong to the same
+ application and share the same `app_id`. However, this interface does not enforce that constraint,
+ and the `app_id` parameter is intentionally omitted from `load_variables` to achieve separation of
+ concern and allow for flexible implementations.
+
+ Implementations of `VariableLoader` should almost always have an `app_id` parameter in
+ their constructor.
+
+ TODO(QuantumGhost): this is a temporally workaround. If we can move the creation of node instance into
+ `WorkflowService.single_step_run`, we may get rid of this interface.
+ """
+
+ @abc.abstractmethod
+ def load_variables(self, selectors: list[list[str]]) -> list[Variable]:
+ """Load variables based on the provided selectors. If the selectors are empty,
+ this method should return an empty list.
+
+ The order of the returned variables is not guaranteed. If the caller wants to ensure
+ a specific order, they should sort the returned list themselves.
+
+ :param: selectors: a list of string list, each inner list should have at least two elements:
+ - the first element is the node ID,
+ - the second element is the variable name.
+ :return: a list of Variable objects that match the provided selectors.
+ """
+ pass
+
+
+class _DummyVariableLoader(VariableLoader):
+ """A dummy implementation of VariableLoader that does not load any variables.
+ Serves as a placeholder when no variable loading is needed.
+ """
+
+ def load_variables(self, selectors: list[list[str]]) -> list[Variable]:
+ return []
+
+
+DUMMY_VARIABLE_LOADER = _DummyVariableLoader()
+
+
+def load_into_variable_pool(
+ variable_loader: VariableLoader,
+ variable_pool: VariablePool,
+ variable_mapping: Mapping[str, Sequence[str]],
+ user_inputs: Mapping[str, Any],
+):
+ # Loading missing variable from draft var here, and set it into
+ # variable_pool.
+ variables_to_load: list[list[str]] = []
+ for key, selector in variable_mapping.items():
+ # NOTE(QuantumGhost): this logic needs to be in sync with
+ # `WorkflowEntry.mapping_user_inputs_to_variable_pool`.
+ node_variable_list = key.split(".")
+ if len(node_variable_list) < 1:
+ raise ValueError(f"Invalid variable key: {key}. It should have at least one element.")
+ if key in user_inputs:
+ continue
+ node_variable_key = ".".join(node_variable_list[1:])
+ if node_variable_key in user_inputs:
+ continue
+ if variable_pool.get(selector) is None:
+ variables_to_load.append(list(selector))
+ loaded = variable_loader.load_variables(variables_to_load)
+ for var in loaded:
+ assert len(var.selector) >= MIN_SELECTORS_LENGTH, f"Invalid variable {var}"
+ variable_utils.append_variables_recursively(
+ variable_pool, node_id=var.selector[0], variable_key_list=list(var.selector[1:]), variable_value=var
+ )
diff --git a/api/core/workflow/workflow_cycle_manager.py b/api/core/workflow/workflow_cycle_manager.py
index b88f9edd03..0aab2426af 100644
--- a/api/core/workflow/workflow_cycle_manager.py
+++ b/api/core/workflow/workflow_cycle_manager.py
@@ -27,6 +27,7 @@ from core.workflow.enums import SystemVariableKey
from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository
from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository
from core.workflow.workflow_entry import WorkflowEntry
+from libs.datetime_utils import naive_utc_now
@dataclass
@@ -92,7 +93,7 @@ class WorkflowCycleManager:
) -> WorkflowExecution:
workflow_execution = self._get_workflow_execution_or_raise_error(workflow_run_id)
- outputs = WorkflowEntry.handle_special_values(outputs)
+ # outputs = WorkflowEntry.handle_special_values(outputs)
workflow_execution.status = WorkflowExecutionStatus.SUCCEEDED
workflow_execution.outputs = outputs or {}
@@ -125,7 +126,7 @@ class WorkflowCycleManager:
trace_manager: Optional[TraceQueueManager] = None,
) -> WorkflowExecution:
execution = self._get_workflow_execution_or_raise_error(workflow_run_id)
- outputs = WorkflowEntry.handle_special_values(dict(outputs) if outputs else None)
+ # outputs = WorkflowEntry.handle_special_values(dict(outputs) if outputs else None)
execution.status = WorkflowExecutionStatus.PARTIAL_SUCCEEDED
execution.outputs = outputs or {}
@@ -160,12 +161,13 @@ class WorkflowCycleManager:
exceptions_count: int = 0,
) -> WorkflowExecution:
workflow_execution = self._get_workflow_execution_or_raise_error(workflow_run_id)
+ now = naive_utc_now()
workflow_execution.status = WorkflowExecutionStatus(status.value)
workflow_execution.error_message = error_message
workflow_execution.total_tokens = total_tokens
workflow_execution.total_steps = total_steps
- workflow_execution.finished_at = datetime.now(UTC).replace(tzinfo=None)
+ workflow_execution.finished_at = now
workflow_execution.exceptions_count = exceptions_count
# Use the instance repository to find running executions for a workflow run
@@ -174,7 +176,6 @@ class WorkflowCycleManager:
)
# Update the domain models
- now = datetime.now(UTC).replace(tzinfo=None)
for node_execution in running_node_executions:
if node_execution.node_execution_id:
# Update the domain model
@@ -242,9 +243,9 @@ class WorkflowCycleManager:
raise ValueError(f"Domain node execution not found: {event.node_execution_id}")
# Process data
- inputs = WorkflowEntry.handle_special_values(event.inputs)
- process_data = WorkflowEntry.handle_special_values(event.process_data)
- outputs = WorkflowEntry.handle_special_values(event.outputs)
+ inputs = event.inputs
+ process_data = event.process_data
+ outputs = event.outputs
# Convert metadata keys to strings
execution_metadata_dict = {}
@@ -289,7 +290,7 @@ class WorkflowCycleManager:
# Process data
inputs = WorkflowEntry.handle_special_values(event.inputs)
process_data = WorkflowEntry.handle_special_values(event.process_data)
- outputs = WorkflowEntry.handle_special_values(event.outputs)
+ outputs = event.outputs
# Convert metadata keys to strings
execution_metadata_dict = {}
@@ -326,7 +327,7 @@ class WorkflowCycleManager:
finished_at = datetime.now(UTC).replace(tzinfo=None)
elapsed_time = (finished_at - created_at).total_seconds()
inputs = WorkflowEntry.handle_special_values(event.inputs)
- outputs = WorkflowEntry.handle_special_values(event.outputs)
+ outputs = event.outputs
# Convert metadata keys to strings
origin_metadata = {
diff --git a/api/core/workflow/workflow_entry.py b/api/core/workflow/workflow_entry.py
index 7648947fca..2868dcb7de 100644
--- a/api/core/workflow/workflow_entry.py
+++ b/api/core/workflow/workflow_entry.py
@@ -21,6 +21,7 @@ from core.workflow.nodes import NodeType
from core.workflow.nodes.base import BaseNode
from core.workflow.nodes.event import NodeEvent
from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING
+from core.workflow.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_into_variable_pool
from factories import file_factory
from models.enums import UserFrom
from models.workflow import (
@@ -68,6 +69,7 @@ class WorkflowEntry:
raise ValueError("Max workflow call depth {} reached.".format(workflow_call_max_depth))
# init workflow run state
+ graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
self.graph_engine = GraphEngine(
tenant_id=tenant_id,
app_id=app_id,
@@ -79,7 +81,7 @@ class WorkflowEntry:
call_depth=call_depth,
graph=graph,
graph_config=graph_config,
- variable_pool=variable_pool,
+ graph_runtime_state=graph_runtime_state,
max_execution_steps=dify_config.WORKFLOW_MAX_EXECUTION_STEPS,
max_execution_time=dify_config.WORKFLOW_MAX_EXECUTION_TIME,
thread_pool_id=thread_pool_id,
@@ -119,7 +121,9 @@ class WorkflowEntry:
workflow: Workflow,
node_id: str,
user_id: str,
- user_inputs: dict,
+ user_inputs: Mapping[str, Any],
+ variable_pool: VariablePool,
+ variable_loader: VariableLoader = DUMMY_VARIABLE_LOADER,
) -> tuple[BaseNode, Generator[NodeEvent | InNodeEvent, None, None]]:
"""
Single step run workflow node
@@ -129,29 +133,14 @@ class WorkflowEntry:
:param user_inputs: user inputs
:return:
"""
- # fetch node info from workflow graph
- workflow_graph = workflow.graph_dict
- if not workflow_graph:
- raise ValueError("workflow graph not found")
-
- nodes = workflow_graph.get("nodes")
- if not nodes:
- raise ValueError("nodes not found in workflow graph")
-
- # fetch node config from node id
- try:
- node_config = next(filter(lambda node: node["id"] == node_id, nodes))
- except StopIteration:
- raise ValueError("node id not found in workflow graph")
+ node_config = workflow.get_node_config_by_id(node_id)
+ node_config_data = node_config.get("data", {})
# Get node class
- node_type = NodeType(node_config.get("data", {}).get("type"))
- node_version = node_config.get("data", {}).get("version", "1")
+ node_type = NodeType(node_config_data.get("type"))
+ node_version = node_config_data.get("version", "1")
node_cls = NODE_TYPE_CLASSES_MAPPING[node_type][node_version]
- # init variable pool
- variable_pool = VariablePool(environment_variables=workflow.environment_variables)
-
# init graph
graph = Graph.init(graph_config=workflow.graph_dict)
@@ -182,16 +171,33 @@ class WorkflowEntry:
except NotImplementedError:
variable_mapping = {}
+ # Loading missing variable from draft var here, and set it into
+ # variable_pool.
+ load_into_variable_pool(
+ variable_loader=variable_loader,
+ variable_pool=variable_pool,
+ variable_mapping=variable_mapping,
+ user_inputs=user_inputs,
+ )
+
cls.mapping_user_inputs_to_variable_pool(
variable_mapping=variable_mapping,
user_inputs=user_inputs,
variable_pool=variable_pool,
tenant_id=workflow.tenant_id,
)
+
try:
# run node
generator = node_instance.run()
except Exception as e:
+ logger.exception(
+ "error while running node_instance, workflow_id=%s, node_id=%s, type=%s, version=%s",
+ workflow.id,
+ node_instance.id,
+ node_instance.node_type,
+ node_instance.version(),
+ )
raise WorkflowNodeRunFailedError(node_instance=node_instance, error=str(e))
return node_instance, generator
@@ -294,10 +300,20 @@ class WorkflowEntry:
return node_instance, generator
except Exception as e:
+ logger.exception(
+ "error while running node_instance, node_id=%s, type=%s, version=%s",
+ node_instance.id,
+ node_instance.node_type,
+ node_instance.version(),
+ )
raise WorkflowNodeRunFailedError(node_instance=node_instance, error=str(e))
@staticmethod
def handle_special_values(value: Optional[Mapping[str, Any]]) -> Mapping[str, Any] | None:
+ # NOTE(QuantumGhost): Avoid using this function in new code.
+ # Keep values structured as long as possible and only convert to dict
+ # immediately before serialization (e.g., JSON serialization) to maintain
+ # data integrity and type information.
result = WorkflowEntry._handle_special_values(value)
return result if isinstance(result, Mapping) or result is None else dict(result)
@@ -324,10 +340,17 @@ class WorkflowEntry:
cls,
*,
variable_mapping: Mapping[str, Sequence[str]],
- user_inputs: dict,
+ user_inputs: Mapping[str, Any],
variable_pool: VariablePool,
tenant_id: str,
) -> None:
+ # NOTE(QuantumGhost): This logic should remain synchronized with
+ # the implementation of `load_into_variable_pool`, specifically the logic about
+ # variable existence checking.
+
+ # WARNING(QuantumGhost): The semantics of this method are not clearly defined,
+ # and multiple parts of the codebase depend on its current behavior.
+ # Modify with caution.
for node_variable, variable_selector in variable_mapping.items():
# fetch node id and variable key from node_variable
node_variable_list = node_variable.split(".")
diff --git a/api/core/workflow/workflow_type_encoder.py b/api/core/workflow/workflow_type_encoder.py
new file mode 100644
index 0000000000..0123fdac18
--- /dev/null
+++ b/api/core/workflow/workflow_type_encoder.py
@@ -0,0 +1,49 @@
+import json
+from collections.abc import Mapping
+from typing import Any
+
+from pydantic import BaseModel
+
+from core.file.models import File
+from core.variables import Segment
+
+
+class WorkflowRuntimeTypeEncoder(json.JSONEncoder):
+ def default(self, o: Any):
+ if isinstance(o, Segment):
+ return o.value
+ elif isinstance(o, File):
+ return o.to_dict()
+ elif isinstance(o, BaseModel):
+ return o.model_dump(mode="json")
+ else:
+ return super().default(o)
+
+
+class WorkflowRuntimeTypeConverter:
+ def to_json_encodable(self, value: Mapping[str, Any] | None) -> Mapping[str, Any] | None:
+ result = self._to_json_encodable_recursive(value)
+ return result if isinstance(result, Mapping) or result is None else dict(result)
+
+ def _to_json_encodable_recursive(self, value: Any) -> Any:
+ if value is None:
+ return value
+ if isinstance(value, (bool, int, str, float)):
+ return value
+ if isinstance(value, Segment):
+ return self._to_json_encodable_recursive(value.value)
+ if isinstance(value, File):
+ return value.to_dict()
+ if isinstance(value, BaseModel):
+ return value.model_dump(mode="json")
+ if isinstance(value, dict):
+ res = {}
+ for k, v in value.items():
+ res[k] = self._to_json_encodable_recursive(v)
+ return res
+ if isinstance(value, list):
+ res_list = []
+ for item in value:
+ res_list.append(self._to_json_encodable_recursive(item))
+ return res_list
+ return value
diff --git a/api/events/event_handlers/__init__.py b/api/events/event_handlers/__init__.py
index 1d6ad35333..ebc55d5ef8 100644
--- a/api/events/event_handlers/__init__.py
+++ b/api/events/event_handlers/__init__.py
@@ -3,8 +3,10 @@ from .clean_when_document_deleted import handle
from .create_document_index import handle
from .create_installed_app_when_app_created import handle
from .create_site_record_when_app_created import handle
-from .deduct_quota_when_message_created import handle
from .delete_tool_parameters_cache_when_sync_draft_workflow import handle
from .update_app_dataset_join_when_app_model_config_updated import handle
from .update_app_dataset_join_when_app_published_workflow_updated import handle
-from .update_provider_last_used_at_when_message_created import handle
+
+# Consolidated handler replaces both deduct_quota_when_message_created and
+# update_provider_last_used_at_when_message_created
+from .update_provider_when_message_created import handle
diff --git a/api/events/event_handlers/deduct_quota_when_message_created.py b/api/events/event_handlers/deduct_quota_when_message_created.py
deleted file mode 100644
index b8e7019446..0000000000
--- a/api/events/event_handlers/deduct_quota_when_message_created.py
+++ /dev/null
@@ -1,65 +0,0 @@
-from datetime import UTC, datetime
-
-from configs import dify_config
-from core.app.entities.app_invoke_entities import AgentChatAppGenerateEntity, ChatAppGenerateEntity
-from core.entities.provider_entities import QuotaUnit
-from core.plugin.entities.plugin import ModelProviderID
-from events.message_event import message_was_created
-from extensions.ext_database import db
-from models.provider import Provider, ProviderType
-
-
-@message_was_created.connect
-def handle(sender, **kwargs):
- message = sender
- application_generate_entity = kwargs.get("application_generate_entity")
-
- if not isinstance(application_generate_entity, ChatAppGenerateEntity | AgentChatAppGenerateEntity):
- return
-
- model_config = application_generate_entity.model_conf
- provider_model_bundle = model_config.provider_model_bundle
- provider_configuration = provider_model_bundle.configuration
-
- if provider_configuration.using_provider_type != ProviderType.SYSTEM:
- return
-
- system_configuration = provider_configuration.system_configuration
-
- if not system_configuration.current_quota_type:
- return
-
- quota_unit = None
- for quota_configuration in system_configuration.quota_configurations:
- if quota_configuration.quota_type == system_configuration.current_quota_type:
- quota_unit = quota_configuration.quota_unit
-
- if quota_configuration.quota_limit == -1:
- return
-
- break
-
- used_quota = None
- if quota_unit:
- if quota_unit == QuotaUnit.TOKENS:
- used_quota = message.message_tokens + message.answer_tokens
- elif quota_unit == QuotaUnit.CREDITS:
- used_quota = dify_config.get_model_credits(model_config.model)
- else:
- used_quota = 1
-
- if used_quota is not None and system_configuration.current_quota_type is not None:
- db.session.query(Provider).filter(
- Provider.tenant_id == application_generate_entity.app_config.tenant_id,
- # TODO: Use provider name with prefix after the data migration.
- Provider.provider_name == ModelProviderID(model_config.provider).provider_name,
- Provider.provider_type == ProviderType.SYSTEM.value,
- Provider.quota_type == system_configuration.current_quota_type.value,
- Provider.quota_limit > Provider.quota_used,
- ).update(
- {
- "quota_used": Provider.quota_used + used_quota,
- "last_used": datetime.now(tz=UTC).replace(tzinfo=None),
- }
- )
- db.session.commit()
diff --git a/api/events/event_handlers/update_provider_last_used_at_when_message_created.py b/api/events/event_handlers/update_provider_last_used_at_when_message_created.py
deleted file mode 100644
index 59412cf87c..0000000000
--- a/api/events/event_handlers/update_provider_last_used_at_when_message_created.py
+++ /dev/null
@@ -1,20 +0,0 @@
-from datetime import UTC, datetime
-
-from core.app.entities.app_invoke_entities import AgentChatAppGenerateEntity, ChatAppGenerateEntity
-from events.message_event import message_was_created
-from extensions.ext_database import db
-from models.provider import Provider
-
-
-@message_was_created.connect
-def handle(sender, **kwargs):
- application_generate_entity = kwargs.get("application_generate_entity")
-
- if not isinstance(application_generate_entity, ChatAppGenerateEntity | AgentChatAppGenerateEntity):
- return
-
- db.session.query(Provider).filter(
- Provider.tenant_id == application_generate_entity.app_config.tenant_id,
- Provider.provider_name == application_generate_entity.model_conf.provider,
- ).update({"last_used": datetime.now(UTC).replace(tzinfo=None)})
- db.session.commit()
diff --git a/api/events/event_handlers/update_provider_when_message_created.py b/api/events/event_handlers/update_provider_when_message_created.py
new file mode 100644
index 0000000000..d3943f2eda
--- /dev/null
+++ b/api/events/event_handlers/update_provider_when_message_created.py
@@ -0,0 +1,234 @@
+import logging
+import time as time_module
+from datetime import datetime
+from typing import Any, Optional
+
+from pydantic import BaseModel
+from sqlalchemy import update
+from sqlalchemy.orm import Session
+
+from configs import dify_config
+from core.app.entities.app_invoke_entities import AgentChatAppGenerateEntity, ChatAppGenerateEntity
+from core.entities.provider_entities import QuotaUnit, SystemConfiguration
+from core.plugin.entities.plugin import ModelProviderID
+from events.message_event import message_was_created
+from extensions.ext_database import db
+from libs import datetime_utils
+from models.model import Message
+from models.provider import Provider, ProviderType
+
+logger = logging.getLogger(__name__)
+
+
+class _ProviderUpdateFilters(BaseModel):
+ """Filters for identifying Provider records to update."""
+
+ tenant_id: str
+ provider_name: str
+ provider_type: Optional[str] = None
+ quota_type: Optional[str] = None
+
+
+class _ProviderUpdateAdditionalFilters(BaseModel):
+ """Additional filters for Provider updates."""
+
+ quota_limit_check: bool = False
+
+
+class _ProviderUpdateValues(BaseModel):
+ """Values to update in Provider records."""
+
+ last_used: Optional[datetime] = None
+ quota_used: Optional[Any] = None # Can be Provider.quota_used + int expression
+
+
+class _ProviderUpdateOperation(BaseModel):
+ """A single Provider update operation."""
+
+ filters: _ProviderUpdateFilters
+ values: _ProviderUpdateValues
+ additional_filters: _ProviderUpdateAdditionalFilters = _ProviderUpdateAdditionalFilters()
+ description: str = "unknown"
+
+
+@message_was_created.connect
+def handle(sender: Message, **kwargs):
+ """
+ Consolidated handler for Provider updates when a message is created.
+
+ This handler replaces both:
+ - update_provider_last_used_at_when_message_created
+ - deduct_quota_when_message_created
+
+ By performing all Provider updates in a single transaction, we ensure
+ consistency and efficiency when updating Provider records.
+ """
+ message = sender
+ application_generate_entity = kwargs.get("application_generate_entity")
+
+ if not isinstance(application_generate_entity, ChatAppGenerateEntity | AgentChatAppGenerateEntity):
+ return
+
+ tenant_id = application_generate_entity.app_config.tenant_id
+ provider_name = application_generate_entity.model_conf.provider
+ current_time = datetime_utils.naive_utc_now()
+
+ # Prepare updates for both scenarios
+ updates_to_perform: list[_ProviderUpdateOperation] = []
+
+ # 1. Always update last_used for the provider
+ basic_update = _ProviderUpdateOperation(
+ filters=_ProviderUpdateFilters(
+ tenant_id=tenant_id,
+ provider_name=provider_name,
+ ),
+ values=_ProviderUpdateValues(last_used=current_time),
+ description="basic_last_used_update",
+ )
+ updates_to_perform.append(basic_update)
+
+ # 2. Check if we need to deduct quota (system provider only)
+ model_config = application_generate_entity.model_conf
+ provider_model_bundle = model_config.provider_model_bundle
+ provider_configuration = provider_model_bundle.configuration
+
+ if (
+ provider_configuration.using_provider_type == ProviderType.SYSTEM
+ and provider_configuration.system_configuration
+ and provider_configuration.system_configuration.current_quota_type is not None
+ ):
+ system_configuration = provider_configuration.system_configuration
+
+ # Calculate quota usage
+ used_quota = _calculate_quota_usage(
+ message=message,
+ system_configuration=system_configuration,
+ model_name=model_config.model,
+ )
+
+ if used_quota is not None:
+ quota_update = _ProviderUpdateOperation(
+ filters=_ProviderUpdateFilters(
+ tenant_id=tenant_id,
+ provider_name=ModelProviderID(model_config.provider).provider_name,
+ provider_type=ProviderType.SYSTEM.value,
+ quota_type=provider_configuration.system_configuration.current_quota_type.value,
+ ),
+ values=_ProviderUpdateValues(quota_used=Provider.quota_used + used_quota, last_used=current_time),
+ additional_filters=_ProviderUpdateAdditionalFilters(
+ quota_limit_check=True # Provider.quota_limit > Provider.quota_used
+ ),
+ description="quota_deduction_update",
+ )
+ updates_to_perform.append(quota_update)
+
+ # Execute all updates
+ start_time = time_module.perf_counter()
+ try:
+ _execute_provider_updates(updates_to_perform)
+
+ # Log successful completion with timing
+ duration = time_module.perf_counter() - start_time
+
+ logger.info(
+ f"Provider updates completed successfully. "
+ f"Updates: {len(updates_to_perform)}, Duration: {duration:.3f}s, "
+ f"Tenant: {tenant_id}, Provider: {provider_name}"
+ )
+
+ except Exception as e:
+ # Log failure with timing and context
+ duration = time_module.perf_counter() - start_time
+
+ logger.exception(
+ f"Provider updates failed after {duration:.3f}s. "
+ f"Updates: {len(updates_to_perform)}, Tenant: {tenant_id}, "
+ f"Provider: {provider_name}"
+ )
+ raise
+
+
+def _calculate_quota_usage(
+ *, message: Message, system_configuration: SystemConfiguration, model_name: str
+) -> Optional[int]:
+ """Calculate quota usage based on message tokens and quota type."""
+ quota_unit = None
+ for quota_configuration in system_configuration.quota_configurations:
+ if quota_configuration.quota_type == system_configuration.current_quota_type:
+ quota_unit = quota_configuration.quota_unit
+ if quota_configuration.quota_limit == -1:
+ return None
+ break
+ if quota_unit is None:
+ return None
+
+ try:
+ if quota_unit == QuotaUnit.TOKENS:
+ tokens = message.message_tokens + message.answer_tokens
+ return tokens
+ if quota_unit == QuotaUnit.CREDITS:
+ tokens = dify_config.get_model_credits(model_name)
+ return tokens
+ elif quota_unit == QuotaUnit.TIMES:
+ return 1
+ return None
+ except Exception as e:
+ logger.exception("Failed to calculate quota usage")
+ return None
+
+
+def _execute_provider_updates(updates_to_perform: list[_ProviderUpdateOperation]):
+ """Execute all Provider updates in a single transaction."""
+ if not updates_to_perform:
+ return
+
+ # Use SQLAlchemy's context manager for transaction management
+ # This automatically handles commit/rollback
+ with Session(db.engine) as session:
+ # Use a single transaction for all updates
+ for update_operation in updates_to_perform:
+ filters = update_operation.filters
+ values = update_operation.values
+ additional_filters = update_operation.additional_filters
+ description = update_operation.description
+
+ # Build the where conditions
+ where_conditions = [
+ Provider.tenant_id == filters.tenant_id,
+ Provider.provider_name == filters.provider_name,
+ ]
+
+ # Add additional filters if specified
+ if filters.provider_type is not None:
+ where_conditions.append(Provider.provider_type == filters.provider_type)
+ if filters.quota_type is not None:
+ where_conditions.append(Provider.quota_type == filters.quota_type)
+ if additional_filters.quota_limit_check:
+ where_conditions.append(Provider.quota_limit > Provider.quota_used)
+
+ # Prepare values dict for SQLAlchemy update
+ update_values = {}
+ if values.last_used is not None:
+ update_values["last_used"] = values.last_used
+ if values.quota_used is not None:
+ update_values["quota_used"] = values.quota_used
+
+ # Build and execute the update statement
+ stmt = update(Provider).where(*where_conditions).values(**update_values)
+ result = session.execute(stmt)
+ rows_affected = result.rowcount
+
+ logger.debug(
+ f"Provider update ({description}): {rows_affected} rows affected. "
+ f"Filters: {filters.model_dump()}, Values: {update_values}"
+ )
+
+ # If no rows were affected for quota updates, log a warning
+ if rows_affected == 0 and description == "quota_deduction_update":
+ logger.warning(
+ f"No Provider rows updated for quota deduction. "
+ f"This may indicate quota limit exceeded or provider not found. "
+ f"Filters: {filters.model_dump()}"
+ )
+
+ logger.debug(f"Successfully processed {len(updates_to_perform)} Provider updates")
diff --git a/api/extensions/ext_app_metrics.py b/api/extensions/ext_app_metrics.py
index b7d412d68d..56a69a1862 100644
--- a/api/extensions/ext_app_metrics.py
+++ b/api/extensions/ext_app_metrics.py
@@ -12,14 +12,14 @@ def init_app(app: DifyApp):
@app.after_request
def after_request(response):
"""Add Version headers to the response."""
- response.headers.add("X-Version", dify_config.CURRENT_VERSION)
+ response.headers.add("X-Version", dify_config.project.version)
response.headers.add("X-Env", dify_config.DEPLOY_ENV)
return response
@app.route("/health")
def health():
return Response(
- json.dumps({"pid": os.getpid(), "status": "ok", "version": dify_config.CURRENT_VERSION}),
+ json.dumps({"pid": os.getpid(), "status": "ok", "version": dify_config.project.version}),
status=200,
content_type="application/json",
)
diff --git a/api/extensions/ext_blueprints.py b/api/extensions/ext_blueprints.py
index 316be12f5c..a4d013ffc0 100644
--- a/api/extensions/ext_blueprints.py
+++ b/api/extensions/ext_blueprints.py
@@ -10,6 +10,7 @@ def init_app(app: DifyApp):
from controllers.console import bp as console_app_bp
from controllers.files import bp as files_bp
from controllers.inner_api import bp as inner_api_bp
+ from controllers.mcp import bp as mcp_bp
from controllers.service_api import bp as service_api_bp
from controllers.web import bp as web_bp
@@ -46,3 +47,4 @@ def init_app(app: DifyApp):
app.register_blueprint(files_bp)
app.register_blueprint(inner_api_bp)
+ app.register_blueprint(mcp_bp)
diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py
index a837552007..6279b1ad36 100644
--- a/api/extensions/ext_celery.py
+++ b/api/extensions/ext_celery.py
@@ -21,6 +21,7 @@ def init_app(app: DifyApp) -> Celery:
"master_name": dify_config.CELERY_SENTINEL_MASTER_NAME,
"sentinel_kwargs": {
"socket_timeout": dify_config.CELERY_SENTINEL_SOCKET_TIMEOUT,
+ "password": dify_config.CELERY_SENTINEL_PASSWORD,
},
}
diff --git a/api/extensions/ext_login.py b/api/extensions/ext_login.py
index 3b4d787d01..11d1856ac4 100644
--- a/api/extensions/ext_login.py
+++ b/api/extensions/ext_login.py
@@ -10,7 +10,7 @@ from dify_app import DifyApp
from extensions.ext_database import db
from libs.passport import PassportService
from models.account import Account, Tenant, TenantAccountJoin
-from models.model import EndUser
+from models.model import AppMCPServer, EndUser
from services.account_service import AccountService
login_manager = flask_login.LoginManager()
@@ -74,6 +74,21 @@ def load_user_from_request(request_from_flask_login):
if not end_user:
raise NotFound("End user not found.")
return end_user
+ elif request.blueprint == "mcp":
+ server_code = request.view_args.get("server_code") if request.view_args else None
+ if not server_code:
+ raise Unauthorized("Invalid Authorization token.")
+ app_mcp_server = db.session.query(AppMCPServer).filter(AppMCPServer.server_code == server_code).first()
+ if not app_mcp_server:
+ raise NotFound("App MCP server not found.")
+ end_user = (
+ db.session.query(EndUser)
+ .filter(EndUser.external_user_id == app_mcp_server.id, EndUser.type == "mcp")
+ .first()
+ )
+ if not end_user:
+ raise NotFound("End user not found.")
+ return end_user
@user_logged_in.connect
diff --git a/api/extensions/ext_mail.py b/api/extensions/ext_mail.py
index 84bc12eca0..df5d8a9c11 100644
--- a/api/extensions/ext_mail.py
+++ b/api/extensions/ext_mail.py
@@ -54,6 +54,15 @@ class Mail:
use_tls=dify_config.SMTP_USE_TLS,
opportunistic_tls=dify_config.SMTP_OPPORTUNISTIC_TLS,
)
+ case "sendgrid":
+ from libs.sendgrid import SendGridClient
+
+ if not dify_config.SENDGRID_API_KEY:
+ raise ValueError("SENDGRID_API_KEY is required for SendGrid mail type")
+
+ self._client = SendGridClient(
+ sendgrid_api_key=dify_config.SENDGRID_API_KEY, _from=dify_config.MAIL_DEFAULT_SEND_FROM or ""
+ )
case _:
raise ValueError("Unsupported mail type {}".format(mail_type))
diff --git a/api/extensions/ext_otel.py b/api/extensions/ext_otel.py
index 6dcfa7bec6..b62b0b60d6 100644
--- a/api/extensions/ext_otel.py
+++ b/api/extensions/ext_otel.py
@@ -12,6 +12,7 @@ from flask_login import user_loaded_from_request, user_logged_in # type: ignore
from configs import dify_config
from dify_app import DifyApp
+from libs.helper import extract_tenant_id
from models import Account, EndUser
@@ -24,11 +25,8 @@ def on_user_loaded(_sender, user: Union["Account", "EndUser"]):
if user:
try:
current_span = get_current_span()
- if isinstance(user, Account) and user.current_tenant_id:
- tenant_id = user.current_tenant_id
- elif isinstance(user, EndUser):
- tenant_id = user.tenant_id
- else:
+ tenant_id = extract_tenant_id(user)
+ if not tenant_id:
return
if current_span:
current_span.set_attribute("service.tenant.id", tenant_id)
@@ -49,7 +47,7 @@ def init_app(app: DifyApp):
logging.getLogger().addHandler(exception_handler)
def init_flask_instrumentor(app: DifyApp):
- meter = get_meter("http_metrics", version=dify_config.CURRENT_VERSION)
+ meter = get_meter("http_metrics", version=dify_config.project.version)
_http_response_counter = meter.create_counter(
"http.server.response.count",
description="Total number of HTTP responses by status code, method and target",
@@ -163,7 +161,7 @@ def init_app(app: DifyApp):
resource = Resource(
attributes={
ResourceAttributes.SERVICE_NAME: dify_config.APPLICATION_NAME,
- ResourceAttributes.SERVICE_VERSION: f"dify-{dify_config.CURRENT_VERSION}-{dify_config.COMMIT_SHA}",
+ ResourceAttributes.SERVICE_VERSION: f"dify-{dify_config.project.version}-{dify_config.COMMIT_SHA}",
ResourceAttributes.PROCESS_PID: os.getpid(),
ResourceAttributes.DEPLOYMENT_ENVIRONMENT: f"{dify_config.DEPLOY_ENV}-{dify_config.EDITION}",
ResourceAttributes.HOST_NAME: socket.gethostname(),
diff --git a/api/extensions/ext_redis.py b/api/extensions/ext_redis.py
index c283b1b7ca..be2f6115f7 100644
--- a/api/extensions/ext_redis.py
+++ b/api/extensions/ext_redis.py
@@ -1,6 +1,10 @@
+import functools
+import logging
+from collections.abc import Callable
from typing import Any, Union
import redis
+from redis import RedisError
from redis.cache import CacheConfig
from redis.cluster import ClusterNode, RedisCluster
from redis.connection import Connection, SSLConnection
@@ -9,6 +13,8 @@ from redis.sentinel import Sentinel
from configs import dify_config
from dify_app import DifyApp
+logger = logging.getLogger(__name__)
+
class RedisClientWrapper:
"""
@@ -115,3 +121,25 @@ def init_app(app: DifyApp):
redis_client.initialize(redis.Redis(connection_pool=pool))
app.extensions["redis"] = redis_client
+
+
+def redis_fallback(default_return: Any = None):
+ """
+ decorator to handle Redis operation exceptions and return a default value when Redis is unavailable.
+
+ Args:
+ default_return: The value to return when a Redis operation fails. Defaults to None.
+ """
+
+ def decorator(func: Callable):
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ try:
+ return func(*args, **kwargs)
+ except RedisError as e:
+ logger.warning(f"Redis operation failed in {func.__name__}: {str(e)}", exc_info=True)
+ return default_return
+
+ return wrapper
+
+ return decorator
diff --git a/api/extensions/ext_sentry.py b/api/extensions/ext_sentry.py
index 3a74aace6a..82aed0d98d 100644
--- a/api/extensions/ext_sentry.py
+++ b/api/extensions/ext_sentry.py
@@ -35,6 +35,6 @@ def init_app(app: DifyApp):
traces_sample_rate=dify_config.SENTRY_TRACES_SAMPLE_RATE,
profiles_sample_rate=dify_config.SENTRY_PROFILES_SAMPLE_RATE,
environment=dify_config.DEPLOY_ENV,
- release=f"dify-{dify_config.CURRENT_VERSION}-{dify_config.COMMIT_SHA}",
+ release=f"dify-{dify_config.project.version}-{dify_config.COMMIT_SHA}",
before_send=before_send,
)
diff --git a/api/factories/agent_factory.py b/api/factories/agent_factory.py
index 4b12afb528..2570bc22f1 100644
--- a/api/factories/agent_factory.py
+++ b/api/factories/agent_factory.py
@@ -10,6 +10,6 @@ def get_plugin_agent_strategy(
agent_provider = manager.fetch_agent_strategy_provider(tenant_id, agent_strategy_provider_name)
for agent_strategy in agent_provider.declaration.strategies:
if agent_strategy.identity.name == agent_strategy_name:
- return PluginAgentStrategy(tenant_id, agent_strategy)
+ return PluginAgentStrategy(tenant_id, agent_strategy, agent_provider.meta.version)
raise ValueError(f"Agent strategy {agent_strategy_name} not found")
diff --git a/api/factories/file_factory.py b/api/factories/file_factory.py
index 52f119936f..25d1390492 100644
--- a/api/factories/file_factory.py
+++ b/api/factories/file_factory.py
@@ -5,6 +5,7 @@ from typing import Any, cast
import httpx
from sqlalchemy import select
+from sqlalchemy.orm import Session
from constants import AUDIO_EXTENSIONS, DOCUMENT_EXTENSIONS, IMAGE_EXTENSIONS, VIDEO_EXTENSIONS
from core.file import File, FileBelongsTo, FileTransferMethod, FileType, FileUploadConfig, helpers
@@ -91,6 +92,8 @@ def build_from_mappings(
tenant_id: str,
strict_type_validation: bool = False,
) -> Sequence[File]:
+ # TODO(QuantumGhost): Performance concern - each mapping triggers a separate database query.
+ # Implement batch processing to reduce database load when handling multiple files.
files = [
build_from_mapping(
mapping=mapping,
@@ -377,3 +380,75 @@ def _get_file_type_by_mimetype(mime_type: str) -> FileType | None:
def get_file_type_by_mime_type(mime_type: str) -> FileType:
return _get_file_type_by_mimetype(mime_type) or FileType.CUSTOM
+
+
+class StorageKeyLoader:
+ """FileKeyLoader load the storage key from database for a list of files.
+ This loader is batched, the database query count is constant regardless of the input size.
+ """
+
+ def __init__(self, session: Session, tenant_id: str) -> None:
+ self._session = session
+ self._tenant_id = tenant_id
+
+ def _load_upload_files(self, upload_file_ids: Sequence[uuid.UUID]) -> Mapping[uuid.UUID, UploadFile]:
+ stmt = select(UploadFile).where(
+ UploadFile.id.in_(upload_file_ids),
+ UploadFile.tenant_id == self._tenant_id,
+ )
+
+ return {uuid.UUID(i.id): i for i in self._session.scalars(stmt)}
+
+ def _load_tool_files(self, tool_file_ids: Sequence[uuid.UUID]) -> Mapping[uuid.UUID, ToolFile]:
+ stmt = select(ToolFile).where(
+ ToolFile.id.in_(tool_file_ids),
+ ToolFile.tenant_id == self._tenant_id,
+ )
+ return {uuid.UUID(i.id): i for i in self._session.scalars(stmt)}
+
+ def load_storage_keys(self, files: Sequence[File]):
+ """Loads storage keys for a sequence of files by retrieving the corresponding
+ `UploadFile` or `ToolFile` records from the database based on their transfer method.
+
+ This method doesn't modify the input sequence structure but updates the `_storage_key`
+ property of each file object by extracting the relevant key from its database record.
+
+ Performance note: This is a batched operation where database query count remains constant
+ regardless of input size. However, for optimal performance, input sequences should contain
+ fewer than 1000 files. For larger collections, split into smaller batches and process each
+ batch separately.
+ """
+
+ upload_file_ids: list[uuid.UUID] = []
+ tool_file_ids: list[uuid.UUID] = []
+ for file in files:
+ related_model_id = file.related_id
+ if file.related_id is None:
+ raise ValueError("file id should not be None.")
+ if file.tenant_id != self._tenant_id:
+ err_msg = (
+ f"invalid file, expected tenant_id={self._tenant_id}, "
+ f"got tenant_id={file.tenant_id}, file_id={file.id}, related_model_id={related_model_id}"
+ )
+ raise ValueError(err_msg)
+ model_id = uuid.UUID(related_model_id)
+
+ if file.transfer_method in (FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL):
+ upload_file_ids.append(model_id)
+ elif file.transfer_method == FileTransferMethod.TOOL_FILE:
+ tool_file_ids.append(model_id)
+
+ tool_files = self._load_tool_files(tool_file_ids)
+ upload_files = self._load_upload_files(upload_file_ids)
+ for file in files:
+ model_id = uuid.UUID(file.related_id)
+ if file.transfer_method in (FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL):
+ upload_file_row = upload_files.get(model_id)
+ if upload_file_row is None:
+ raise ValueError(f"Upload file not found for id: {model_id}")
+ file._storage_key = upload_file_row.key
+ elif file.transfer_method == FileTransferMethod.TOOL_FILE:
+ tool_file_row = tool_files.get(model_id)
+ if tool_file_row is None:
+ raise ValueError(f"Tool file not found for id: {model_id}")
+ file._storage_key = tool_file_row.file_key
diff --git a/api/factories/variable_factory.py b/api/factories/variable_factory.py
index fa8a90e79f..250ee4695e 100644
--- a/api/factories/variable_factory.py
+++ b/api/factories/variable_factory.py
@@ -39,11 +39,11 @@ from core.variables.variables import (
from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID
-class InvalidSelectorError(ValueError):
+class UnsupportedSegmentTypeError(Exception):
pass
-class UnsupportedSegmentTypeError(Exception):
+class TypeMismatchError(Exception):
pass
@@ -114,6 +114,10 @@ def _build_variable_from_mapping(*, mapping: Mapping[str, Any], selector: Sequen
return cast(Variable, result)
+def infer_segment_type_from_value(value: Any, /) -> SegmentType:
+ return build_segment(value).value_type
+
+
def build_segment(value: Any, /) -> Segment:
if value is None:
return NoneSegment()
@@ -144,10 +148,80 @@ def build_segment(value: Any, /) -> Segment:
case SegmentType.NONE:
return ArrayAnySegment(value=value)
case _:
+ # This should be unreachable.
raise ValueError(f"not supported value {value}")
raise ValueError(f"not supported value {value}")
+def build_segment_with_type(segment_type: SegmentType, value: Any) -> Segment:
+ """
+ Build a segment with explicit type checking.
+
+ This function creates a segment from a value while enforcing type compatibility
+ with the specified segment_type. It provides stricter type validation compared
+ to the standard build_segment function.
+
+ Args:
+ segment_type: The expected SegmentType for the resulting segment
+ value: The value to be converted into a segment
+
+ Returns:
+ Segment: A segment instance of the appropriate type
+
+ Raises:
+ TypeMismatchError: If the value type doesn't match the expected segment_type
+
+ Special Cases:
+ - For empty list [] values, if segment_type is array[*], returns the corresponding array type
+ - Type validation is performed before segment creation
+
+ Examples:
+ >>> build_segment_with_type(SegmentType.STRING, "hello")
+ StringSegment(value="hello")
+
+ >>> build_segment_with_type(SegmentType.ARRAY_STRING, [])
+ ArrayStringSegment(value=[])
+
+ >>> build_segment_with_type(SegmentType.STRING, 123)
+ # Raises TypeMismatchError
+ """
+ # Handle None values
+ if value is None:
+ if segment_type == SegmentType.NONE:
+ return NoneSegment()
+ else:
+ raise TypeMismatchError(f"Expected {segment_type}, but got None")
+
+ # Handle empty list special case for array types
+ if isinstance(value, list) and len(value) == 0:
+ if segment_type == SegmentType.ARRAY_ANY:
+ return ArrayAnySegment(value=value)
+ elif segment_type == SegmentType.ARRAY_STRING:
+ return ArrayStringSegment(value=value)
+ elif segment_type == SegmentType.ARRAY_NUMBER:
+ return ArrayNumberSegment(value=value)
+ elif segment_type == SegmentType.ARRAY_OBJECT:
+ return ArrayObjectSegment(value=value)
+ elif segment_type == SegmentType.ARRAY_FILE:
+ return ArrayFileSegment(value=value)
+ else:
+ raise TypeMismatchError(f"Expected {segment_type}, but got empty list")
+
+ # Build segment using existing logic to infer actual type
+ inferred_segment = build_segment(value)
+ inferred_type = inferred_segment.value_type
+
+ # Type compatibility checking
+ if inferred_type == segment_type:
+ return inferred_segment
+
+ # Type mismatch - raise error with descriptive message
+ raise TypeMismatchError(
+ f"Type mismatch: expected {segment_type}, but value '{value}' "
+ f"(type: {type(value).__name__}) corresponds to {inferred_type}"
+ )
+
+
def segment_to_variable(
*,
segment: Segment,
diff --git a/api/fields/app_fields.py b/api/fields/app_fields.py
index 500ca47c7e..73c224542a 100644
--- a/api/fields/app_fields.py
+++ b/api/fields/app_fields.py
@@ -1,8 +1,21 @@
+import json
+
from flask_restful import fields
from fields.workflow_fields import workflow_partial_fields
from libs.helper import AppIconUrlField, TimestampField
+
+class JsonStringField(fields.Raw):
+ def format(self, value):
+ if isinstance(value, str):
+ try:
+ return json.loads(value)
+ except (json.JSONDecodeError, TypeError):
+ return value
+ return value
+
+
app_detail_kernel_fields = {
"id": fields.String,
"name": fields.String,
@@ -218,3 +231,14 @@ app_import_fields = {
app_import_check_dependencies_fields = {
"leaked_dependencies": fields.List(fields.Nested(leaked_dependency_fields)),
}
+
+app_server_fields = {
+ "id": fields.String,
+ "name": fields.String,
+ "server_code": fields.String,
+ "description": fields.String,
+ "status": fields.String,
+ "parameters": JsonStringField,
+ "created_at": TimestampField,
+ "updated_at": TimestampField,
+}
diff --git a/api/fields/workflow_fields.py b/api/fields/workflow_fields.py
index 9f1bef3b36..f00ea71c54 100644
--- a/api/fields/workflow_fields.py
+++ b/api/fields/workflow_fields.py
@@ -17,6 +17,7 @@ class EnvironmentVariableField(fields.Raw):
"name": value.name,
"value": encrypter.obfuscated_token(value.value),
"value_type": value.value_type.value,
+ "description": value.description,
}
if isinstance(value, Variable):
return {
@@ -24,6 +25,7 @@ class EnvironmentVariableField(fields.Raw):
"name": value.name,
"value": value.value,
"value_type": value.value_type.value,
+ "description": value.description,
}
if isinstance(value, dict):
value_type = value.get("value_type")
diff --git a/api/fields/workflow_run_fields.py b/api/fields/workflow_run_fields.py
index 74fdf8bd97..a106728e9c 100644
--- a/api/fields/workflow_run_fields.py
+++ b/api/fields/workflow_run_fields.py
@@ -19,7 +19,6 @@ workflow_run_for_log_fields = {
workflow_run_for_list_fields = {
"id": fields.String,
- "sequence_number": fields.Integer,
"version": fields.String,
"status": fields.String,
"elapsed_time": fields.Float,
@@ -36,7 +35,6 @@ advanced_chat_workflow_run_for_list_fields = {
"id": fields.String,
"conversation_id": fields.String,
"message_id": fields.String,
- "sequence_number": fields.Integer,
"version": fields.String,
"status": fields.String,
"elapsed_time": fields.Float,
@@ -63,7 +61,6 @@ workflow_run_pagination_fields = {
workflow_run_detail_fields = {
"id": fields.String,
- "sequence_number": fields.Integer,
"version": fields.String,
"graph": fields.Raw(attribute="graph_dict"),
"inputs": fields.Raw(attribute="inputs_dict"),
diff --git a/api/libs/datetime_utils.py b/api/libs/datetime_utils.py
new file mode 100644
index 0000000000..e576a34629
--- /dev/null
+++ b/api/libs/datetime_utils.py
@@ -0,0 +1,22 @@
+import abc
+import datetime
+from typing import Protocol
+
+
+class _NowFunction(Protocol):
+ @abc.abstractmethod
+ def __call__(self, tz: datetime.timezone | None) -> datetime.datetime:
+ pass
+
+
+# _now_func is a callable with the _NowFunction signature.
+# Its sole purpose is to abstract time retrieval, enabling
+# developers to mock this behavior in tests and time-dependent scenarios.
+_now_func: _NowFunction = datetime.datetime.now
+
+
+def naive_utc_now() -> datetime.datetime:
+ """Return a naive datetime object (without timezone information)
+ representing current UTC time.
+ """
+ return _now_func(datetime.UTC).replace(tzinfo=None)
diff --git a/api/libs/file_utils.py b/api/libs/file_utils.py
new file mode 100644
index 0000000000..982b2cc1ac
--- /dev/null
+++ b/api/libs/file_utils.py
@@ -0,0 +1,30 @@
+from pathlib import Path
+
+
+def search_file_upwards(
+ base_dir_path: Path,
+ target_file_name: str,
+ max_search_parent_depth: int,
+) -> Path:
+ """
+ Find a target file in the current directory or its parent directories up to a specified depth.
+ :param base_dir_path: Starting directory path to search from.
+ :param target_file_name: Name of the file to search for.
+ :param max_search_parent_depth: Maximum number of parent directories to search upwards.
+ :return: Path of the file if found, otherwise None.
+ """
+ current_path = base_dir_path.resolve()
+ for _ in range(max_search_parent_depth):
+ candidate_path = current_path / target_file_name
+ if candidate_path.is_file():
+ return candidate_path
+ parent_path = current_path.parent
+ if parent_path == current_path: # reached the root directory
+ break
+ else:
+ current_path = parent_path
+
+ raise ValueError(
+ f"File '{target_file_name}' not found in the directory '{base_dir_path.resolve()}' or its parent directories"
+ f" in depth of {max_search_parent_depth}."
+ )
diff --git a/api/libs/flask_utils.py b/api/libs/flask_utils.py
new file mode 100644
index 0000000000..4ea2779584
--- /dev/null
+++ b/api/libs/flask_utils.py
@@ -0,0 +1,65 @@
+import contextvars
+from collections.abc import Iterator
+from contextlib import contextmanager
+from typing import TypeVar
+
+from flask import Flask, g, has_request_context
+
+T = TypeVar("T")
+
+
+@contextmanager
+def preserve_flask_contexts(
+ flask_app: Flask,
+ context_vars: contextvars.Context,
+) -> Iterator[None]:
+ """
+ A context manager that handles:
+ 1. flask-login's UserProxy copy
+ 2. ContextVars copy
+ 3. flask_app.app_context()
+
+ This context manager ensures that the Flask application context is properly set up,
+ the current user is preserved across context boundaries, and any provided context variables
+ are set within the new context.
+
+ Note:
+ This manager aims to allow use current_user cross thread and app context,
+ but it's not the recommend use, it's better to pass user directly in parameters.
+
+ Args:
+ flask_app: The Flask application instance
+ context_vars: contextvars.Context object containing context variables to be set in the new context
+
+ Yields:
+ None
+
+ Example:
+ ```python
+ with preserve_flask_contexts(flask_app, context_vars=context_vars):
+ # Code that needs Flask app context and context variables
+ # Current user will be preserved if available
+ ```
+ """
+ # Set context variables if provided
+ if context_vars:
+ for var, val in context_vars.items():
+ var.set(val)
+
+ # Save current user before entering new app context
+ saved_user = None
+ if has_request_context() and hasattr(g, "_login_user"):
+ saved_user = g._login_user
+
+ # Enter Flask app context
+ with flask_app.app_context():
+ try:
+ # Restore user in new app context if it was saved
+ if saved_user is not None:
+ g._login_user = saved_user
+
+ # Yield control back to the caller
+ yield
+ finally:
+ # Any cleanup can be added here if needed
+ pass
diff --git a/api/libs/helper.py b/api/libs/helper.py
index 3f2a630956..48126461a3 100644
--- a/api/libs/helper.py
+++ b/api/libs/helper.py
@@ -25,6 +25,31 @@ from extensions.ext_redis import redis_client
if TYPE_CHECKING:
from models.account import Account
+ from models.model import EndUser
+
+
+def extract_tenant_id(user: Union["Account", "EndUser"]) -> str | None:
+ """
+ Extract tenant_id from Account or EndUser object.
+
+ Args:
+ user: Account or EndUser object
+
+ Returns:
+ tenant_id string if available, None otherwise
+
+ Raises:
+ ValueError: If user is neither Account nor EndUser
+ """
+ from models.account import Account
+ from models.model import EndUser
+
+ if isinstance(user, Account):
+ return user.current_tenant_id
+ elif isinstance(user, EndUser):
+ return user.tenant_id
+ else:
+ raise ValueError(f"Invalid user type: {type(user)}. Expected Account or EndUser.")
def run(script):
diff --git a/api/libs/jsonutil.py b/api/libs/jsonutil.py
new file mode 100644
index 0000000000..fa29671034
--- /dev/null
+++ b/api/libs/jsonutil.py
@@ -0,0 +1,11 @@
+import json
+
+from pydantic import BaseModel
+
+
+class PydanticModelEncoder(json.JSONEncoder):
+ def default(self, o):
+ if isinstance(o, BaseModel):
+ return o.model_dump()
+ else:
+ super().default(o)
diff --git a/api/libs/passport.py b/api/libs/passport.py
index 8df4f529bc..fe8fc33b5f 100644
--- a/api/libs/passport.py
+++ b/api/libs/passport.py
@@ -14,9 +14,11 @@ class PassportService:
def verify(self, token):
try:
return jwt.decode(token, self.sk, algorithms=["HS256"])
+ except jwt.exceptions.ExpiredSignatureError:
+ raise Unauthorized("Token has expired.")
except jwt.exceptions.InvalidSignatureError:
raise Unauthorized("Invalid token signature.")
except jwt.exceptions.DecodeError:
raise Unauthorized("Invalid token.")
- except jwt.exceptions.ExpiredSignatureError:
- raise Unauthorized("Token has expired.")
+ except jwt.exceptions.PyJWTError: # Catch-all for other JWT errors
+ raise Unauthorized("Invalid token.")
diff --git a/api/libs/sendgrid.py b/api/libs/sendgrid.py
new file mode 100644
index 0000000000..5409e3eeeb
--- /dev/null
+++ b/api/libs/sendgrid.py
@@ -0,0 +1,45 @@
+import logging
+
+import sendgrid # type: ignore
+from python_http_client.exceptions import ForbiddenError, UnauthorizedError
+from sendgrid.helpers.mail import Content, Email, Mail, To # type: ignore
+
+
+class SendGridClient:
+ def __init__(self, sendgrid_api_key: str, _from: str):
+ self.sendgrid_api_key = sendgrid_api_key
+ self._from = _from
+
+ def send(self, mail: dict):
+ logging.debug("Sending email with SendGrid")
+
+ try:
+ _to = mail["to"]
+
+ if not _to:
+ raise ValueError("SendGridClient: Cannot send email: recipient address is missing.")
+
+ sg = sendgrid.SendGridAPIClient(api_key=self.sendgrid_api_key)
+ from_email = Email(self._from)
+ to_email = To(_to)
+ subject = mail["subject"]
+ content = Content("text/html", mail["html"])
+ mail = Mail(from_email, to_email, subject, content)
+ mail_json = mail.get() # type: ignore
+ response = sg.client.mail.send.post(request_body=mail_json)
+ logging.debug(response.status_code)
+ logging.debug(response.body)
+ logging.debug(response.headers)
+
+ except TimeoutError as e:
+ logging.exception("SendGridClient Timeout occurred while sending email")
+ raise
+ except (UnauthorizedError, ForbiddenError) as e:
+ logging.exception(
+ "SendGridClient Authentication failed. "
+ "Verify that your credentials and the 'from' email address are correct"
+ )
+ raise
+ except Exception as e:
+ logging.exception(f"SendGridClient Unexpected error occurred while sending email to {_to}")
+ raise
diff --git a/api/libs/smtp.py b/api/libs/smtp.py
index 35561f071c..b94386660e 100644
--- a/api/libs/smtp.py
+++ b/api/libs/smtp.py
@@ -22,7 +22,11 @@ class SMTPClient:
if self.use_tls:
if self.opportunistic_tls:
smtp = smtplib.SMTP(self.server, self.port, timeout=10)
+ # Send EHLO command with the HELO domain name as the server address
+ smtp.ehlo(self.server)
smtp.starttls()
+ # Resend EHLO command to identify the TLS session
+ smtp.ehlo(self.server)
else:
smtp = smtplib.SMTP_SSL(self.server, self.port, timeout=10)
else:
diff --git a/api/migrations/versions/2025_06_19_1633-0ab65e1cc7fa_remove_sequence_number_from_workflow_.py b/api/migrations/versions/2025_06_19_1633-0ab65e1cc7fa_remove_sequence_number_from_workflow_.py
new file mode 100644
index 0000000000..29fef77798
--- /dev/null
+++ b/api/migrations/versions/2025_06_19_1633-0ab65e1cc7fa_remove_sequence_number_from_workflow_.py
@@ -0,0 +1,66 @@
+"""remove sequence_number from workflow_runs
+
+Revision ID: 0ab65e1cc7fa
+Revises: 4474872b0ee6
+Create Date: 2025-06-19 16:33:13.377215
+
+"""
+from alembic import op
+import models as models
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '0ab65e1cc7fa'
+down_revision = '4474872b0ee6'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table('workflow_runs', schema=None) as batch_op:
+ batch_op.drop_index(batch_op.f('workflow_run_tenant_app_sequence_idx'))
+ batch_op.drop_column('sequence_number')
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+
+ # WARNING: This downgrade CANNOT recover the original sequence_number values!
+ # The original sequence numbers are permanently lost after the upgrade.
+ # This downgrade will regenerate sequence numbers based on created_at order,
+ # which may result in different values than the original sequence numbers.
+ #
+ # If you need to preserve original sequence numbers, use the alternative
+ # migration approach that creates a backup table before removal.
+
+ # Step 1: Add sequence_number column as nullable first
+ with op.batch_alter_table('workflow_runs', schema=None) as batch_op:
+ batch_op.add_column(sa.Column('sequence_number', sa.INTEGER(), autoincrement=False, nullable=True))
+
+ # Step 2: Populate sequence_number values based on created_at order within each app
+ # NOTE: This recreates sequence numbering logic but values will be different
+ # from the original sequence numbers that were removed in the upgrade
+ connection = op.get_bind()
+ connection.execute(sa.text("""
+ UPDATE workflow_runs
+ SET sequence_number = subquery.row_num
+ FROM (
+ SELECT id, ROW_NUMBER() OVER (
+ PARTITION BY tenant_id, app_id
+ ORDER BY created_at, id
+ ) as row_num
+ FROM workflow_runs
+ ) subquery
+ WHERE workflow_runs.id = subquery.id
+ """))
+
+ # Step 3: Make the column NOT NULL and add the index
+ with op.batch_alter_table('workflow_runs', schema=None) as batch_op:
+ batch_op.alter_column('sequence_number', nullable=False)
+ batch_op.create_index(batch_op.f('workflow_run_tenant_app_sequence_idx'), ['tenant_id', 'app_id', 'sequence_number'], unique=False)
+
+ # ### end Alembic commands ###
diff --git a/api/migrations/versions/2025_06_25_0936-58eb7bdb93fe_add_mcp_server_tool_and_app_server.py b/api/migrations/versions/2025_06_25_0936-58eb7bdb93fe_add_mcp_server_tool_and_app_server.py
new file mode 100644
index 0000000000..0548bf05ef
--- /dev/null
+++ b/api/migrations/versions/2025_06_25_0936-58eb7bdb93fe_add_mcp_server_tool_and_app_server.py
@@ -0,0 +1,64 @@
+"""add mcp server tool and app server
+
+Revision ID: 58eb7bdb93fe
+Revises: 0ab65e1cc7fa
+Create Date: 2025-06-25 09:36:07.510570
+
+"""
+from alembic import op
+import models as models
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '58eb7bdb93fe'
+down_revision = '0ab65e1cc7fa'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('app_mcp_servers',
+ sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
+ sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
+ sa.Column('app_id', models.types.StringUUID(), nullable=False),
+ sa.Column('name', sa.String(length=255), nullable=False),
+ sa.Column('description', sa.String(length=255), nullable=False),
+ sa.Column('server_code', sa.String(length=255), nullable=False),
+ sa.Column('status', sa.String(length=255), server_default=sa.text("'normal'::character varying"), nullable=False),
+ sa.Column('parameters', sa.Text(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
+ sa.PrimaryKeyConstraint('id', name='app_mcp_server_pkey'),
+ sa.UniqueConstraint('tenant_id', 'app_id', name='unique_app_mcp_server_tenant_app_id'),
+ sa.UniqueConstraint('server_code', name='unique_app_mcp_server_server_code')
+ )
+ op.create_table('tool_mcp_providers',
+ sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
+ sa.Column('name', sa.String(length=40), nullable=False),
+ sa.Column('server_identifier', sa.String(length=24), nullable=False),
+ sa.Column('server_url', sa.Text(), nullable=False),
+ sa.Column('server_url_hash', sa.String(length=64), nullable=False),
+ sa.Column('icon', sa.String(length=255), nullable=True),
+ sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
+ sa.Column('user_id', models.types.StringUUID(), nullable=False),
+ sa.Column('encrypted_credentials', sa.Text(), nullable=True),
+ sa.Column('authed', sa.Boolean(), nullable=False),
+ sa.Column('tools', sa.Text(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False),
+ sa.PrimaryKeyConstraint('id', name='tool_mcp_provider_pkey'),
+ sa.UniqueConstraint('tenant_id', 'name', name='unique_mcp_provider_name'),
+ sa.UniqueConstraint('tenant_id', 'server_identifier', name='unique_mcp_provider_server_identifier'),
+ sa.UniqueConstraint('tenant_id', 'server_url_hash', name='unique_mcp_provider_server_url')
+ )
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('tool_mcp_providers')
+ op.drop_table('app_mcp_servers')
+ # ### end Alembic commands ###
diff --git a/api/models/__init__.py b/api/models/__init__.py
index 83b50eb099..1b4bdd32e4 100644
--- a/api/models/__init__.py
+++ b/api/models/__init__.py
@@ -34,6 +34,7 @@ from .model import (
App,
AppAnnotationHitHistory,
AppAnnotationSetting,
+ AppMCPServer,
AppMode,
AppModelConfig,
Conversation,
@@ -103,6 +104,7 @@ __all__ = [
"AppAnnotationHitHistory",
"AppAnnotationSetting",
"AppDatasetJoin",
+ "AppMCPServer", # Added
"AppMode",
"AppModelConfig",
"BuiltinToolProvider",
diff --git a/api/models/_workflow_exc.py b/api/models/_workflow_exc.py
new file mode 100644
index 0000000000..f6271bda47
--- /dev/null
+++ b/api/models/_workflow_exc.py
@@ -0,0 +1,20 @@
+"""All these exceptions are not meant to be caught by callers."""
+
+
+class WorkflowDataError(Exception):
+ """Base class for all workflow data related exceptions.
+
+ This should be used to indicate issues with workflow data integrity, such as
+ no `graph` configuration, missing `nodes` field in `graph` configuration, or
+ similar issues.
+ """
+
+ pass
+
+
+class NodeNotFoundError(WorkflowDataError):
+ """Raised when a node with the specified ID is not found in the workflow."""
+
+ def __init__(self, node_id: str):
+ super().__init__(f"Node with ID '{node_id}' not found in the workflow.")
+ self.node_id = node_id
diff --git a/api/models/dataset.py b/api/models/dataset.py
index ad43d6f371..1ec27203a0 100644
--- a/api/models/dataset.py
+++ b/api/models/dataset.py
@@ -140,7 +140,7 @@ class Dataset(Base):
def word_count(self):
return (
db.session.query(Document)
- .with_entities(func.coalesce(func.sum(Document.word_count)))
+ .with_entities(func.coalesce(func.sum(Document.word_count), 0))
.filter(Document.dataset_id == self.id)
.scalar()
)
@@ -448,7 +448,7 @@ class Document(Base):
def hit_count(self):
return (
db.session.query(DocumentSegment)
- .with_entities(func.coalesce(func.sum(DocumentSegment.hit_count)))
+ .with_entities(func.coalesce(func.sum(DocumentSegment.hit_count), 0))
.filter(DocumentSegment.document_id == self.id)
.scalar()
)
diff --git a/api/models/enums.py b/api/models/enums.py
index 4434c3fec8..cc9f28a7bb 100644
--- a/api/models/enums.py
+++ b/api/models/enums.py
@@ -21,3 +21,12 @@ class DraftVariableType(StrEnum):
NODE = "node"
SYS = "sys"
CONVERSATION = "conversation"
+
+
+class MessageStatus(StrEnum):
+ """
+ Message Status Enum
+ """
+
+ NORMAL = "normal"
+ ERROR = "error"
diff --git a/api/models/model.py b/api/models/model.py
index 229e77134e..7e9e91727d 100644
--- a/api/models/model.py
+++ b/api/models/model.py
@@ -10,7 +10,6 @@ from core.plugin.entities.plugin import GenericProviderID
from core.tools.entities.tool_entities import ToolProviderType
from core.tools.signature import sign_tool_file
from core.workflow.entities.workflow_execution import WorkflowExecutionStatus
-from services.plugin.plugin_service import PluginService
if TYPE_CHECKING:
from models.workflow import Workflow
@@ -51,7 +50,6 @@ class AppMode(StrEnum):
CHAT = "chat"
ADVANCED_CHAT = "advanced-chat"
AGENT_CHAT = "agent-chat"
- CHANNEL = "channel"
@classmethod
def value_of(cls, value: str) -> "AppMode":
@@ -169,6 +167,7 @@ class App(Base):
@property
def deleted_tools(self) -> list:
from core.tools.tool_manager import ToolManager
+ from services.plugin.plugin_service import PluginService
# get agent mode tools
app_model_config = self.app_model_config
@@ -611,6 +610,14 @@ class InstalledApp(Base):
return tenant
+class ConversationSource(StrEnum):
+ """This enumeration is designed for use with `Conversation.from_source`."""
+
+ # NOTE(QuantumGhost): The enumeration members may not cover all possible cases.
+ API = "api"
+ CONSOLE = "console"
+
+
class Conversation(Base):
__tablename__ = "conversations"
__table_args__ = (
@@ -632,7 +639,14 @@ class Conversation(Base):
system_instruction = db.Column(db.Text)
system_instruction_tokens = db.Column(db.Integer, nullable=False, server_default=db.text("0"))
status = db.Column(db.String(255), nullable=False)
+
+ # The `invoke_from` records how the conversation is created.
+ #
+ # Its value corresponds to the members of `InvokeFrom`.
+ # (api/core/app/entities/app_invoke_entities.py)
invoke_from = db.Column(db.String(255), nullable=True)
+
+ # ref: ConversationSource.
from_source = db.Column(db.String(255), nullable=False)
from_end_user_id = db.Column(StringUUID)
from_account_id = db.Column(StringUUID)
@@ -661,7 +675,7 @@ class Conversation(Base):
if isinstance(value, dict) and value.get("dify_model_identity") == FILE_MODEL_IDENTITY:
if value["transfer_method"] == FileTransferMethod.TOOL_FILE:
value["tool_file_id"] = value["related_id"]
- elif value["transfer_method"] == FileTransferMethod.LOCAL_FILE:
+ elif value["transfer_method"] in [FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL]:
value["upload_file_id"] = value["related_id"]
inputs[key] = file_factory.build_from_mapping(mapping=value, tenant_id=value["tenant_id"])
elif isinstance(value, list) and all(
@@ -671,7 +685,7 @@ class Conversation(Base):
for item in value:
if item["transfer_method"] == FileTransferMethod.TOOL_FILE:
item["tool_file_id"] = item["related_id"]
- elif item["transfer_method"] == FileTransferMethod.LOCAL_FILE:
+ elif item["transfer_method"] in [FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL]:
item["upload_file_id"] = item["related_id"]
inputs[key].append(file_factory.build_from_mapping(mapping=item, tenant_id=item["tenant_id"]))
@@ -703,7 +717,6 @@ class Conversation(Base):
if "model" in override_model_configs:
app_model_config = AppModelConfig()
app_model_config = app_model_config.from_model_config_dict(override_model_configs)
- assert app_model_config is not None, "app model config not found"
model_config = app_model_config.to_dict()
else:
model_config["configs"] = override_model_configs
@@ -817,7 +830,12 @@ class Conversation(Base):
@property
def first_message(self):
- return db.session.query(Message).filter(Message.conversation_id == self.id).first()
+ return (
+ db.session.query(Message)
+ .filter(Message.conversation_id == self.id)
+ .order_by(Message.created_at.asc())
+ .first()
+ )
@property
def app(self):
@@ -894,11 +912,11 @@ class Message(Base):
_inputs: Mapped[dict] = mapped_column("inputs", db.JSON)
query: Mapped[str] = db.Column(db.Text, nullable=False)
message = db.Column(db.JSON, nullable=False)
- message_tokens = db.Column(db.Integer, nullable=False, server_default=db.text("0"))
+ message_tokens: Mapped[int] = db.Column(db.Integer, nullable=False, server_default=db.text("0"))
message_unit_price = db.Column(db.Numeric(10, 4), nullable=False)
message_price_unit = db.Column(db.Numeric(10, 7), nullable=False, server_default=db.text("0.001"))
answer: Mapped[str] = db.Column(db.Text, nullable=False)
- answer_tokens = db.Column(db.Integer, nullable=False, server_default=db.text("0"))
+ answer_tokens: Mapped[int] = db.Column(db.Integer, nullable=False, server_default=db.text("0"))
answer_unit_price = db.Column(db.Numeric(10, 4), nullable=False)
answer_price_unit = db.Column(db.Numeric(10, 7), nullable=False, server_default=db.text("0.001"))
parent_message_id = db.Column(StringUUID, nullable=True)
@@ -915,7 +933,7 @@ class Message(Base):
created_at: Mapped[datetime] = mapped_column(db.DateTime, server_default=func.current_timestamp())
updated_at = db.Column(db.DateTime, nullable=False, server_default=func.current_timestamp())
agent_based = db.Column(db.Boolean, nullable=False, server_default=db.text("false"))
- workflow_run_id = db.Column(StringUUID)
+ workflow_run_id: Mapped[str] = db.Column(StringUUID)
@property
def inputs(self):
@@ -927,7 +945,7 @@ class Message(Base):
if isinstance(value, dict) and value.get("dify_model_identity") == FILE_MODEL_IDENTITY:
if value["transfer_method"] == FileTransferMethod.TOOL_FILE:
value["tool_file_id"] = value["related_id"]
- elif value["transfer_method"] == FileTransferMethod.LOCAL_FILE:
+ elif value["transfer_method"] in [FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL]:
value["upload_file_id"] = value["related_id"]
inputs[key] = file_factory.build_from_mapping(mapping=value, tenant_id=value["tenant_id"])
elif isinstance(value, list) and all(
@@ -937,7 +955,7 @@ class Message(Base):
for item in value:
if item["transfer_method"] == FileTransferMethod.TOOL_FILE:
item["tool_file_id"] = item["related_id"]
- elif item["transfer_method"] == FileTransferMethod.LOCAL_FILE:
+ elif item["transfer_method"] in [FileTransferMethod.LOCAL_FILE, FileTransferMethod.REMOTE_URL]:
item["upload_file_id"] = item["related_id"]
inputs[key].append(file_factory.build_from_mapping(mapping=item, tenant_id=item["tenant_id"]))
return inputs
@@ -1437,6 +1455,39 @@ class EndUser(Base, UserMixin):
updated_at = db.Column(db.DateTime, nullable=False, server_default=func.current_timestamp())
+class AppMCPServer(Base):
+ __tablename__ = "app_mcp_servers"
+ __table_args__ = (
+ db.PrimaryKeyConstraint("id", name="app_mcp_server_pkey"),
+ db.UniqueConstraint("tenant_id", "app_id", name="unique_app_mcp_server_tenant_app_id"),
+ db.UniqueConstraint("server_code", name="unique_app_mcp_server_server_code"),
+ )
+ id = db.Column(StringUUID, server_default=db.text("uuid_generate_v4()"))
+ tenant_id = db.Column(StringUUID, nullable=False)
+ app_id = db.Column(StringUUID, nullable=False)
+ name = db.Column(db.String(255), nullable=False)
+ description = db.Column(db.String(255), nullable=False)
+ server_code = db.Column(db.String(255), nullable=False)
+ status = db.Column(db.String(255), nullable=False, server_default=db.text("'normal'::character varying"))
+ parameters = db.Column(db.Text, nullable=False)
+
+ created_at = db.Column(db.DateTime, nullable=False, server_default=func.current_timestamp())
+ updated_at = db.Column(db.DateTime, nullable=False, server_default=func.current_timestamp())
+
+ @staticmethod
+ def generate_server_code(n):
+ while True:
+ result = generate_string(n)
+ while db.session.query(AppMCPServer).filter(AppMCPServer.server_code == result).count() > 0:
+ result = generate_string(n)
+
+ return result
+
+ @property
+ def parameters_dict(self) -> dict[str, Any]:
+ return cast(dict[str, Any], json.loads(self.parameters))
+
+
class Site(Base):
__tablename__ = "sites"
__table_args__ = (
diff --git a/api/models/tools.py b/api/models/tools.py
index 03fbc3acb1..9d2c3baea5 100644
--- a/api/models/tools.py
+++ b/api/models/tools.py
@@ -1,12 +1,16 @@
import json
from datetime import datetime
from typing import Any, cast
+from urllib.parse import urlparse
import sqlalchemy as sa
from deprecated import deprecated
from sqlalchemy import ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column
+from core.file import helpers as file_helpers
+from core.helper import encrypter
+from core.mcp.types import Tool
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_bundle import ApiToolBundle
from core.tools.entities.tool_entities import ApiProviderSchemaType, WorkflowToolParameterConfiguration
@@ -189,6 +193,108 @@ class WorkflowToolProvider(Base):
return db.session.query(App).filter(App.id == self.app_id).first()
+class MCPToolProvider(Base):
+ """
+ The table stores the mcp providers.
+ """
+
+ __tablename__ = "tool_mcp_providers"
+ __table_args__ = (
+ db.PrimaryKeyConstraint("id", name="tool_mcp_provider_pkey"),
+ db.UniqueConstraint("tenant_id", "server_url_hash", name="unique_mcp_provider_server_url"),
+ db.UniqueConstraint("tenant_id", "name", name="unique_mcp_provider_name"),
+ db.UniqueConstraint("tenant_id", "server_identifier", name="unique_mcp_provider_server_identifier"),
+ )
+
+ id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()"))
+ # name of the mcp provider
+ name: Mapped[str] = mapped_column(db.String(40), nullable=False)
+ # server identifier of the mcp provider
+ server_identifier: Mapped[str] = mapped_column(db.String(24), nullable=False)
+ # encrypted url of the mcp provider
+ server_url: Mapped[str] = mapped_column(db.Text, nullable=False)
+ # hash of server_url for uniqueness check
+ server_url_hash: Mapped[str] = mapped_column(db.String(64), nullable=False)
+ # icon of the mcp provider
+ icon: Mapped[str] = mapped_column(db.String(255), nullable=True)
+ # tenant id
+ tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
+ # who created this tool
+ user_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
+ # encrypted credentials
+ encrypted_credentials: Mapped[str] = mapped_column(db.Text, nullable=True)
+ # authed
+ authed: Mapped[bool] = mapped_column(db.Boolean, nullable=False, default=False)
+ # tools
+ tools: Mapped[str] = mapped_column(db.Text, nullable=False, default="[]")
+ created_at: Mapped[datetime] = mapped_column(
+ db.DateTime, nullable=False, server_default=db.text("CURRENT_TIMESTAMP(0)")
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ db.DateTime, nullable=False, server_default=db.text("CURRENT_TIMESTAMP(0)")
+ )
+
+ def load_user(self) -> Account | None:
+ return db.session.query(Account).filter(Account.id == self.user_id).first()
+
+ @property
+ def tenant(self) -> Tenant | None:
+ return db.session.query(Tenant).filter(Tenant.id == self.tenant_id).first()
+
+ @property
+ def credentials(self) -> dict:
+ try:
+ return cast(dict, json.loads(self.encrypted_credentials)) or {}
+ except Exception:
+ return {}
+
+ @property
+ def mcp_tools(self) -> list[Tool]:
+ return [Tool(**tool) for tool in json.loads(self.tools)]
+
+ @property
+ def provider_icon(self) -> dict[str, str] | str:
+ try:
+ return cast(dict[str, str], json.loads(self.icon))
+ except json.JSONDecodeError:
+ return file_helpers.get_signed_file_url(self.icon)
+
+ @property
+ def decrypted_server_url(self) -> str:
+ return cast(str, encrypter.decrypt_token(self.tenant_id, self.server_url))
+
+ @property
+ def masked_server_url(self) -> str:
+ def mask_url(url: str, mask_char: str = "*") -> str:
+ """
+ mask the url to a simple string
+ """
+ parsed = urlparse(url)
+ base_url = f"{parsed.scheme}://{parsed.netloc}"
+
+ if parsed.path and parsed.path != "/":
+ return f"{base_url}/{mask_char * 6}"
+ else:
+ return base_url
+
+ return mask_url(self.decrypted_server_url)
+
+ @property
+ def decrypted_credentials(self) -> dict:
+ from core.tools.mcp_tool.provider import MCPToolProviderController
+ from core.tools.utils.configuration import ProviderConfigEncrypter
+
+ provider_controller = MCPToolProviderController._from_db(self)
+
+ tool_configuration = ProviderConfigEncrypter(
+ tenant_id=self.tenant_id,
+ config=list(provider_controller.get_credentials_schema()),
+ provider_type=provider_controller.provider_type.value,
+ provider_identity=provider_controller.provider_id,
+ )
+ return tool_configuration.decrypt(self.credentials, use_cache=False)
+
+
class ToolModelInvoke(Base):
"""
store the invoke logs from tool invoke
diff --git a/api/models/workflow.py b/api/models/workflow.py
index 2fff045543..77d48bec4f 100644
--- a/api/models/workflow.py
+++ b/api/models/workflow.py
@@ -7,10 +7,17 @@ from typing import TYPE_CHECKING, Any, Optional, Union
from uuid import uuid4
from flask_login import current_user
+from sqlalchemy import orm
+from core.file.constants import maybe_file_object
+from core.file.models import File
from core.variables import utils as variable_utils
from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
-from factories.variable_factory import build_segment
+from core.workflow.nodes.enums import NodeType
+from factories.variable_factory import TypeMismatchError, build_segment_with_type
+from libs.helper import extract_tenant_id
+
+from ._workflow_exc import NodeNotFoundError, WorkflowDataError
if TYPE_CHECKING:
from models.model import AppMode
@@ -72,6 +79,10 @@ class WorkflowType(Enum):
return cls.WORKFLOW if app_mode == AppMode.WORKFLOW else cls.CHAT
+class _InvalidGraphDefinitionError(Exception):
+ pass
+
+
class Workflow(Base):
"""
Workflow, for `Workflow App` and `Chat App workflow mode`.
@@ -136,6 +147,8 @@ class Workflow(Base):
"conversation_variables", db.Text, nullable=False, server_default="{}"
)
+ VERSION_DRAFT = "draft"
+
@classmethod
def new(
cls,
@@ -179,8 +192,72 @@ class Workflow(Base):
@property
def graph_dict(self) -> Mapping[str, Any]:
+ # TODO(QuantumGhost): Consider caching `graph_dict` to avoid repeated JSON decoding.
+ #
+ # Using `functools.cached_property` could help, but some code in the codebase may
+ # modify the returned dict, which can cause issues elsewhere.
+ #
+ # For example, changing this property to a cached property led to errors like the
+ # following when single stepping an `Iteration` node:
+ #
+ # Root node id 1748401971780start not found in the graph
+ #
+ # There is currently no standard way to make a dict deeply immutable in Python,
+ # and tracking modifications to the returned dict is difficult. For now, we leave
+ # the code as-is to avoid these issues.
+ #
+ # Currently, the following functions / methods would mutate the returned dict:
+ #
+ # - `_get_graph_and_variable_pool_of_single_iteration`.
+ # - `_get_graph_and_variable_pool_of_single_loop`.
return json.loads(self.graph) if self.graph else {}
+ def get_node_config_by_id(self, node_id: str) -> Mapping[str, Any]:
+ """Extract a node configuration from the workflow graph by node ID.
+ A node configuration is a dictionary containing the node's properties, including
+ the node's id, title, and its data as a dict.
+ """
+ workflow_graph = self.graph_dict
+
+ if not workflow_graph:
+ raise WorkflowDataError(f"workflow graph not found, workflow_id={self.id}")
+
+ nodes = workflow_graph.get("nodes")
+ if not nodes:
+ raise WorkflowDataError("nodes not found in workflow graph")
+
+ try:
+ node_config = next(filter(lambda node: node["id"] == node_id, nodes))
+ except StopIteration:
+ raise NodeNotFoundError(node_id)
+ assert isinstance(node_config, dict)
+ return node_config
+
+ @staticmethod
+ def get_node_type_from_node_config(node_config: Mapping[str, Any]) -> NodeType:
+ """Extract type of a node from the node configuration returned by `get_node_config_by_id`."""
+ node_config_data = node_config.get("data", {})
+ # Get node class
+ node_type = NodeType(node_config_data.get("type"))
+ return node_type
+
+ @staticmethod
+ def get_enclosing_node_type_and_id(node_config: Mapping[str, Any]) -> tuple[NodeType, str] | None:
+ in_loop = node_config.get("isInLoop", False)
+ in_iteration = node_config.get("isInIteration", False)
+ if in_loop:
+ loop_id = node_config.get("loop_id")
+ if loop_id is None:
+ raise _InvalidGraphDefinitionError("invalid graph")
+ return NodeType.LOOP, loop_id
+ elif in_iteration:
+ iteration_id = node_config.get("iteration_id")
+ if iteration_id is None:
+ raise _InvalidGraphDefinitionError("invalid graph")
+ return NodeType.ITERATION, iteration_id
+ else:
+ return None
+
@property
def features(self) -> str:
"""
@@ -276,12 +353,7 @@ class Workflow(Base):
self._environment_variables = "{}"
# Get tenant_id from current_user (Account or EndUser)
- if isinstance(current_user, Account):
- # Account user
- tenant_id = current_user.current_tenant_id
- else:
- # EndUser
- tenant_id = current_user.tenant_id
+ tenant_id = extract_tenant_id(current_user)
if not tenant_id:
return []
@@ -308,12 +380,7 @@ class Workflow(Base):
return
# Get tenant_id from current_user (Account or EndUser)
- if isinstance(current_user, Account):
- # Account user
- tenant_id = current_user.current_tenant_id
- else:
- # EndUser
- tenant_id = current_user.tenant_id
+ tenant_id = extract_tenant_id(current_user)
if not tenant_id:
self._environment_variables = "{}"
@@ -376,6 +443,10 @@ class Workflow(Base):
ensure_ascii=False,
)
+ @staticmethod
+ def version_from_datetime(d: datetime) -> str:
+ return str(d)
+
class WorkflowRun(Base):
"""
@@ -386,7 +457,7 @@ class WorkflowRun(Base):
- id (uuid) Run ID
- tenant_id (uuid) Workspace ID
- app_id (uuid) App ID
- - sequence_number (int) Auto-increment sequence number, incremented within the App, starting from 1
+
- workflow_id (uuid) Workflow ID
- type (string) Workflow type
- triggered_from (string) Trigger source
@@ -419,13 +490,12 @@ class WorkflowRun(Base):
__table_args__ = (
db.PrimaryKeyConstraint("id", name="workflow_run_pkey"),
db.Index("workflow_run_triggerd_from_idx", "tenant_id", "app_id", "triggered_from"),
- db.Index("workflow_run_tenant_app_sequence_idx", "tenant_id", "app_id", "sequence_number"),
)
id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()"))
tenant_id: Mapped[str] = mapped_column(StringUUID)
app_id: Mapped[str] = mapped_column(StringUUID)
- sequence_number: Mapped[int] = mapped_column()
+
workflow_id: Mapped[str] = mapped_column(StringUUID)
type: Mapped[str] = mapped_column(db.String(255))
triggered_from: Mapped[str] = mapped_column(db.String(255))
@@ -485,7 +555,6 @@ class WorkflowRun(Base):
"id": self.id,
"tenant_id": self.tenant_id,
"app_id": self.app_id,
- "sequence_number": self.sequence_number,
"workflow_id": self.workflow_id,
"type": self.type,
"triggered_from": self.triggered_from,
@@ -511,7 +580,6 @@ class WorkflowRun(Base):
id=data.get("id"),
tenant_id=data.get("tenant_id"),
app_id=data.get("app_id"),
- sequence_number=data.get("sequence_number"),
workflow_id=data.get("workflow_id"),
type=data.get("type"),
triggered_from=data.get("triggered_from"),
@@ -838,8 +906,18 @@ def _naive_utc_datetime():
class WorkflowDraftVariable(Base):
+ """`WorkflowDraftVariable` record variables and outputs generated during
+ debugging worfklow or chatflow.
+
+ IMPORTANT: This model maintains multiple invariant rules that must be preserved.
+ Do not instantiate this class directly with the constructor.
+
+ Instead, use the factory methods (`new_conversation_variable`, `new_sys_variable`,
+ `new_node_variable`) defined below to ensure all invariants are properly maintained.
+ """
+
@staticmethod
- def unique_columns() -> list[str]:
+ def unique_app_id_node_id_name() -> list[str]:
return [
"app_id",
"node_id",
@@ -847,7 +925,9 @@ class WorkflowDraftVariable(Base):
]
__tablename__ = "workflow_draft_variables"
- __table_args__ = (UniqueConstraint(*unique_columns()),)
+ __table_args__ = (UniqueConstraint(*unique_app_id_node_id_name()),)
+ # Required for instance variable annotation.
+ __allow_unmapped__ = True
# id is the unique identifier of a draft variable.
id: Mapped[str] = mapped_column(StringUUID, primary_key=True, server_default=db.text("uuid_generate_v4()"))
@@ -928,6 +1008,36 @@ class WorkflowDraftVariable(Base):
default=None,
)
+ # Cache for deserialized value
+ #
+ # NOTE(QuantumGhost): This field serves two purposes:
+ #
+ # 1. Caches deserialized values to reduce repeated parsing costs
+ # 2. Allows modification of the deserialized value after retrieval,
+ # particularly important for `File`` variables which require database
+ # lookups to obtain storage_key and other metadata
+ #
+ # Use double underscore prefix for better encapsulation,
+ # making this attribute harder to access from outside the class.
+ __value: Segment | None
+
+ def __init__(self, *args, **kwargs):
+ """
+ The constructor of `WorkflowDraftVariable` is not intended for
+ direct use outside this file. Its solo purpose is setup private state
+ used by the model instance.
+
+ Please use the factory methods
+ (`new_conversation_variable`, `new_sys_variable`, `new_node_variable`)
+ defined below to create instances of this class.
+ """
+ super().__init__(*args, **kwargs)
+ self.__value = None
+
+ @orm.reconstructor
+ def _init_on_load(self):
+ self.__value = None
+
def get_selector(self) -> list[str]:
selector = json.loads(self.selector)
if not isinstance(selector, list):
@@ -942,15 +1052,92 @@ class WorkflowDraftVariable(Base):
def _set_selector(self, value: list[str]):
self.selector = json.dumps(value)
- def get_value(self) -> Segment | None:
- return build_segment(json.loads(self.value))
+ def _loads_value(self) -> Segment:
+ value = json.loads(self.value)
+ return self.build_segment_with_type(self.value_type, value)
+
+ @staticmethod
+ def rebuild_file_types(value: Any) -> Any:
+ # NOTE(QuantumGhost): Temporary workaround for structured data handling.
+ # By this point, `output` has been converted to dict by
+ # `WorkflowEntry.handle_special_values`, so we need to
+ # reconstruct File objects from their serialized form
+ # to maintain proper variable saving behavior.
+ #
+ # Ideally, we should work with structured data objects directly
+ # rather than their serialized forms.
+ # However, multiple components in the codebase depend on
+ # `WorkflowEntry.handle_special_values`, making a comprehensive migration challenging.
+ if isinstance(value, dict):
+ if not maybe_file_object(value):
+ return value
+ return File.model_validate(value)
+ elif isinstance(value, list) and value:
+ first = value[0]
+ if not maybe_file_object(first):
+ return value
+ return [File.model_validate(i) for i in value]
+ else:
+ return value
+
+ @classmethod
+ def build_segment_with_type(cls, segment_type: SegmentType, value: Any) -> Segment:
+ # Extends `variable_factory.build_segment_with_type` functionality by
+ # reconstructing `FileSegment`` or `ArrayFileSegment`` objects from
+ # their serialized dictionary or list representations, respectively.
+ if segment_type == SegmentType.FILE:
+ if isinstance(value, File):
+ return build_segment_with_type(segment_type, value)
+ elif isinstance(value, dict):
+ file = cls.rebuild_file_types(value)
+ return build_segment_with_type(segment_type, file)
+ else:
+ raise TypeMismatchError(f"expected dict or File for FileSegment, got {type(value)}")
+ if segment_type == SegmentType.ARRAY_FILE:
+ if not isinstance(value, list):
+ raise TypeMismatchError(f"expected list for ArrayFileSegment, got {type(value)}")
+ file_list = cls.rebuild_file_types(value)
+ return build_segment_with_type(segment_type=segment_type, value=file_list)
+
+ return build_segment_with_type(segment_type=segment_type, value=value)
+
+ def get_value(self) -> Segment:
+ """Decode the serialized value into its corresponding `Segment` object.
+
+ This method caches the result, so repeated calls will return the same
+ object instance without re-parsing the serialized data.
+
+ If you need to modify the returned `Segment`, use `value.model_copy()`
+ to create a copy first to avoid affecting the cached instance.
+
+ For more information about the caching mechanism, see the documentation
+ of the `__value` field.
+
+ Returns:
+ Segment: The deserialized value as a Segment object.
+ """
+
+ if self.__value is not None:
+ return self.__value
+ value = self._loads_value()
+ self.__value = value
+ return value
def set_name(self, name: str):
self.name = name
self._set_selector([self.node_id, name])
def set_value(self, value: Segment):
- self.value = json.dumps(value.value)
+ """Updates the `value` and corresponding `value_type` fields in the database model.
+
+ This method also stores the provided Segment object in the deserialized cache
+ without creating a copy, allowing for efficient value access.
+
+ Args:
+ value: The Segment object to store as the variable's value.
+ """
+ self.__value = value
+ self.value = json.dumps(value, cls=variable_utils.SegmentJSONEncoder)
self.value_type = value.value_type
def get_node_id(self) -> str | None:
@@ -976,6 +1163,7 @@ class WorkflowDraftVariable(Base):
node_id: str,
name: str,
value: Segment,
+ node_execution_id: str | None,
description: str = "",
) -> "WorkflowDraftVariable":
variable = WorkflowDraftVariable()
@@ -987,6 +1175,7 @@ class WorkflowDraftVariable(Base):
variable.name = name
variable.set_value(value)
variable._set_selector(list(variable_utils.to_selector(node_id, name)))
+ variable.node_execution_id = node_execution_id
return variable
@classmethod
@@ -996,13 +1185,17 @@ class WorkflowDraftVariable(Base):
app_id: str,
name: str,
value: Segment,
+ description: str = "",
) -> "WorkflowDraftVariable":
variable = cls._new(
app_id=app_id,
node_id=CONVERSATION_VARIABLE_NODE_ID,
name=name,
value=value,
+ description=description,
+ node_execution_id=None,
)
+ variable.editable = True
return variable
@classmethod
@@ -1012,9 +1205,16 @@ class WorkflowDraftVariable(Base):
app_id: str,
name: str,
value: Segment,
+ node_execution_id: str,
editable: bool = False,
) -> "WorkflowDraftVariable":
- variable = cls._new(app_id=app_id, node_id=SYSTEM_VARIABLE_NODE_ID, name=name, value=value)
+ variable = cls._new(
+ app_id=app_id,
+ node_id=SYSTEM_VARIABLE_NODE_ID,
+ name=name,
+ node_execution_id=node_execution_id,
+ value=value,
+ )
variable.editable = editable
return variable
@@ -1026,11 +1226,19 @@ class WorkflowDraftVariable(Base):
node_id: str,
name: str,
value: Segment,
+ node_execution_id: str,
visible: bool = True,
+ editable: bool = True,
) -> "WorkflowDraftVariable":
- variable = cls._new(app_id=app_id, node_id=node_id, name=name, value=value)
+ variable = cls._new(
+ app_id=app_id,
+ node_id=node_id,
+ name=name,
+ node_execution_id=node_execution_id,
+ value=value,
+ )
variable.visible = visible
- variable.editable = True
+ variable.editable = editable
return variable
@property
diff --git a/api/mypy.ini b/api/mypy.ini
index 12fe529b08..6836b2602b 100644
--- a/api/mypy.ini
+++ b/api/mypy.ini
@@ -18,4 +18,3 @@ ignore_missing_imports=True
[mypy-flask_restful.inputs]
ignore_missing_imports=True
-
diff --git a/api/pyproject.toml b/api/pyproject.toml
index 9631586ed4..7f1efa671f 100644
--- a/api/pyproject.toml
+++ b/api/pyproject.toml
@@ -1,9 +1,10 @@
[project]
name = "dify-api"
-dynamic = ["version"]
+version = "1.6.0"
requires-python = ">=3.11,<3.13"
dependencies = [
+ "arize-phoenix-otel~=0.9.2",
"authlib==1.3.1",
"azure-identity==1.16.1",
"beautifulsoup4==4.12.2",
@@ -81,6 +82,9 @@ dependencies = [
"weave~=0.51.0",
"yarl~=1.18.3",
"webvtt-py~=0.5.1",
+ "sseclient-py>=1.8.0",
+ "httpx-sse>=0.4.0",
+ "sendgrid~=6.12.3",
]
# Before adding new dependency, consider place it in
# alphabet order (a-z) and suitable group.
@@ -104,7 +108,7 @@ dev = [
"faker~=32.1.0",
"lxml-stubs~=0.5.1",
"mypy~=1.16.0",
- "ruff~=0.11.5",
+ "ruff~=0.12.3",
"pytest~=8.3.2",
"pytest-benchmark~=4.0.0",
"pytest-cov~=4.1.0",
@@ -148,11 +152,13 @@ dev = [
"types-ujson>=5.10.0",
"boto3-stubs>=1.38.20",
"types-jmespath>=1.0.2.20240106",
+ "hypothesis>=6.131.15",
"types_pyOpenSSL>=24.1.0",
"types_cffi>=1.17.0",
"types_setuptools>=80.9.0",
"pandas-stubs~=2.2.3",
"scipy-stubs>=1.15.3.0",
+ "types-python-http-client>=3.3.7.20240910",
]
############################################################
@@ -195,11 +201,12 @@ vdb = [
"pymochow==1.3.1",
"pyobvector~=0.1.6",
"qdrant-client==1.9.0",
- "tablestore==6.1.0",
+ "tablestore==6.2.0",
"tcvectordb~=1.6.4",
"tidb-vector==0.0.9",
"upstash-vector==0.6.0",
"volcengine-compat~=1.0.0",
"weaviate-client~=3.24.0",
"xinference-client~=1.2.2",
+ "mo-vector~=0.1.13",
]
diff --git a/api/repositories/__init__.py b/api/repositories/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/repositories/api_workflow_node_execution_repository.py b/api/repositories/api_workflow_node_execution_repository.py
new file mode 100644
index 0000000000..00a2d1f87d
--- /dev/null
+++ b/api/repositories/api_workflow_node_execution_repository.py
@@ -0,0 +1,197 @@
+"""
+Service-layer repository protocol for WorkflowNodeExecutionModel operations.
+
+This module provides a protocol interface for service-layer operations on WorkflowNodeExecutionModel
+that abstracts database queries currently done directly in service classes. This repository is
+specifically designed for service-layer needs and is separate from the core domain repository.
+
+The service repository handles operations that require access to database-specific fields like
+tenant_id, app_id, triggered_from, etc., which are not part of the core domain model.
+"""
+
+from collections.abc import Sequence
+from datetime import datetime
+from typing import Optional, Protocol
+
+from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository
+from models.workflow import WorkflowNodeExecutionModel
+
+
+class DifyAPIWorkflowNodeExecutionRepository(WorkflowNodeExecutionRepository, Protocol):
+ """
+ Protocol for service-layer operations on WorkflowNodeExecutionModel.
+
+ This repository provides database access patterns specifically needed by service classes,
+ handling queries that involve database-specific fields and multi-tenancy concerns.
+
+ Key responsibilities:
+ - Manages database operations for workflow node executions
+ - Handles multi-tenant data isolation
+ - Provides batch processing capabilities
+ - Supports execution lifecycle management
+
+ Implementation notes:
+ - Returns database models directly (WorkflowNodeExecutionModel)
+ - Handles tenant/app filtering automatically
+ - Provides service-specific query patterns
+ - Focuses on database operations without domain logic
+ - Supports cleanup and maintenance operations
+ """
+
+ def get_node_last_execution(
+ self,
+ tenant_id: str,
+ app_id: str,
+ workflow_id: str,
+ node_id: str,
+ ) -> Optional[WorkflowNodeExecutionModel]:
+ """
+ Get the most recent execution for a specific node.
+
+ This method finds the latest execution of a specific node within a workflow,
+ ordered by creation time. Used primarily for debugging and inspection purposes.
+
+ Args:
+ tenant_id: The tenant identifier
+ app_id: The application identifier
+ workflow_id: The workflow identifier
+ node_id: The node identifier
+
+ Returns:
+ The most recent WorkflowNodeExecutionModel for the node, or None if not found
+ """
+ ...
+
+ def get_executions_by_workflow_run(
+ self,
+ tenant_id: str,
+ app_id: str,
+ workflow_run_id: str,
+ ) -> Sequence[WorkflowNodeExecutionModel]:
+ """
+ Get all node executions for a specific workflow run.
+
+ This method retrieves all node executions that belong to a specific workflow run,
+ ordered by index in descending order for proper trace visualization.
+
+ Args:
+ tenant_id: The tenant identifier
+ app_id: The application identifier
+ workflow_run_id: The workflow run identifier
+
+ Returns:
+ A sequence of WorkflowNodeExecutionModel instances ordered by index (desc)
+ """
+ ...
+
+ def get_execution_by_id(
+ self,
+ execution_id: str,
+ tenant_id: Optional[str] = None,
+ ) -> Optional[WorkflowNodeExecutionModel]:
+ """
+ Get a workflow node execution by its ID.
+
+ This method retrieves a specific execution by its unique identifier.
+ Tenant filtering is optional for cases where the execution ID is globally unique.
+
+ When `tenant_id` is None, it's the caller's responsibility to ensure proper data isolation between tenants.
+ If the `execution_id` comes from untrusted sources (e.g., retrieved from an API request), the caller should
+ set `tenant_id` to prevent horizontal privilege escalation.
+
+ Args:
+ execution_id: The execution identifier
+ tenant_id: Optional tenant identifier for additional filtering
+
+ Returns:
+ The WorkflowNodeExecutionModel if found, or None if not found
+ """
+ ...
+
+ def delete_expired_executions(
+ self,
+ tenant_id: str,
+ before_date: datetime,
+ batch_size: int = 1000,
+ ) -> int:
+ """
+ Delete workflow node executions that are older than the specified date.
+
+ This method is used for cleanup operations to remove expired executions
+ in batches to avoid overwhelming the database.
+
+ Args:
+ tenant_id: The tenant identifier
+ before_date: Delete executions created before this date
+ batch_size: Maximum number of executions to delete in one batch
+
+ Returns:
+ The number of executions deleted
+ """
+ ...
+
+ def delete_executions_by_app(
+ self,
+ tenant_id: str,
+ app_id: str,
+ batch_size: int = 1000,
+ ) -> int:
+ """
+ Delete all workflow node executions for a specific app.
+
+ This method is used when removing an app and all its related data.
+ Executions are deleted in batches to avoid overwhelming the database.
+
+ Args:
+ tenant_id: The tenant identifier
+ app_id: The application identifier
+ batch_size: Maximum number of executions to delete in one batch
+
+ Returns:
+ The total number of executions deleted
+ """
+ ...
+
+ def get_expired_executions_batch(
+ self,
+ tenant_id: str,
+ before_date: datetime,
+ batch_size: int = 1000,
+ ) -> Sequence[WorkflowNodeExecutionModel]:
+ """
+ Get a batch of expired workflow node executions for backup purposes.
+
+ This method retrieves expired executions without deleting them,
+ allowing the caller to backup the data before deletion.
+
+ Args:
+ tenant_id: The tenant identifier
+ before_date: Get executions created before this date
+ batch_size: Maximum number of executions to retrieve
+
+ Returns:
+ A sequence of WorkflowNodeExecutionModel instances
+ """
+ ...
+
+ def delete_executions_by_ids(
+ self,
+ execution_ids: Sequence[str],
+ ) -> int:
+ """
+ Delete workflow node executions by their IDs.
+
+ This method deletes specific executions by their IDs,
+ typically used after backing up the data.
+
+ This method does not perform tenant isolation checks. The caller is responsible for ensuring proper
+ data isolation between tenants. When execution IDs come from untrusted sources (e.g., API requests),
+ additional tenant validation should be implemented to prevent unauthorized access.
+
+ Args:
+ execution_ids: List of execution IDs to delete
+
+ Returns:
+ The number of executions deleted
+ """
+ ...
diff --git a/api/repositories/api_workflow_run_repository.py b/api/repositories/api_workflow_run_repository.py
new file mode 100644
index 0000000000..59e7baeb79
--- /dev/null
+++ b/api/repositories/api_workflow_run_repository.py
@@ -0,0 +1,181 @@
+"""
+API WorkflowRun Repository Protocol
+
+This module defines the protocol for service-layer WorkflowRun operations.
+The repository provides an abstraction layer for WorkflowRun database operations
+used by service classes, separating service-layer concerns from core domain logic.
+
+Key Features:
+- Paginated workflow run queries with filtering
+- Bulk deletion operations with OSS backup support
+- Multi-tenant data isolation
+- Expired record cleanup with data retention
+- Service-layer specific query patterns
+
+Usage:
+ This protocol should be used by service classes that need to perform
+ WorkflowRun database operations. It provides a clean interface that
+ hides implementation details and supports dependency injection.
+
+Example:
+ ```python
+ from repositories.dify_api_repository_factory import DifyAPIRepositoryFactory
+
+ session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
+ repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker)
+
+ # Get paginated workflow runs
+ runs = repo.get_paginated_workflow_runs(
+ tenant_id="tenant-123",
+ app_id="app-456",
+ triggered_from="debugging",
+ limit=20
+ )
+ ```
+"""
+
+from collections.abc import Sequence
+from datetime import datetime
+from typing import Optional, Protocol
+
+from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository
+from libs.infinite_scroll_pagination import InfiniteScrollPagination
+from models.workflow import WorkflowRun
+
+
+class APIWorkflowRunRepository(WorkflowExecutionRepository, Protocol):
+ """
+ Protocol for service-layer WorkflowRun repository operations.
+
+ This protocol defines the interface for WorkflowRun database operations
+ that are specific to service-layer needs, including pagination, filtering,
+ and bulk operations with data backup support.
+ """
+
+ def get_paginated_workflow_runs(
+ self,
+ tenant_id: str,
+ app_id: str,
+ triggered_from: str,
+ limit: int = 20,
+ last_id: Optional[str] = None,
+ ) -> InfiniteScrollPagination:
+ """
+ Get paginated workflow runs with filtering.
+
+ Retrieves workflow runs for a specific app and trigger source with
+ cursor-based pagination support. Used primarily for debugging and
+ workflow run listing in the UI.
+
+ Args:
+ tenant_id: Tenant identifier for multi-tenant isolation
+ app_id: Application identifier
+ triggered_from: Filter by trigger source (e.g., "debugging", "app-run")
+ limit: Maximum number of records to return (default: 20)
+ last_id: Cursor for pagination - ID of the last record from previous page
+
+ Returns:
+ InfiniteScrollPagination object containing:
+ - data: List of WorkflowRun objects
+ - limit: Applied limit
+ - has_more: Boolean indicating if more records exist
+
+ Raises:
+ ValueError: If last_id is provided but the corresponding record doesn't exist
+ """
+ ...
+
+ def get_workflow_run_by_id(
+ self,
+ tenant_id: str,
+ app_id: str,
+ run_id: str,
+ ) -> Optional[WorkflowRun]:
+ """
+ Get a specific workflow run by ID.
+
+ Retrieves a single workflow run with tenant and app isolation.
+ Used for workflow run detail views and execution tracking.
+
+ Args:
+ tenant_id: Tenant identifier for multi-tenant isolation
+ app_id: Application identifier
+ run_id: Workflow run identifier
+
+ Returns:
+ WorkflowRun object if found, None otherwise
+ """
+ ...
+
+ def get_expired_runs_batch(
+ self,
+ tenant_id: str,
+ before_date: datetime,
+ batch_size: int = 1000,
+ ) -> Sequence[WorkflowRun]:
+ """
+ Get a batch of expired workflow runs for cleanup.
+
+ Retrieves workflow runs created before the specified date for
+ cleanup operations. Used by scheduled tasks to remove old data
+ while maintaining data retention policies.
+
+ Args:
+ tenant_id: Tenant identifier for multi-tenant isolation
+ before_date: Only return runs created before this date
+ batch_size: Maximum number of records to return
+
+ Returns:
+ Sequence of WorkflowRun objects to be processed for cleanup
+ """
+ ...
+
+ def delete_runs_by_ids(
+ self,
+ run_ids: Sequence[str],
+ ) -> int:
+ """
+ Delete workflow runs by their IDs.
+
+ Performs bulk deletion of workflow runs by ID. This method should
+ be used after backing up the data to OSS storage for retention.
+
+ Args:
+ run_ids: Sequence of workflow run IDs to delete
+
+ Returns:
+ Number of records actually deleted
+
+ Note:
+ This method performs hard deletion. Ensure data is backed up
+ to OSS storage before calling this method for compliance with
+ data retention policies.
+ """
+ ...
+
+ def delete_runs_by_app(
+ self,
+ tenant_id: str,
+ app_id: str,
+ batch_size: int = 1000,
+ ) -> int:
+ """
+ Delete all workflow runs for a specific app.
+
+ Performs bulk deletion of all workflow runs associated with an app.
+ Used during app cleanup operations. Processes records in batches
+ to avoid memory issues and long-running transactions.
+
+ Args:
+ tenant_id: Tenant identifier for multi-tenant isolation
+ app_id: Application identifier
+ batch_size: Number of records to process in each batch
+
+ Returns:
+ Total number of records deleted across all batches
+
+ Note:
+ This method performs hard deletion without backup. Use with caution
+ and ensure proper data retention policies are followed.
+ """
+ ...
diff --git a/api/repositories/factory.py b/api/repositories/factory.py
new file mode 100644
index 0000000000..0a0adbf2c2
--- /dev/null
+++ b/api/repositories/factory.py
@@ -0,0 +1,103 @@
+"""
+DifyAPI Repository Factory for creating repository instances.
+
+This factory is specifically designed for DifyAPI repositories that handle
+service-layer operations with dependency injection patterns.
+"""
+
+import logging
+
+from sqlalchemy.orm import sessionmaker
+
+from configs import dify_config
+from core.repositories import DifyCoreRepositoryFactory, RepositoryImportError
+from repositories.api_workflow_node_execution_repository import DifyAPIWorkflowNodeExecutionRepository
+from repositories.api_workflow_run_repository import APIWorkflowRunRepository
+
+logger = logging.getLogger(__name__)
+
+
+class DifyAPIRepositoryFactory(DifyCoreRepositoryFactory):
+ """
+ Factory for creating DifyAPI repository instances based on configuration.
+
+ This factory handles the creation of repositories that are specifically designed
+ for service-layer operations and use dependency injection with sessionmaker
+ for better testability and separation of concerns.
+ """
+
+ @classmethod
+ def create_api_workflow_node_execution_repository(
+ cls, session_maker: sessionmaker
+ ) -> DifyAPIWorkflowNodeExecutionRepository:
+ """
+ Create a DifyAPIWorkflowNodeExecutionRepository instance based on configuration.
+
+ This repository is designed for service-layer operations and uses dependency injection
+ with a sessionmaker for better testability and separation of concerns. It provides
+ database access patterns specifically needed by service classes, handling queries
+ that involve database-specific fields and multi-tenancy concerns.
+
+ Args:
+ session_maker: SQLAlchemy sessionmaker to inject for database session management.
+
+ Returns:
+ Configured DifyAPIWorkflowNodeExecutionRepository instance
+
+ Raises:
+ RepositoryImportError: If the configured repository cannot be imported or instantiated
+ """
+ class_path = dify_config.API_WORKFLOW_NODE_EXECUTION_REPOSITORY
+ logger.debug(f"Creating DifyAPIWorkflowNodeExecutionRepository from: {class_path}")
+
+ try:
+ repository_class = cls._import_class(class_path)
+ cls._validate_repository_interface(repository_class, DifyAPIWorkflowNodeExecutionRepository)
+ # Service repository requires session_maker parameter
+ cls._validate_constructor_signature(repository_class, ["session_maker"])
+
+ return repository_class(session_maker=session_maker) # type: ignore[no-any-return]
+ except RepositoryImportError:
+ # Re-raise our custom errors as-is
+ raise
+ except Exception as e:
+ logger.exception("Failed to create DifyAPIWorkflowNodeExecutionRepository")
+ raise RepositoryImportError(
+ f"Failed to create DifyAPIWorkflowNodeExecutionRepository from '{class_path}': {e}"
+ ) from e
+
+ @classmethod
+ def create_api_workflow_run_repository(cls, session_maker: sessionmaker) -> APIWorkflowRunRepository:
+ """
+ Create an APIWorkflowRunRepository instance based on configuration.
+
+ This repository is designed for service-layer WorkflowRun operations and uses dependency
+ injection with a sessionmaker for better testability and separation of concerns. It provides
+ database access patterns specifically needed by service classes for workflow run management,
+ including pagination, filtering, and bulk operations.
+
+ Args:
+ session_maker: SQLAlchemy sessionmaker to inject for database session management.
+
+ Returns:
+ Configured APIWorkflowRunRepository instance
+
+ Raises:
+ RepositoryImportError: If the configured repository cannot be imported or instantiated
+ """
+ class_path = dify_config.API_WORKFLOW_RUN_REPOSITORY
+ logger.debug(f"Creating APIWorkflowRunRepository from: {class_path}")
+
+ try:
+ repository_class = cls._import_class(class_path)
+ cls._validate_repository_interface(repository_class, APIWorkflowRunRepository)
+ # Service repository requires session_maker parameter
+ cls._validate_constructor_signature(repository_class, ["session_maker"])
+
+ return repository_class(session_maker=session_maker) # type: ignore[no-any-return]
+ except RepositoryImportError:
+ # Re-raise our custom errors as-is
+ raise
+ except Exception as e:
+ logger.exception("Failed to create APIWorkflowRunRepository")
+ raise RepositoryImportError(f"Failed to create APIWorkflowRunRepository from '{class_path}': {e}") from e
diff --git a/api/repositories/sqlalchemy_api_workflow_node_execution_repository.py b/api/repositories/sqlalchemy_api_workflow_node_execution_repository.py
new file mode 100644
index 0000000000..e6a23ddf9f
--- /dev/null
+++ b/api/repositories/sqlalchemy_api_workflow_node_execution_repository.py
@@ -0,0 +1,290 @@
+"""
+SQLAlchemy implementation of WorkflowNodeExecutionServiceRepository.
+
+This module provides a concrete implementation of the service repository protocol
+using SQLAlchemy 2.0 style queries for WorkflowNodeExecutionModel operations.
+"""
+
+from collections.abc import Sequence
+from datetime import datetime
+from typing import Optional
+
+from sqlalchemy import delete, desc, select
+from sqlalchemy.orm import Session, sessionmaker
+
+from models.workflow import WorkflowNodeExecutionModel
+from repositories.api_workflow_node_execution_repository import DifyAPIWorkflowNodeExecutionRepository
+
+
+class DifyAPISQLAlchemyWorkflowNodeExecutionRepository(DifyAPIWorkflowNodeExecutionRepository):
+ """
+ SQLAlchemy implementation of DifyAPIWorkflowNodeExecutionRepository.
+
+ This repository provides service-layer database operations for WorkflowNodeExecutionModel
+ using SQLAlchemy 2.0 style queries. It implements the DifyAPIWorkflowNodeExecutionRepository
+ protocol with the following features:
+
+ - Multi-tenancy data isolation through tenant_id filtering
+ - Direct database model operations without domain conversion
+ - Batch processing for efficient large-scale operations
+ - Optimized query patterns for common access patterns
+ - Dependency injection for better testability and maintainability
+ - Session management and transaction handling with proper cleanup
+ - Maintenance operations for data lifecycle management
+ - Thread-safe database operations using session-per-request pattern
+ """
+
+ def __init__(self, session_maker: sessionmaker[Session]):
+ """
+ Initialize the repository with a sessionmaker.
+
+ Args:
+ session_maker: SQLAlchemy sessionmaker for creating database sessions
+ """
+ self._session_maker = session_maker
+
+ def get_node_last_execution(
+ self,
+ tenant_id: str,
+ app_id: str,
+ workflow_id: str,
+ node_id: str,
+ ) -> Optional[WorkflowNodeExecutionModel]:
+ """
+ Get the most recent execution for a specific node.
+
+ This method replicates the query pattern from WorkflowService.get_node_last_run()
+ using SQLAlchemy 2.0 style syntax.
+
+ Args:
+ tenant_id: The tenant identifier
+ app_id: The application identifier
+ workflow_id: The workflow identifier
+ node_id: The node identifier
+
+ Returns:
+ The most recent WorkflowNodeExecutionModel for the node, or None if not found
+ """
+ stmt = (
+ select(WorkflowNodeExecutionModel)
+ .where(
+ WorkflowNodeExecutionModel.tenant_id == tenant_id,
+ WorkflowNodeExecutionModel.app_id == app_id,
+ WorkflowNodeExecutionModel.workflow_id == workflow_id,
+ WorkflowNodeExecutionModel.node_id == node_id,
+ )
+ .order_by(desc(WorkflowNodeExecutionModel.created_at))
+ .limit(1)
+ )
+
+ with self._session_maker() as session:
+ return session.scalar(stmt)
+
+ def get_executions_by_workflow_run(
+ self,
+ tenant_id: str,
+ app_id: str,
+ workflow_run_id: str,
+ ) -> Sequence[WorkflowNodeExecutionModel]:
+ """
+ Get all node executions for a specific workflow run.
+
+ This method replicates the query pattern from WorkflowRunService.get_workflow_run_node_executions()
+ using SQLAlchemy 2.0 style syntax.
+
+ Args:
+ tenant_id: The tenant identifier
+ app_id: The application identifier
+ workflow_run_id: The workflow run identifier
+
+ Returns:
+ A sequence of WorkflowNodeExecutionModel instances ordered by index (desc)
+ """
+ stmt = (
+ select(WorkflowNodeExecutionModel)
+ .where(
+ WorkflowNodeExecutionModel.tenant_id == tenant_id,
+ WorkflowNodeExecutionModel.app_id == app_id,
+ WorkflowNodeExecutionModel.workflow_run_id == workflow_run_id,
+ )
+ .order_by(desc(WorkflowNodeExecutionModel.index))
+ )
+
+ with self._session_maker() as session:
+ return session.execute(stmt).scalars().all()
+
+ def get_execution_by_id(
+ self,
+ execution_id: str,
+ tenant_id: Optional[str] = None,
+ ) -> Optional[WorkflowNodeExecutionModel]:
+ """
+ Get a workflow node execution by its ID.
+
+ This method replicates the query pattern from WorkflowDraftVariableService
+ and WorkflowService.single_step_run_workflow_node() using SQLAlchemy 2.0 style syntax.
+
+ When `tenant_id` is None, it's the caller's responsibility to ensure proper data isolation between tenants.
+ If the `execution_id` comes from untrusted sources (e.g., retrieved from an API request), the caller should
+ set `tenant_id` to prevent horizontal privilege escalation.
+
+ Args:
+ execution_id: The execution identifier
+ tenant_id: Optional tenant identifier for additional filtering
+
+ Returns:
+ The WorkflowNodeExecutionModel if found, or None if not found
+ """
+ stmt = select(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.id == execution_id)
+
+ # Add tenant filtering if provided
+ if tenant_id is not None:
+ stmt = stmt.where(WorkflowNodeExecutionModel.tenant_id == tenant_id)
+
+ with self._session_maker() as session:
+ return session.scalar(stmt)
+
+ def delete_expired_executions(
+ self,
+ tenant_id: str,
+ before_date: datetime,
+ batch_size: int = 1000,
+ ) -> int:
+ """
+ Delete workflow node executions that are older than the specified date.
+
+ Args:
+ tenant_id: The tenant identifier
+ before_date: Delete executions created before this date
+ batch_size: Maximum number of executions to delete in one batch
+
+ Returns:
+ The number of executions deleted
+ """
+ total_deleted = 0
+
+ while True:
+ with self._session_maker() as session:
+ # Find executions to delete in batches
+ stmt = (
+ select(WorkflowNodeExecutionModel.id)
+ .where(
+ WorkflowNodeExecutionModel.tenant_id == tenant_id,
+ WorkflowNodeExecutionModel.created_at < before_date,
+ )
+ .limit(batch_size)
+ )
+
+ execution_ids = session.execute(stmt).scalars().all()
+ if not execution_ids:
+ break
+
+ # Delete the batch
+ delete_stmt = delete(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.id.in_(execution_ids))
+ result = session.execute(delete_stmt)
+ session.commit()
+ total_deleted += result.rowcount
+
+ # If we deleted fewer than the batch size, we're done
+ if len(execution_ids) < batch_size:
+ break
+
+ return total_deleted
+
+ def delete_executions_by_app(
+ self,
+ tenant_id: str,
+ app_id: str,
+ batch_size: int = 1000,
+ ) -> int:
+ """
+ Delete all workflow node executions for a specific app.
+
+ Args:
+ tenant_id: The tenant identifier
+ app_id: The application identifier
+ batch_size: Maximum number of executions to delete in one batch
+
+ Returns:
+ The total number of executions deleted
+ """
+ total_deleted = 0
+
+ while True:
+ with self._session_maker() as session:
+ # Find executions to delete in batches
+ stmt = (
+ select(WorkflowNodeExecutionModel.id)
+ .where(
+ WorkflowNodeExecutionModel.tenant_id == tenant_id,
+ WorkflowNodeExecutionModel.app_id == app_id,
+ )
+ .limit(batch_size)
+ )
+
+ execution_ids = session.execute(stmt).scalars().all()
+ if not execution_ids:
+ break
+
+ # Delete the batch
+ delete_stmt = delete(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.id.in_(execution_ids))
+ result = session.execute(delete_stmt)
+ session.commit()
+ total_deleted += result.rowcount
+
+ # If we deleted fewer than the batch size, we're done
+ if len(execution_ids) < batch_size:
+ break
+
+ return total_deleted
+
+ def get_expired_executions_batch(
+ self,
+ tenant_id: str,
+ before_date: datetime,
+ batch_size: int = 1000,
+ ) -> Sequence[WorkflowNodeExecutionModel]:
+ """
+ Get a batch of expired workflow node executions for backup purposes.
+
+ Args:
+ tenant_id: The tenant identifier
+ before_date: Get executions created before this date
+ batch_size: Maximum number of executions to retrieve
+
+ Returns:
+ A sequence of WorkflowNodeExecutionModel instances
+ """
+ stmt = (
+ select(WorkflowNodeExecutionModel)
+ .where(
+ WorkflowNodeExecutionModel.tenant_id == tenant_id,
+ WorkflowNodeExecutionModel.created_at < before_date,
+ )
+ .limit(batch_size)
+ )
+
+ with self._session_maker() as session:
+ return session.execute(stmt).scalars().all()
+
+ def delete_executions_by_ids(
+ self,
+ execution_ids: Sequence[str],
+ ) -> int:
+ """
+ Delete workflow node executions by their IDs.
+
+ Args:
+ execution_ids: List of execution IDs to delete
+
+ Returns:
+ The number of executions deleted
+ """
+ if not execution_ids:
+ return 0
+
+ with self._session_maker() as session:
+ stmt = delete(WorkflowNodeExecutionModel).where(WorkflowNodeExecutionModel.id.in_(execution_ids))
+ result = session.execute(stmt)
+ session.commit()
+ return result.rowcount
diff --git a/api/repositories/sqlalchemy_api_workflow_run_repository.py b/api/repositories/sqlalchemy_api_workflow_run_repository.py
new file mode 100644
index 0000000000..ebd1d74b20
--- /dev/null
+++ b/api/repositories/sqlalchemy_api_workflow_run_repository.py
@@ -0,0 +1,203 @@
+"""
+SQLAlchemy API WorkflowRun Repository Implementation
+
+This module provides the SQLAlchemy-based implementation of the APIWorkflowRunRepository
+protocol. It handles service-layer WorkflowRun database operations using SQLAlchemy 2.0
+style queries with proper session management and multi-tenant data isolation.
+
+Key Features:
+- SQLAlchemy 2.0 style queries for modern database operations
+- Cursor-based pagination for efficient large dataset handling
+- Bulk operations with batch processing for performance
+- Multi-tenant data isolation and security
+- Proper session management with dependency injection
+
+Implementation Notes:
+- Uses sessionmaker for consistent session management
+- Implements cursor-based pagination using created_at timestamps
+- Provides efficient bulk deletion with batch processing
+- Maintains data consistency with proper transaction handling
+"""
+
+import logging
+from collections.abc import Sequence
+from datetime import datetime
+from typing import Optional, cast
+
+from sqlalchemy import delete, select
+from sqlalchemy.orm import Session, sessionmaker
+
+from libs.infinite_scroll_pagination import InfiniteScrollPagination
+from models.workflow import WorkflowRun
+from repositories.api_workflow_run_repository import APIWorkflowRunRepository
+
+logger = logging.getLogger(__name__)
+
+
+class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository):
+ """
+ SQLAlchemy implementation of APIWorkflowRunRepository.
+
+ Provides service-layer WorkflowRun database operations using SQLAlchemy 2.0
+ style queries. Supports dependency injection through sessionmaker and
+ maintains proper multi-tenant data isolation.
+
+ Args:
+ session_maker: SQLAlchemy sessionmaker instance for database connections
+ """
+
+ def __init__(self, session_maker: sessionmaker[Session]) -> None:
+ """
+ Initialize the repository with a sessionmaker.
+
+ Args:
+ session_maker: SQLAlchemy sessionmaker for database connections
+ """
+ self._session_maker = session_maker
+
+ def get_paginated_workflow_runs(
+ self,
+ tenant_id: str,
+ app_id: str,
+ triggered_from: str,
+ limit: int = 20,
+ last_id: Optional[str] = None,
+ ) -> InfiniteScrollPagination:
+ """
+ Get paginated workflow runs with filtering.
+
+ Implements cursor-based pagination using created_at timestamps for
+ efficient handling of large datasets. Filters by tenant, app, and
+ trigger source for proper data isolation.
+ """
+ with self._session_maker() as session:
+ # Build base query with filters
+ base_stmt = select(WorkflowRun).where(
+ WorkflowRun.tenant_id == tenant_id,
+ WorkflowRun.app_id == app_id,
+ WorkflowRun.triggered_from == triggered_from,
+ )
+
+ if last_id:
+ # Get the last workflow run for cursor-based pagination
+ last_run_stmt = base_stmt.where(WorkflowRun.id == last_id)
+ last_workflow_run = session.scalar(last_run_stmt)
+
+ if not last_workflow_run:
+ raise ValueError("Last workflow run not exists")
+
+ # Get records created before the last run's timestamp
+ base_stmt = base_stmt.where(
+ WorkflowRun.created_at < last_workflow_run.created_at,
+ WorkflowRun.id != last_workflow_run.id,
+ )
+
+ # First page - get most recent records
+ workflow_runs = session.scalars(base_stmt.order_by(WorkflowRun.created_at.desc()).limit(limit + 1)).all()
+
+ # Check if there are more records for pagination
+ has_more = len(workflow_runs) > limit
+ if has_more:
+ workflow_runs = workflow_runs[:-1]
+
+ return InfiniteScrollPagination(data=workflow_runs, limit=limit, has_more=has_more)
+
+ def get_workflow_run_by_id(
+ self,
+ tenant_id: str,
+ app_id: str,
+ run_id: str,
+ ) -> Optional[WorkflowRun]:
+ """
+ Get a specific workflow run by ID with tenant and app isolation.
+ """
+ with self._session_maker() as session:
+ stmt = select(WorkflowRun).where(
+ WorkflowRun.tenant_id == tenant_id,
+ WorkflowRun.app_id == app_id,
+ WorkflowRun.id == run_id,
+ )
+ return cast(Optional[WorkflowRun], session.scalar(stmt))
+
+ def get_expired_runs_batch(
+ self,
+ tenant_id: str,
+ before_date: datetime,
+ batch_size: int = 1000,
+ ) -> Sequence[WorkflowRun]:
+ """
+ Get a batch of expired workflow runs for cleanup operations.
+ """
+ with self._session_maker() as session:
+ stmt = (
+ select(WorkflowRun)
+ .where(
+ WorkflowRun.tenant_id == tenant_id,
+ WorkflowRun.created_at < before_date,
+ )
+ .limit(batch_size)
+ )
+ return cast(Sequence[WorkflowRun], session.scalars(stmt).all())
+
+ def delete_runs_by_ids(
+ self,
+ run_ids: Sequence[str],
+ ) -> int:
+ """
+ Delete workflow runs by their IDs using bulk deletion.
+ """
+ if not run_ids:
+ return 0
+
+ with self._session_maker() as session:
+ stmt = delete(WorkflowRun).where(WorkflowRun.id.in_(run_ids))
+ result = session.execute(stmt)
+ session.commit()
+
+ deleted_count = cast(int, result.rowcount)
+ logger.info(f"Deleted {deleted_count} workflow runs by IDs")
+ return deleted_count
+
+ def delete_runs_by_app(
+ self,
+ tenant_id: str,
+ app_id: str,
+ batch_size: int = 1000,
+ ) -> int:
+ """
+ Delete all workflow runs for a specific app in batches.
+ """
+ total_deleted = 0
+
+ while True:
+ with self._session_maker() as session:
+ # Get a batch of run IDs to delete
+ stmt = (
+ select(WorkflowRun.id)
+ .where(
+ WorkflowRun.tenant_id == tenant_id,
+ WorkflowRun.app_id == app_id,
+ )
+ .limit(batch_size)
+ )
+ run_ids = session.scalars(stmt).all()
+
+ if not run_ids:
+ break
+
+ # Delete the batch
+ delete_stmt = delete(WorkflowRun).where(WorkflowRun.id.in_(run_ids))
+ result = session.execute(delete_stmt)
+ session.commit()
+
+ batch_deleted = result.rowcount
+ total_deleted += batch_deleted
+
+ logger.info(f"Deleted batch of {batch_deleted} workflow runs for app {app_id}")
+
+ # If we deleted fewer records than the batch size, we're done
+ if batch_deleted < batch_size:
+ break
+
+ logger.info(f"Total deleted {total_deleted} workflow runs for app {app_id}")
+ return total_deleted
diff --git a/api/services/account_service.py b/api/services/account_service.py
index 14d238467d..2ba6f4345b 100644
--- a/api/services/account_service.py
+++ b/api/services/account_service.py
@@ -16,7 +16,7 @@ from configs import dify_config
from constants.languages import language_timezone_mapping, languages
from events.tenant_event import tenant_was_created
from extensions.ext_database import db
-from extensions.ext_redis import redis_client
+from extensions.ext_redis import redis_client, redis_fallback
from libs.helper import RateLimiter, TokenManager
from libs.passport import PassportService
from libs.password import compare_password, hash_password, valid_password
@@ -495,6 +495,7 @@ class AccountService:
return account
@staticmethod
+ @redis_fallback(default_return=None)
def add_login_error_rate_limit(email: str) -> None:
key = f"login_error_rate_limit:{email}"
count = redis_client.get(key)
@@ -504,6 +505,7 @@ class AccountService:
redis_client.setex(key, dify_config.LOGIN_LOCKOUT_DURATION, count)
@staticmethod
+ @redis_fallback(default_return=False)
def is_login_error_rate_limit(email: str) -> bool:
key = f"login_error_rate_limit:{email}"
count = redis_client.get(key)
@@ -516,11 +518,13 @@ class AccountService:
return False
@staticmethod
+ @redis_fallback(default_return=None)
def reset_login_error_rate_limit(email: str):
key = f"login_error_rate_limit:{email}"
redis_client.delete(key)
@staticmethod
+ @redis_fallback(default_return=None)
def add_forgot_password_error_rate_limit(email: str) -> None:
key = f"forgot_password_error_rate_limit:{email}"
count = redis_client.get(key)
@@ -530,6 +534,7 @@ class AccountService:
redis_client.setex(key, dify_config.FORGOT_PASSWORD_LOCKOUT_DURATION, count)
@staticmethod
+ @redis_fallback(default_return=False)
def is_forgot_password_error_rate_limit(email: str) -> bool:
key = f"forgot_password_error_rate_limit:{email}"
count = redis_client.get(key)
@@ -542,11 +547,13 @@ class AccountService:
return False
@staticmethod
+ @redis_fallback(default_return=None)
def reset_forgot_password_error_rate_limit(email: str):
key = f"forgot_password_error_rate_limit:{email}"
redis_client.delete(key)
@staticmethod
+ @redis_fallback(default_return=False)
def is_email_send_ip_limit(ip_address: str):
minute_key = f"email_send_ip_limit_minute:{ip_address}"
freeze_key = f"email_send_ip_limit_freeze:{ip_address}"
@@ -889,7 +896,7 @@ class RegisterService:
TenantService.create_owner_tenant_if_not_exist(account=account, is_setup=True)
- dify_setup = DifySetup(version=dify_config.CURRENT_VERSION)
+ dify_setup = DifySetup(version=dify_config.project.version)
db.session.add(dify_setup)
db.session.commit()
except Exception as e:
diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py
index d2875180d8..20257fa345 100644
--- a/api/services/app_dsl_service.py
+++ b/api/services/app_dsl_service.py
@@ -32,6 +32,7 @@ from models import Account, App, AppMode
from models.model import AppModelConfig
from models.workflow import Workflow
from services.plugin.dependencies_analysis import DependenciesAnalysisService
+from services.workflow_draft_variable_service import WorkflowDraftVariableService
from services.workflow_service import WorkflowService
logger = logging.getLogger(__name__)
@@ -292,6 +293,8 @@ class AppDslService:
dependencies=check_dependencies_pending_data,
)
+ draft_var_srv = WorkflowDraftVariableService(session=self._session)
+ draft_var_srv.delete_workflow_variables(app_id=app.id)
return Import(
id=import_id,
status=status,
@@ -421,7 +424,7 @@ class AppDslService:
# Set icon type
icon_type_value = icon_type or app_data.get("icon_type")
- if icon_type_value in ["emoji", "link"]:
+ if icon_type_value in ["emoji", "link", "image"]:
icon_type = icon_type_value
else:
icon_type = "emoji"
diff --git a/api/services/app_service.py b/api/services/app_service.py
index d08462d001..db0f8cd414 100644
--- a/api/services/app_service.py
+++ b/api/services/app_service.py
@@ -47,8 +47,6 @@ class AppService:
filters.append(App.mode == AppMode.ADVANCED_CHAT.value)
elif args["mode"] == "agent-chat":
filters.append(App.mode == AppMode.AGENT_CHAT.value)
- elif args["mode"] == "channel":
- filters.append(App.mode == AppMode.CHANNEL.value)
if args.get("is_created_by_me", False):
filters.append(App.created_by == user_id)
diff --git a/api/services/audio_service.py b/api/services/audio_service.py
index a259f5a4c4..e8923eb51b 100644
--- a/api/services/audio_service.py
+++ b/api/services/audio_service.py
@@ -1,13 +1,17 @@
import io
import logging
import uuid
+from collections.abc import Generator
from typing import Optional
+from flask import Response, stream_with_context
from werkzeug.datastructures import FileStorage
from constants import AUDIO_EXTENSIONS
from core.model_manager import ModelManager
from core.model_runtime.entities.model_entities import ModelType
+from extensions.ext_database import db
+from models.enums import MessageStatus
from models.model import App, AppMode, AppModelConfig, Message
from services.errors.audio import (
AudioTooLargeServiceError,
@@ -16,6 +20,7 @@ from services.errors.audio import (
ProviderNotSupportTextToSpeechServiceError,
UnsupportedAudioTypeServiceError,
)
+from services.workflow_service import WorkflowService
FILE_SIZE = 30
FILE_SIZE_LIMIT = FILE_SIZE * 1024 * 1024
@@ -74,35 +79,36 @@ class AudioService:
voice: Optional[str] = None,
end_user: Optional[str] = None,
message_id: Optional[str] = None,
+ is_draft: bool = False,
):
- from collections.abc import Generator
-
- from flask import Response, stream_with_context
-
from app import app
- from extensions.ext_database import db
- def invoke_tts(text_content: str, app_model: App, voice: Optional[str] = None):
+ def invoke_tts(text_content: str, app_model: App, voice: Optional[str] = None, is_draft: bool = False):
with app.app_context():
- if app_model.mode in {AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value}:
- workflow = app_model.workflow
- if workflow is None:
- raise ValueError("TTS is not enabled")
-
- features_dict = workflow.features_dict
- if "text_to_speech" not in features_dict or not features_dict["text_to_speech"].get("enabled"):
- raise ValueError("TTS is not enabled")
-
- voice = features_dict["text_to_speech"].get("voice") if voice is None else voice
- else:
- if app_model.app_model_config is None:
- raise ValueError("AppModelConfig not found")
- text_to_speech_dict = app_model.app_model_config.text_to_speech_dict
-
- if not text_to_speech_dict.get("enabled"):
- raise ValueError("TTS is not enabled")
-
- voice = text_to_speech_dict.get("voice") if voice is None else voice
+ if voice is None:
+ if app_model.mode in {AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value}:
+ if is_draft:
+ workflow = WorkflowService().get_draft_workflow(app_model=app_model)
+ else:
+ workflow = app_model.workflow
+ if (
+ workflow is None
+ or "text_to_speech" not in workflow.features_dict
+ or not workflow.features_dict["text_to_speech"].get("enabled")
+ ):
+ raise ValueError("TTS is not enabled")
+
+ voice = workflow.features_dict["text_to_speech"].get("voice")
+ else:
+ if not is_draft:
+ if app_model.app_model_config is None:
+ raise ValueError("AppModelConfig not found")
+ text_to_speech_dict = app_model.app_model_config.text_to_speech_dict
+
+ if not text_to_speech_dict.get("enabled"):
+ raise ValueError("TTS is not enabled")
+
+ voice = text_to_speech_dict.get("voice")
model_manager = ModelManager()
model_instance = model_manager.get_default_model_instance(
@@ -132,18 +138,18 @@ class AudioService:
message = db.session.query(Message).filter(Message.id == message_id).first()
if message is None:
return None
- if message.answer == "" and message.status == "normal":
+ if message.answer == "" and message.status == MessageStatus.NORMAL:
return None
else:
- response = invoke_tts(message.answer, app_model=app_model, voice=voice)
+ response = invoke_tts(text_content=message.answer, app_model=app_model, voice=voice, is_draft=is_draft)
if isinstance(response, Generator):
return Response(stream_with_context(response), content_type="audio/mpeg")
return response
else:
if text is None:
raise ValueError("Text is required")
- response = invoke_tts(text, app_model, voice)
+ response = invoke_tts(text_content=text, app_model=app_model, voice=voice, is_draft=is_draft)
if isinstance(response, Generator):
return Response(stream_with_context(response), content_type="audio/mpeg")
return response
diff --git a/api/services/clear_free_plan_tenant_expired_logs.py b/api/services/clear_free_plan_tenant_expired_logs.py
index 1fd560d581..ddd16b2e0c 100644
--- a/api/services/clear_free_plan_tenant_expired_logs.py
+++ b/api/services/clear_free_plan_tenant_expired_logs.py
@@ -6,7 +6,7 @@ from concurrent.futures import ThreadPoolExecutor
import click
from flask import Flask, current_app
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from core.model_runtime.utils.encoders import jsonable_encoder
@@ -14,7 +14,7 @@ from extensions.ext_database import db
from extensions.ext_storage import storage
from models.account import Tenant
from models.model import App, Conversation, Message
-from models.workflow import WorkflowNodeExecutionModel, WorkflowRun
+from repositories.factory import DifyAPIRepositoryFactory
from services.billing_service import BillingService
logger = logging.getLogger(__name__)
@@ -105,84 +105,99 @@ class ClearFreePlanTenantExpiredLogs:
)
)
- while True:
- with Session(db.engine).no_autoflush as session:
- workflow_node_executions = (
- session.query(WorkflowNodeExecutionModel)
- .filter(
- WorkflowNodeExecutionModel.tenant_id == tenant_id,
- WorkflowNodeExecutionModel.created_at
- < datetime.datetime.now() - datetime.timedelta(days=days),
- )
- .limit(batch)
- .all()
- )
+ # Process expired workflow node executions with backup
+ session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
+ node_execution_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(session_maker)
+ before_date = datetime.datetime.now() - datetime.timedelta(days=days)
+ total_deleted = 0
- if len(workflow_node_executions) == 0:
- break
+ while True:
+ # Get a batch of expired executions for backup
+ workflow_node_executions = node_execution_repo.get_expired_executions_batch(
+ tenant_id=tenant_id,
+ before_date=before_date,
+ batch_size=batch,
+ )
- # save workflow node executions
- storage.save(
- f"free_plan_tenant_expired_logs/"
- f"{tenant_id}/workflow_node_executions/{datetime.datetime.now().strftime('%Y-%m-%d')}"
- f"-{time.time()}.json",
- json.dumps(
- jsonable_encoder(workflow_node_executions),
- ).encode("utf-8"),
- )
+ if len(workflow_node_executions) == 0:
+ break
+
+ # Save workflow node executions to storage
+ storage.save(
+ f"free_plan_tenant_expired_logs/"
+ f"{tenant_id}/workflow_node_executions/{datetime.datetime.now().strftime('%Y-%m-%d')}"
+ f"-{time.time()}.json",
+ json.dumps(
+ jsonable_encoder(workflow_node_executions),
+ ).encode("utf-8"),
+ )
- workflow_node_execution_ids = [
- workflow_node_execution.id for workflow_node_execution in workflow_node_executions
- ]
+ # Extract IDs for deletion
+ workflow_node_execution_ids = [
+ workflow_node_execution.id for workflow_node_execution in workflow_node_executions
+ ]
- # delete workflow node executions
- session.query(WorkflowNodeExecutionModel).filter(
- WorkflowNodeExecutionModel.id.in_(workflow_node_execution_ids),
- ).delete(synchronize_session=False)
- session.commit()
+ # Delete the backed up executions
+ deleted_count = node_execution_repo.delete_executions_by_ids(workflow_node_execution_ids)
+ total_deleted += deleted_count
- click.echo(
- click.style(
- f"[{datetime.datetime.now()}] Processed {len(workflow_node_execution_ids)}"
- f" workflow node executions for tenant {tenant_id}"
- )
+ click.echo(
+ click.style(
+ f"[{datetime.datetime.now()}] Processed {len(workflow_node_execution_ids)}"
+ f" workflow node executions for tenant {tenant_id}"
)
+ )
+
+ # If we got fewer than the batch size, we're done
+ if len(workflow_node_executions) < batch:
+ break
+
+ # Process expired workflow runs with backup
+ session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
+ workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker)
+ before_date = datetime.datetime.now() - datetime.timedelta(days=days)
+ total_deleted = 0
while True:
- with Session(db.engine).no_autoflush as session:
- workflow_runs = (
- session.query(WorkflowRun)
- .filter(
- WorkflowRun.tenant_id == tenant_id,
- WorkflowRun.created_at < datetime.datetime.now() - datetime.timedelta(days=days),
- )
- .limit(batch)
- .all()
- )
+ # Get a batch of expired workflow runs for backup
+ workflow_runs = workflow_run_repo.get_expired_runs_batch(
+ tenant_id=tenant_id,
+ before_date=before_date,
+ batch_size=batch,
+ )
- if len(workflow_runs) == 0:
- break
+ if len(workflow_runs) == 0:
+ break
+
+ # Save workflow runs to storage
+ storage.save(
+ f"free_plan_tenant_expired_logs/"
+ f"{tenant_id}/workflow_runs/{datetime.datetime.now().strftime('%Y-%m-%d')}"
+ f"-{time.time()}.json",
+ json.dumps(
+ jsonable_encoder(
+ [workflow_run.to_dict() for workflow_run in workflow_runs],
+ ),
+ ).encode("utf-8"),
+ )
- # save workflow runs
+ # Extract IDs for deletion
+ workflow_run_ids = [workflow_run.id for workflow_run in workflow_runs]
- storage.save(
- f"free_plan_tenant_expired_logs/"
- f"{tenant_id}/workflow_runs/{datetime.datetime.now().strftime('%Y-%m-%d')}"
- f"-{time.time()}.json",
- json.dumps(
- jsonable_encoder(
- [workflow_run.to_dict() for workflow_run in workflow_runs],
- ),
- ).encode("utf-8"),
- )
+ # Delete the backed up workflow runs
+ deleted_count = workflow_run_repo.delete_runs_by_ids(workflow_run_ids)
+ total_deleted += deleted_count
- workflow_run_ids = [workflow_run.id for workflow_run in workflow_runs]
+ click.echo(
+ click.style(
+ f"[{datetime.datetime.now()}] Processed {len(workflow_run_ids)}"
+ f" workflow runs for tenant {tenant_id}"
+ )
+ )
- # delete workflow runs
- session.query(WorkflowRun).filter(
- WorkflowRun.id.in_(workflow_run_ids),
- ).delete(synchronize_session=False)
- session.commit()
+ # If we got fewer than the batch size, we're done
+ if len(workflow_runs) < batch:
+ break
@classmethod
def process(cls, days: int, batch: int, tenant_ids: list[str]):
diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py
index e98b47921f..e42b5ace75 100644
--- a/api/services/dataset_service.py
+++ b/api/services/dataset_service.py
@@ -59,6 +59,7 @@ from services.external_knowledge_service import ExternalDatasetService
from services.feature_service import FeatureModel, FeatureService
from services.tag_service import TagService
from services.vector_service import VectorService
+from tasks.add_document_to_index_task import add_document_to_index_task
from tasks.batch_clean_document_task import batch_clean_document_task
from tasks.clean_notion_document_task import clean_notion_document_task
from tasks.deal_dataset_vector_index_task import deal_dataset_vector_index_task
@@ -70,6 +71,7 @@ from tasks.document_indexing_update_task import document_indexing_update_task
from tasks.duplicate_document_indexing_task import duplicate_document_indexing_task
from tasks.enable_segments_to_index_task import enable_segments_to_index_task
from tasks.recover_document_indexing_task import recover_document_indexing_task
+from tasks.remove_document_from_index_task import remove_document_from_index_task
from tasks.retry_document_indexing_task import retry_document_indexing_task
from tasks.sync_website_document_indexing_task import sync_website_document_indexing_task
@@ -276,176 +278,351 @@ class DatasetService:
except ProviderTokenNotInitError as ex:
raise ValueError(ex.description)
+ @staticmethod
+ def check_reranking_model_setting(tenant_id: str, reranking_model_provider: str, reranking_model: str):
+ try:
+ model_manager = ModelManager()
+ model_manager.get_model_instance(
+ tenant_id=tenant_id,
+ provider=reranking_model_provider,
+ model_type=ModelType.RERANK,
+ model=reranking_model,
+ )
+ except LLMBadRequestError:
+ raise ValueError(
+ "No Rerank Model available. Please configure a valid provider in the Settings -> Model Provider."
+ )
+ except ProviderTokenNotInitError as ex:
+ raise ValueError(ex.description)
+
@staticmethod
def update_dataset(dataset_id, data, user):
+ """
+ Update dataset configuration and settings.
+
+ Args:
+ dataset_id: The unique identifier of the dataset to update
+ data: Dictionary containing the update data
+ user: The user performing the update operation
+
+ Returns:
+ Dataset: The updated dataset object
+
+ Raises:
+ ValueError: If dataset not found or validation fails
+ NoPermissionError: If user lacks permission to update the dataset
+ """
+ # Retrieve and validate dataset existence
dataset = DatasetService.get_dataset(dataset_id)
if not dataset:
raise ValueError("Dataset not found")
+ # Verify user has permission to update this dataset
DatasetService.check_dataset_permission(dataset, user)
+
+ # Handle external dataset updates
if dataset.provider == "external":
- external_retrieval_model = data.get("external_retrieval_model", None)
- if external_retrieval_model:
- dataset.retrieval_model = external_retrieval_model
- dataset.name = data.get("name", dataset.name)
- dataset.description = data.get("description", "")
- permission = data.get("permission")
- if permission:
- dataset.permission = permission
- external_knowledge_id = data.get("external_knowledge_id", None)
- db.session.add(dataset)
- if not external_knowledge_id:
- raise ValueError("External knowledge id is required.")
- external_knowledge_api_id = data.get("external_knowledge_api_id", None)
- if not external_knowledge_api_id:
- raise ValueError("External knowledge api id is required.")
-
- with Session(db.engine) as session:
- external_knowledge_binding = (
- session.query(ExternalKnowledgeBindings).filter_by(dataset_id=dataset_id).first()
- )
+ return DatasetService._update_external_dataset(dataset, data, user)
+ else:
+ return DatasetService._update_internal_dataset(dataset, data, user)
- if not external_knowledge_binding:
- raise ValueError("External knowledge binding not found.")
+ @staticmethod
+ def _update_external_dataset(dataset, data, user):
+ """
+ Update external dataset configuration.
+
+ Args:
+ dataset: The dataset object to update
+ data: Update data dictionary
+ user: User performing the update
+
+ Returns:
+ Dataset: Updated dataset object
+ """
+ # Update retrieval model if provided
+ external_retrieval_model = data.get("external_retrieval_model", None)
+ if external_retrieval_model:
+ dataset.retrieval_model = external_retrieval_model
+
+ # Update basic dataset properties
+ dataset.name = data.get("name", dataset.name)
+ dataset.description = data.get("description", dataset.description)
+
+ # Update permission if provided
+ permission = data.get("permission")
+ if permission:
+ dataset.permission = permission
+
+ # Validate and update external knowledge configuration
+ external_knowledge_id = data.get("external_knowledge_id", None)
+ external_knowledge_api_id = data.get("external_knowledge_api_id", None)
+
+ if not external_knowledge_id:
+ raise ValueError("External knowledge id is required.")
+ if not external_knowledge_api_id:
+ raise ValueError("External knowledge api id is required.")
+ # Update metadata fields
+ dataset.updated_by = user.id if user else None
+ dataset.updated_at = datetime.datetime.utcnow()
+ db.session.add(dataset)
- if (
- external_knowledge_binding.external_knowledge_id != external_knowledge_id
- or external_knowledge_binding.external_knowledge_api_id != external_knowledge_api_id
- ):
- external_knowledge_binding.external_knowledge_id = external_knowledge_id
- external_knowledge_binding.external_knowledge_api_id = external_knowledge_api_id
- db.session.add(external_knowledge_binding)
- db.session.commit()
- else:
- data.pop("partial_member_list", None)
- data.pop("external_knowledge_api_id", None)
- data.pop("external_knowledge_id", None)
- data.pop("external_retrieval_model", None)
- filtered_data = {k: v for k, v in data.items() if v is not None or k == "description"}
- action = None
- if dataset.indexing_technique != data["indexing_technique"]:
- # if update indexing_technique
- if data["indexing_technique"] == "economy":
- action = "remove"
- filtered_data["embedding_model"] = None
- filtered_data["embedding_model_provider"] = None
- filtered_data["collection_binding_id"] = None
- elif data["indexing_technique"] == "high_quality":
- action = "add"
- # get embedding model setting
- try:
- model_manager = ModelManager()
- embedding_model = model_manager.get_model_instance(
- tenant_id=current_user.current_tenant_id,
- provider=data["embedding_model_provider"],
- model_type=ModelType.TEXT_EMBEDDING,
- model=data["embedding_model"],
- )
- filtered_data["embedding_model"] = embedding_model.model
- filtered_data["embedding_model_provider"] = embedding_model.provider
- dataset_collection_binding = DatasetCollectionBindingService.get_dataset_collection_binding(
- embedding_model.provider, embedding_model.model
- )
- filtered_data["collection_binding_id"] = dataset_collection_binding.id
- except LLMBadRequestError:
- raise ValueError(
- "No Embedding Model available. Please configure a valid provider "
- "in the Settings -> Model Provider."
- )
- except ProviderTokenNotInitError as ex:
- raise ValueError(ex.description)
- else:
- # add default plugin id to both setting sets, to make sure the plugin model provider is consistent
- # Skip embedding model checks if not provided in the update request
- if (
- "embedding_model_provider" not in data
- or "embedding_model" not in data
- or not data.get("embedding_model_provider")
- or not data.get("embedding_model")
- ):
- # If the dataset already has embedding model settings, use those
- if dataset.embedding_model_provider and dataset.embedding_model:
- # Keep existing values
- filtered_data["embedding_model_provider"] = dataset.embedding_model_provider
- filtered_data["embedding_model"] = dataset.embedding_model
- # If collection_binding_id exists, keep it too
- if dataset.collection_binding_id:
- filtered_data["collection_binding_id"] = dataset.collection_binding_id
- # Otherwise, don't try to update embedding model settings at all
- # Remove these fields from filtered_data if they exist but are None/empty
- if "embedding_model_provider" in filtered_data and not filtered_data["embedding_model_provider"]:
- del filtered_data["embedding_model_provider"]
- if "embedding_model" in filtered_data and not filtered_data["embedding_model"]:
- del filtered_data["embedding_model"]
- else:
- skip_embedding_update = False
- try:
- # Handle existing model provider
- plugin_model_provider = dataset.embedding_model_provider
- plugin_model_provider_str = None
- if plugin_model_provider:
- plugin_model_provider_str = str(ModelProviderID(plugin_model_provider))
-
- # Handle new model provider from request
- new_plugin_model_provider = data["embedding_model_provider"]
- new_plugin_model_provider_str = None
- if new_plugin_model_provider:
- new_plugin_model_provider_str = str(ModelProviderID(new_plugin_model_provider))
-
- # Only update embedding model if both values are provided and different from current
- if (
- plugin_model_provider_str != new_plugin_model_provider_str
- or data["embedding_model"] != dataset.embedding_model
- ):
- action = "update"
- model_manager = ModelManager()
- try:
- embedding_model = model_manager.get_model_instance(
- tenant_id=current_user.current_tenant_id,
- provider=data["embedding_model_provider"],
- model_type=ModelType.TEXT_EMBEDDING,
- model=data["embedding_model"],
- )
- except ProviderTokenNotInitError:
- # If we can't get the embedding model, skip updating it
- # and keep the existing settings if available
- if dataset.embedding_model_provider and dataset.embedding_model:
- filtered_data["embedding_model_provider"] = dataset.embedding_model_provider
- filtered_data["embedding_model"] = dataset.embedding_model
- if dataset.collection_binding_id:
- filtered_data["collection_binding_id"] = dataset.collection_binding_id
- # Skip the rest of the embedding model update
- skip_embedding_update = True
- if not skip_embedding_update:
- filtered_data["embedding_model"] = embedding_model.model
- filtered_data["embedding_model_provider"] = embedding_model.provider
- dataset_collection_binding = (
- DatasetCollectionBindingService.get_dataset_collection_binding(
- embedding_model.provider, embedding_model.model
- )
- )
- filtered_data["collection_binding_id"] = dataset_collection_binding.id
- except LLMBadRequestError:
- raise ValueError(
- "No Embedding Model available. Please configure a valid provider "
- "in the Settings -> Model Provider."
- )
- except ProviderTokenNotInitError as ex:
- raise ValueError(ex.description)
+ # Update external knowledge binding
+ DatasetService._update_external_knowledge_binding(dataset.id, external_knowledge_id, external_knowledge_api_id)
+
+ # Commit changes to database
+ db.session.commit()
+
+ return dataset
- filtered_data["updated_by"] = user.id
- filtered_data["updated_at"] = datetime.datetime.now()
+ @staticmethod
+ def _update_external_knowledge_binding(dataset_id, external_knowledge_id, external_knowledge_api_id):
+ """
+ Update external knowledge binding configuration.
+
+ Args:
+ dataset_id: Dataset identifier
+ external_knowledge_id: External knowledge identifier
+ external_knowledge_api_id: External knowledge API identifier
+ """
+ with Session(db.engine) as session:
+ external_knowledge_binding = (
+ session.query(ExternalKnowledgeBindings).filter_by(dataset_id=dataset_id).first()
+ )
- # update Retrieval model
- filtered_data["retrieval_model"] = data["retrieval_model"]
+ if not external_knowledge_binding:
+ raise ValueError("External knowledge binding not found.")
- db.session.query(Dataset).filter_by(id=dataset_id).update(filtered_data)
+ # Update binding if values have changed
+ if (
+ external_knowledge_binding.external_knowledge_id != external_knowledge_id
+ or external_knowledge_binding.external_knowledge_api_id != external_knowledge_api_id
+ ):
+ external_knowledge_binding.external_knowledge_id = external_knowledge_id
+ external_knowledge_binding.external_knowledge_api_id = external_knowledge_api_id
+ db.session.add(external_knowledge_binding)
+
+ @staticmethod
+ def _update_internal_dataset(dataset, data, user):
+ """
+ Update internal dataset configuration.
+
+ Args:
+ dataset: The dataset object to update
+ data: Update data dictionary
+ user: User performing the update
+
+ Returns:
+ Dataset: Updated dataset object
+ """
+ # Remove external-specific fields from update data
+ data.pop("partial_member_list", None)
+ data.pop("external_knowledge_api_id", None)
+ data.pop("external_knowledge_id", None)
+ data.pop("external_retrieval_model", None)
+
+ # Filter out None values except for description field
+ filtered_data = {k: v for k, v in data.items() if v is not None or k == "description"}
+
+ # Handle indexing technique changes and embedding model updates
+ action = DatasetService._handle_indexing_technique_change(dataset, data, filtered_data)
+
+ # Add metadata fields
+ filtered_data["updated_by"] = user.id
+ filtered_data["updated_at"] = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
+ # update Retrieval model
+ filtered_data["retrieval_model"] = data["retrieval_model"]
+
+ # Update dataset in database
+ db.session.query(Dataset).filter_by(id=dataset.id).update(filtered_data)
+ db.session.commit()
+
+ # Trigger vector index task if indexing technique changed
+ if action:
+ deal_dataset_vector_index_task.delay(dataset.id, action)
- db.session.commit()
- if action:
- deal_dataset_vector_index_task.delay(dataset_id, action)
return dataset
+ @staticmethod
+ def _handle_indexing_technique_change(dataset, data, filtered_data):
+ """
+ Handle changes in indexing technique and configure embedding models accordingly.
+
+ Args:
+ dataset: Current dataset object
+ data: Update data dictionary
+ filtered_data: Filtered update data
+
+ Returns:
+ str: Action to perform ('add', 'remove', 'update', or None)
+ """
+ if dataset.indexing_technique != data["indexing_technique"]:
+ if data["indexing_technique"] == "economy":
+ # Remove embedding model configuration for economy mode
+ filtered_data["embedding_model"] = None
+ filtered_data["embedding_model_provider"] = None
+ filtered_data["collection_binding_id"] = None
+ return "remove"
+ elif data["indexing_technique"] == "high_quality":
+ # Configure embedding model for high quality mode
+ DatasetService._configure_embedding_model_for_high_quality(data, filtered_data)
+ return "add"
+ else:
+ # Handle embedding model updates when indexing technique remains the same
+ return DatasetService._handle_embedding_model_update_when_technique_unchanged(dataset, data, filtered_data)
+ return None
+
+ @staticmethod
+ def _configure_embedding_model_for_high_quality(data, filtered_data):
+ """
+ Configure embedding model settings for high quality indexing.
+
+ Args:
+ data: Update data dictionary
+ filtered_data: Filtered update data to modify
+ """
+ try:
+ model_manager = ModelManager()
+ embedding_model = model_manager.get_model_instance(
+ tenant_id=current_user.current_tenant_id,
+ provider=data["embedding_model_provider"],
+ model_type=ModelType.TEXT_EMBEDDING,
+ model=data["embedding_model"],
+ )
+ filtered_data["embedding_model"] = embedding_model.model
+ filtered_data["embedding_model_provider"] = embedding_model.provider
+ dataset_collection_binding = DatasetCollectionBindingService.get_dataset_collection_binding(
+ embedding_model.provider, embedding_model.model
+ )
+ filtered_data["collection_binding_id"] = dataset_collection_binding.id
+ except LLMBadRequestError:
+ raise ValueError(
+ "No Embedding Model available. Please configure a valid provider in the Settings -> Model Provider."
+ )
+ except ProviderTokenNotInitError as ex:
+ raise ValueError(ex.description)
+
+ @staticmethod
+ def _handle_embedding_model_update_when_technique_unchanged(dataset, data, filtered_data):
+ """
+ Handle embedding model updates when indexing technique remains the same.
+
+ Args:
+ dataset: Current dataset object
+ data: Update data dictionary
+ filtered_data: Filtered update data to modify
+
+ Returns:
+ str: Action to perform ('update' or None)
+ """
+ # Skip embedding model checks if not provided in the update request
+ if (
+ "embedding_model_provider" not in data
+ or "embedding_model" not in data
+ or not data.get("embedding_model_provider")
+ or not data.get("embedding_model")
+ ):
+ DatasetService._preserve_existing_embedding_settings(dataset, filtered_data)
+ return None
+ else:
+ return DatasetService._update_embedding_model_settings(dataset, data, filtered_data)
+
+ @staticmethod
+ def _preserve_existing_embedding_settings(dataset, filtered_data):
+ """
+ Preserve existing embedding model settings when not provided in update.
+
+ Args:
+ dataset: Current dataset object
+ filtered_data: Filtered update data to modify
+ """
+ # If the dataset already has embedding model settings, use those
+ if dataset.embedding_model_provider and dataset.embedding_model:
+ filtered_data["embedding_model_provider"] = dataset.embedding_model_provider
+ filtered_data["embedding_model"] = dataset.embedding_model
+ # If collection_binding_id exists, keep it too
+ if dataset.collection_binding_id:
+ filtered_data["collection_binding_id"] = dataset.collection_binding_id
+ # Otherwise, don't try to update embedding model settings at all
+ # Remove these fields from filtered_data if they exist but are None/empty
+ if "embedding_model_provider" in filtered_data and not filtered_data["embedding_model_provider"]:
+ del filtered_data["embedding_model_provider"]
+ if "embedding_model" in filtered_data and not filtered_data["embedding_model"]:
+ del filtered_data["embedding_model"]
+
+ @staticmethod
+ def _update_embedding_model_settings(dataset, data, filtered_data):
+ """
+ Update embedding model settings with new values.
+
+ Args:
+ dataset: Current dataset object
+ data: Update data dictionary
+ filtered_data: Filtered update data to modify
+
+ Returns:
+ str: Action to perform ('update' or None)
+ """
+ try:
+ # Compare current and new model provider settings
+ current_provider_str = (
+ str(ModelProviderID(dataset.embedding_model_provider)) if dataset.embedding_model_provider else None
+ )
+ new_provider_str = (
+ str(ModelProviderID(data["embedding_model_provider"])) if data["embedding_model_provider"] else None
+ )
+
+ # Only update if values are different
+ if current_provider_str != new_provider_str or data["embedding_model"] != dataset.embedding_model:
+ DatasetService._apply_new_embedding_settings(dataset, data, filtered_data)
+ return "update"
+ except LLMBadRequestError:
+ raise ValueError(
+ "No Embedding Model available. Please configure a valid provider in the Settings -> Model Provider."
+ )
+ except ProviderTokenNotInitError as ex:
+ raise ValueError(ex.description)
+ return None
+
+ @staticmethod
+ def _apply_new_embedding_settings(dataset, data, filtered_data):
+ """
+ Apply new embedding model settings to the dataset.
+
+ Args:
+ dataset: Current dataset object
+ data: Update data dictionary
+ filtered_data: Filtered update data to modify
+ """
+ model_manager = ModelManager()
+ try:
+ embedding_model = model_manager.get_model_instance(
+ tenant_id=current_user.current_tenant_id,
+ provider=data["embedding_model_provider"],
+ model_type=ModelType.TEXT_EMBEDDING,
+ model=data["embedding_model"],
+ )
+ except ProviderTokenNotInitError:
+ # If we can't get the embedding model, preserve existing settings
+ logging.warning(
+ f"Failed to initialize embedding model {data['embedding_model_provider']}/{data['embedding_model']}, "
+ f"preserving existing settings"
+ )
+ if dataset.embedding_model_provider and dataset.embedding_model:
+ filtered_data["embedding_model_provider"] = dataset.embedding_model_provider
+ filtered_data["embedding_model"] = dataset.embedding_model
+ if dataset.collection_binding_id:
+ filtered_data["collection_binding_id"] = dataset.collection_binding_id
+ # Skip the rest of the embedding model update
+ return
+
+ # Apply new embedding model settings
+ filtered_data["embedding_model"] = embedding_model.model
+ filtered_data["embedding_model_provider"] = embedding_model.provider
+ dataset_collection_binding = DatasetCollectionBindingService.get_dataset_collection_binding(
+ embedding_model.provider, embedding_model.model
+ )
+ filtered_data["collection_binding_id"] = dataset_collection_binding.id
+
@staticmethod
def delete_dataset(dataset_id, user):
dataset = DatasetService.get_dataset(dataset_id)
@@ -976,12 +1153,17 @@ class DocumentService:
process_rule = knowledge_config.process_rule
if process_rule:
if process_rule.mode in ("custom", "hierarchical"):
- dataset_process_rule = DatasetProcessRule(
- dataset_id=dataset.id,
- mode=process_rule.mode,
- rules=process_rule.rules.model_dump_json() if process_rule.rules else None,
- created_by=account.id,
- )
+ if process_rule.rules:
+ dataset_process_rule = DatasetProcessRule(
+ dataset_id=dataset.id,
+ mode=process_rule.mode,
+ rules=process_rule.rules.model_dump_json() if process_rule.rules else None,
+ created_by=account.id,
+ )
+ else:
+ dataset_process_rule = dataset.latest_process_rule
+ if not dataset_process_rule:
+ raise ValueError("No process rule found.")
elif process_rule.mode == "automatic":
dataset_process_rule = DatasetProcessRule(
dataset_id=dataset.id,
@@ -1402,16 +1584,16 @@ class DocumentService:
knowledge_config.embedding_model, # type: ignore
)
dataset_collection_binding_id = dataset_collection_binding.id
- if knowledge_config.retrieval_model:
- retrieval_model = knowledge_config.retrieval_model
- else:
- retrieval_model = RetrievalModel(
- search_method=RetrievalMethod.SEMANTIC_SEARCH.value,
- reranking_enable=False,
- reranking_model=RerankingModel(reranking_provider_name="", reranking_model_name=""),
- top_k=2,
- score_threshold_enabled=False,
- )
+ if knowledge_config.retrieval_model:
+ retrieval_model = knowledge_config.retrieval_model
+ else:
+ retrieval_model = RetrievalModel(
+ search_method=RetrievalMethod.SEMANTIC_SEARCH.value,
+ reranking_enable=False,
+ reranking_model=RerankingModel(reranking_provider_name="", reranking_model_name=""),
+ top_k=2,
+ score_threshold_enabled=False,
+ )
# save dataset
dataset = Dataset(
tenant_id=tenant_id,
@@ -1603,6 +1785,191 @@ class DocumentService:
if not isinstance(args["process_rule"]["rules"]["segmentation"]["max_tokens"], int):
raise ValueError("Process rule segmentation max_tokens is invalid")
+ @staticmethod
+ def batch_update_document_status(dataset: Dataset, document_ids: list[str], action: str, user):
+ """
+ Batch update document status.
+
+ Args:
+ dataset (Dataset): The dataset object
+ document_ids (list[str]): List of document IDs to update
+ action (str): Action to perform (enable, disable, archive, un_archive)
+ user: Current user performing the action
+
+ Raises:
+ DocumentIndexingError: If document is being indexed or not in correct state
+ ValueError: If action is invalid
+ """
+ if not document_ids:
+ return
+
+ # Early validation of action parameter
+ valid_actions = ["enable", "disable", "archive", "un_archive"]
+ if action not in valid_actions:
+ raise ValueError(f"Invalid action: {action}. Must be one of {valid_actions}")
+
+ documents_to_update = []
+
+ # First pass: validate all documents and prepare updates
+ for document_id in document_ids:
+ document = DocumentService.get_document(dataset.id, document_id)
+ if not document:
+ continue
+
+ # Check if document is being indexed
+ indexing_cache_key = f"document_{document.id}_indexing"
+ cache_result = redis_client.get(indexing_cache_key)
+ if cache_result is not None:
+ raise DocumentIndexingError(f"Document:{document.name} is being indexed, please try again later")
+
+ # Prepare update based on action
+ update_info = DocumentService._prepare_document_status_update(document, action, user)
+ if update_info:
+ documents_to_update.append(update_info)
+
+ # Second pass: apply all updates in a single transaction
+ if documents_to_update:
+ try:
+ for update_info in documents_to_update:
+ document = update_info["document"]
+ updates = update_info["updates"]
+
+ # Apply updates to the document
+ for field, value in updates.items():
+ setattr(document, field, value)
+
+ db.session.add(document)
+
+ # Batch commit all changes
+ db.session.commit()
+ except Exception as e:
+ # Rollback on any error
+ db.session.rollback()
+ raise e
+ # Execute async tasks and set Redis cache after successful commit
+ # propagation_error is used to capture any errors for submitting async task execution
+ propagation_error = None
+ for update_info in documents_to_update:
+ try:
+ # Execute async tasks after successful commit
+ if update_info["async_task"]:
+ task_info = update_info["async_task"]
+ task_func = task_info["function"]
+ task_args = task_info["args"]
+ task_func.delay(*task_args)
+ except Exception as e:
+ # Log the error but do not rollback the transaction
+ logging.exception(f"Error executing async task for document {update_info['document'].id}")
+ # don't raise the error immediately, but capture it for later
+ propagation_error = e
+ try:
+ # Set Redis cache if needed after successful commit
+ if update_info["set_cache"]:
+ document = update_info["document"]
+ indexing_cache_key = f"document_{document.id}_indexing"
+ redis_client.setex(indexing_cache_key, 600, 1)
+ except Exception as e:
+ # Log the error but do not rollback the transaction
+ logging.exception(f"Error setting cache for document {update_info['document'].id}")
+ # Raise any propagation error after all updates
+ if propagation_error:
+ raise propagation_error
+
+ @staticmethod
+ def _prepare_document_status_update(document, action: str, user):
+ """
+ Prepare document status update information.
+
+ Args:
+ document: Document object to update
+ action: Action to perform
+ user: Current user
+
+ Returns:
+ dict: Update information or None if no update needed
+ """
+ now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
+
+ if action == "enable":
+ return DocumentService._prepare_enable_update(document, now)
+ elif action == "disable":
+ return DocumentService._prepare_disable_update(document, user, now)
+ elif action == "archive":
+ return DocumentService._prepare_archive_update(document, user, now)
+ elif action == "un_archive":
+ return DocumentService._prepare_unarchive_update(document, now)
+
+ return None
+
+ @staticmethod
+ def _prepare_enable_update(document, now):
+ """Prepare updates for enabling a document."""
+ if document.enabled:
+ return None
+
+ return {
+ "document": document,
+ "updates": {"enabled": True, "disabled_at": None, "disabled_by": None, "updated_at": now},
+ "async_task": {"function": add_document_to_index_task, "args": [document.id]},
+ "set_cache": True,
+ }
+
+ @staticmethod
+ def _prepare_disable_update(document, user, now):
+ """Prepare updates for disabling a document."""
+ if not document.completed_at or document.indexing_status != "completed":
+ raise DocumentIndexingError(f"Document: {document.name} is not completed.")
+
+ if not document.enabled:
+ return None
+
+ return {
+ "document": document,
+ "updates": {"enabled": False, "disabled_at": now, "disabled_by": user.id, "updated_at": now},
+ "async_task": {"function": remove_document_from_index_task, "args": [document.id]},
+ "set_cache": True,
+ }
+
+ @staticmethod
+ def _prepare_archive_update(document, user, now):
+ """Prepare updates for archiving a document."""
+ if document.archived:
+ return None
+
+ update_info = {
+ "document": document,
+ "updates": {"archived": True, "archived_at": now, "archived_by": user.id, "updated_at": now},
+ "async_task": None,
+ "set_cache": False,
+ }
+
+ # Only set async task and cache if document is currently enabled
+ if document.enabled:
+ update_info["async_task"] = {"function": remove_document_from_index_task, "args": [document.id]}
+ update_info["set_cache"] = True
+
+ return update_info
+
+ @staticmethod
+ def _prepare_unarchive_update(document, now):
+ """Prepare updates for unarchiving a document."""
+ if not document.archived:
+ return None
+
+ update_info = {
+ "document": document,
+ "updates": {"archived": False, "archived_at": None, "archived_by": None, "updated_at": now},
+ "async_task": None,
+ "set_cache": False,
+ }
+
+ # Only re-index if the document is currently enabled
+ if document.enabled:
+ update_info["async_task"] = {"function": add_document_to_index_task, "args": [document.id]}
+ update_info["set_cache"] = True
+
+ return update_info
+
class SegmentService:
@classmethod
@@ -1857,6 +2224,7 @@ class SegmentService:
# calc embedding use tokens
if document.doc_form == "qa_model":
+ segment.answer = args.answer
tokens = embedding_model.get_text_embedding_num_tokens(texts=[content + segment.answer])[0]
else:
tokens = embedding_model.get_text_embedding_num_tokens(texts=[content])[0]
diff --git a/api/services/enterprise/enterprise_service.py b/api/services/enterprise/enterprise_service.py
index 8c06ee9386..54d45f45ea 100644
--- a/api/services/enterprise/enterprise_service.py
+++ b/api/services/enterprise/enterprise_service.py
@@ -29,7 +29,7 @@ class EnterpriseService:
raise ValueError("No data found.")
try:
# parse the UTC timestamp from the response
- return datetime.fromisoformat(data.replace("Z", "+00:00"))
+ return datetime.fromisoformat(data)
except ValueError as e:
raise ValueError(f"Invalid date format: {data}") from e
@@ -40,7 +40,7 @@ class EnterpriseService:
raise ValueError("No data found.")
try:
# parse the UTC timestamp from the response
- return datetime.fromisoformat(data.replace("Z", "+00:00"))
+ return datetime.fromisoformat(data)
except ValueError as e:
raise ValueError(f"Invalid date format: {data}") from e
diff --git a/api/services/entities/knowledge_entities/knowledge_entities.py b/api/services/entities/knowledge_entities/knowledge_entities.py
index bb3be61f85..88d4224e97 100644
--- a/api/services/entities/knowledge_entities/knowledge_entities.py
+++ b/api/services/entities/knowledge_entities/knowledge_entities.py
@@ -95,13 +95,13 @@ class WeightKeywordSetting(BaseModel):
class WeightModel(BaseModel):
- weight_type: Optional[str] = None
+ weight_type: Optional[Literal["semantic_first", "keyword_first", "customized"]] = None
vector_setting: Optional[WeightVectorSetting] = None
keyword_setting: Optional[WeightKeywordSetting] = None
class RetrievalModel(BaseModel):
- search_method: Literal["hybrid_search", "semantic_search", "full_text_search"]
+ search_method: Literal["hybrid_search", "semantic_search", "full_text_search", "keyword_search"]
reranking_enable: bool
reranking_model: Optional[RerankingModel] = None
reranking_mode: Optional[str] = None
diff --git a/api/services/errors/__init__.py b/api/services/errors/__init__.py
index eb1f055708..697e691224 100644
--- a/api/services/errors/__init__.py
+++ b/api/services/errors/__init__.py
@@ -4,7 +4,6 @@ from . import (
app_model_config,
audio,
base,
- completion,
conversation,
dataset,
document,
@@ -19,7 +18,6 @@ __all__ = [
"app_model_config",
"audio",
"base",
- "completion",
"conversation",
"dataset",
"document",
diff --git a/api/services/errors/account.py b/api/services/errors/account.py
index 5aca12ffeb..4d3d150e07 100644
--- a/api/services/errors/account.py
+++ b/api/services/errors/account.py
@@ -55,7 +55,3 @@ class MemberNotInTenantError(BaseServiceError):
class RoleAlreadyAssignedError(BaseServiceError):
pass
-
-
-class RateLimitExceededError(BaseServiceError):
- pass
diff --git a/api/services/errors/app.py b/api/services/errors/app.py
index 87e9e9247d..5d348c61be 100644
--- a/api/services/errors/app.py
+++ b/api/services/errors/app.py
@@ -4,3 +4,7 @@ class MoreLikeThisDisabledError(Exception):
class WorkflowHashNotEqualError(Exception):
pass
+
+
+class IsDraftWorkflowError(Exception):
+ pass
diff --git a/api/services/errors/completion.py b/api/services/errors/plugin.py
similarity index 51%
rename from api/services/errors/completion.py
rename to api/services/errors/plugin.py
index 7fc50a588e..be5b144b3d 100644
--- a/api/services/errors/completion.py
+++ b/api/services/errors/plugin.py
@@ -1,5 +1,5 @@
from services.errors.base import BaseServiceError
-class CompletionStoppedError(BaseServiceError):
+class PluginInstallationForbiddenError(BaseServiceError):
pass
diff --git a/api/services/feature_service.py b/api/services/feature_service.py
index be85a03e80..188caf3505 100644
--- a/api/services/feature_service.py
+++ b/api/services/feature_service.py
@@ -88,6 +88,26 @@ class WebAppAuthModel(BaseModel):
allow_email_password_login: bool = False
+class PluginInstallationScope(StrEnum):
+ NONE = "none"
+ OFFICIAL_ONLY = "official_only"
+ OFFICIAL_AND_SPECIFIC_PARTNERS = "official_and_specific_partners"
+ ALL = "all"
+
+
+class PluginInstallationPermissionModel(BaseModel):
+ # Plugin installation scope – possible values:
+ # none: prohibit all plugin installations
+ # official_only: allow only Dify official plugins
+ # official_and_specific_partners: allow official and specific partner plugins
+ # all: allow installation of all plugins
+ plugin_installation_scope: PluginInstallationScope = PluginInstallationScope.ALL
+
+ # If True, restrict plugin installation to the marketplace only
+ # Equivalent to ForceEnablePluginVerification
+ restrict_to_marketplace_only: bool = False
+
+
class FeatureModel(BaseModel):
billing: BillingModel = BillingModel()
education: EducationModel = EducationModel()
@@ -128,6 +148,7 @@ class SystemFeatureModel(BaseModel):
license: LicenseModel = LicenseModel()
branding: BrandingModel = BrandingModel()
webapp_auth: WebAppAuthModel = WebAppAuthModel()
+ plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel()
class FeatureService:
@@ -291,3 +312,12 @@ class FeatureService:
features.license.workspaces.enabled = license_info["workspaces"]["enabled"]
features.license.workspaces.limit = license_info["workspaces"]["limit"]
features.license.workspaces.size = license_info["workspaces"]["used"]
+
+ if "PluginInstallationPermission" in enterprise_info:
+ plugin_installation_info = enterprise_info["PluginInstallationPermission"]
+ features.plugin_installation_permission.plugin_installation_scope = plugin_installation_info[
+ "pluginInstallationScope"
+ ]
+ features.plugin_installation_permission.restrict_to_marketplace_only = plugin_installation_info[
+ "restrictToMarketplaceOnly"
+ ]
diff --git a/api/services/file_service.py b/api/services/file_service.py
index 2d68f30c5a..286535bd18 100644
--- a/api/services/file_service.py
+++ b/api/services/file_service.py
@@ -18,6 +18,7 @@ from core.file import helpers as file_helpers
from core.rag.extractor.extract_processor import ExtractProcessor
from extensions.ext_database import db
from extensions.ext_storage import storage
+from libs.helper import extract_tenant_id
from models.account import Account
from models.enums import CreatorUserRole
from models.model import EndUser, UploadFile
@@ -61,11 +62,7 @@ class FileService:
# generate file key
file_uuid = str(uuid.uuid4())
- if isinstance(user, Account):
- current_tenant_id = user.current_tenant_id
- else:
- # end_user
- current_tenant_id = user.tenant_id
+ current_tenant_id = extract_tenant_id(user)
file_key = "upload_files/" + (current_tenant_id or "") + "/" + file_uuid + "." + extension
diff --git a/api/services/metadata_service.py b/api/services/metadata_service.py
index 26d6d4ce18..cfcb121153 100644
--- a/api/services/metadata_service.py
+++ b/api/services/metadata_service.py
@@ -19,6 +19,10 @@ from services.entities.knowledge_entities.knowledge_entities import (
class MetadataService:
@staticmethod
def create_metadata(dataset_id: str, metadata_args: MetadataArgs) -> DatasetMetadata:
+ # check if metadata name is too long
+ if len(metadata_args.name) > 255:
+ raise ValueError("Metadata name cannot exceed 255 characters.")
+
# check if metadata name already exists
if (
db.session.query(DatasetMetadata)
@@ -42,6 +46,10 @@ class MetadataService:
@staticmethod
def update_metadata_name(dataset_id: str, metadata_id: str, name: str) -> DatasetMetadata: # type: ignore
+ # check if metadata name is too long
+ if len(name) > 255:
+ raise ValueError("Metadata name cannot exceed 255 characters.")
+
lock_key = f"dataset_metadata_lock_{dataset_id}"
# check if metadata name already exists
if (
diff --git a/api/services/moderation_service.py b/api/services/moderation_service.py
deleted file mode 100644
index 082afeed89..0000000000
--- a/api/services/moderation_service.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from typing import Optional
-
-from core.moderation.factory import ModerationFactory, ModerationOutputsResult
-from extensions.ext_database import db
-from models.model import App, AppModelConfig
-
-
-class ModerationService:
- def moderation_for_outputs(self, app_id: str, app_model: App, text: str) -> ModerationOutputsResult:
- app_model_config: Optional[AppModelConfig] = None
-
- app_model_config = (
- db.session.query(AppModelConfig).filter(AppModelConfig.id == app_model.app_model_config_id).first()
- )
-
- if not app_model_config:
- raise ValueError("app model config not found")
-
- name = app_model_config.sensitive_word_avoidance_dict["type"]
- config = app_model_config.sensitive_word_avoidance_dict["config"]
-
- moderation = ModerationFactory(name, app_id, app_model.tenant_id, config)
- return moderation.moderation_for_outputs(text)
diff --git a/api/services/ops_service.py b/api/services/ops_service.py
index 792f50703e..dbeb4f1908 100644
--- a/api/services/ops_service.py
+++ b/api/services/ops_service.py
@@ -34,6 +34,24 @@ class OpsService:
)
new_decrypt_tracing_config = OpsTraceManager.obfuscated_decrypt_token(tracing_provider, decrypt_tracing_config)
+ if tracing_provider == "arize" and (
+ "project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url")
+ ):
+ try:
+ project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider)
+ new_decrypt_tracing_config.update({"project_url": project_url})
+ except Exception:
+ new_decrypt_tracing_config.update({"project_url": "https://app.arize.com/"})
+
+ if tracing_provider == "phoenix" and (
+ "project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url")
+ ):
+ try:
+ project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider)
+ new_decrypt_tracing_config.update({"project_url": project_url})
+ except Exception:
+ new_decrypt_tracing_config.update({"project_url": "https://app.phoenix.arize.com/projects/"})
+
if tracing_provider == "langfuse" and (
"project_key" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_key")
):
@@ -76,6 +94,16 @@ class OpsService:
new_decrypt_tracing_config.update({"project_url": project_url})
except Exception:
new_decrypt_tracing_config.update({"project_url": "https://wandb.ai/"})
+
+ if tracing_provider == "aliyun" and (
+ "project_url" not in decrypt_tracing_config or not decrypt_tracing_config.get("project_url")
+ ):
+ try:
+ project_url = OpsTraceManager.get_trace_config_project_url(decrypt_tracing_config, tracing_provider)
+ new_decrypt_tracing_config.update({"project_url": project_url})
+ except Exception:
+ new_decrypt_tracing_config.update({"project_url": "https://arms.console.aliyun.com/"})
+
trace_config_data.tracing_config = new_decrypt_tracing_config
return trace_config_data.to_dict()
@@ -107,7 +135,9 @@ class OpsService:
return {"error": "Invalid Credentials"}
# get project url
- if tracing_provider == "langfuse":
+ if tracing_provider in ("arize", "phoenix"):
+ project_url = OpsTraceManager.get_trace_config_project_url(tracing_config, tracing_provider)
+ elif tracing_provider == "langfuse":
project_key = OpsTraceManager.get_trace_config_project_key(tracing_config, tracing_provider)
project_url = "{host}/project/{key}".format(host=tracing_config.get("host"), key=project_key)
elif tracing_provider in ("langsmith", "opik"):
diff --git a/api/services/plugin/data_migration.py b/api/services/plugin/data_migration.py
index 1c5abfecba..5324036414 100644
--- a/api/services/plugin/data_migration.py
+++ b/api/services/plugin/data_migration.py
@@ -3,7 +3,7 @@ import logging
import click
-from core.entities import DEFAULT_PLUGIN_ID
+from core.plugin.entities.plugin import GenericProviderID, ModelProviderID, ToolProviderID
from models.engine import db
logger = logging.getLogger(__name__)
@@ -12,17 +12,17 @@ logger = logging.getLogger(__name__)
class PluginDataMigration:
@classmethod
def migrate(cls) -> None:
- cls.migrate_db_records("providers", "provider_name") # large table
- cls.migrate_db_records("provider_models", "provider_name")
- cls.migrate_db_records("provider_orders", "provider_name")
- cls.migrate_db_records("tenant_default_models", "provider_name")
- cls.migrate_db_records("tenant_preferred_model_providers", "provider_name")
- cls.migrate_db_records("provider_model_settings", "provider_name")
- cls.migrate_db_records("load_balancing_model_configs", "provider_name")
+ cls.migrate_db_records("providers", "provider_name", ModelProviderID) # large table
+ cls.migrate_db_records("provider_models", "provider_name", ModelProviderID)
+ cls.migrate_db_records("provider_orders", "provider_name", ModelProviderID)
+ cls.migrate_db_records("tenant_default_models", "provider_name", ModelProviderID)
+ cls.migrate_db_records("tenant_preferred_model_providers", "provider_name", ModelProviderID)
+ cls.migrate_db_records("provider_model_settings", "provider_name", ModelProviderID)
+ cls.migrate_db_records("load_balancing_model_configs", "provider_name", ModelProviderID)
cls.migrate_datasets()
- cls.migrate_db_records("embeddings", "provider_name") # large table
- cls.migrate_db_records("dataset_collection_bindings", "provider_name")
- cls.migrate_db_records("tool_builtin_providers", "provider")
+ cls.migrate_db_records("embeddings", "provider_name", ModelProviderID) # large table
+ cls.migrate_db_records("dataset_collection_bindings", "provider_name", ModelProviderID)
+ cls.migrate_db_records("tool_builtin_providers", "provider", ToolProviderID)
@classmethod
def migrate_datasets(cls) -> None:
@@ -66,9 +66,10 @@ limit 1000"""
fg="white",
)
)
- retrieval_model["reranking_model"]["reranking_provider_name"] = (
- f"{DEFAULT_PLUGIN_ID}/{retrieval_model['reranking_model']['reranking_provider_name']}/{retrieval_model['reranking_model']['reranking_provider_name']}"
- )
+ # update google to langgenius/gemini/google etc.
+ retrieval_model["reranking_model"]["reranking_provider_name"] = ModelProviderID(
+ retrieval_model["reranking_model"]["reranking_provider_name"]
+ ).to_string()
retrieval_model_changed = True
click.echo(
@@ -86,9 +87,11 @@ limit 1000"""
update_retrieval_model_sql = ", retrieval_model = :retrieval_model"
params["retrieval_model"] = json.dumps(retrieval_model)
+ params["provider_name"] = ModelProviderID(provider_name).to_string()
+
sql = f"""update {table_name}
set {provider_column_name} =
- concat('{DEFAULT_PLUGIN_ID}/', {provider_column_name}, '/', {provider_column_name})
+ :provider_name
{update_retrieval_model_sql}
where id = :record_id"""
conn.execute(db.text(sql), params)
@@ -122,7 +125,9 @@ limit 1000"""
)
@classmethod
- def migrate_db_records(cls, table_name: str, provider_column_name: str) -> None:
+ def migrate_db_records(
+ cls, table_name: str, provider_column_name: str, provider_cls: type[GenericProviderID]
+ ) -> None:
click.echo(click.style(f"Migrating [{table_name}] data for plugin", fg="white"))
processed_count = 0
@@ -166,7 +171,8 @@ limit 1000"""
)
try:
- updated_value = f"{DEFAULT_PLUGIN_ID}/{provider_name}/{provider_name}"
+ # update jina to langgenius/jina_tool/jina etc.
+ updated_value = provider_cls(provider_name).to_string()
batch_updates.append((updated_value, record_id))
except Exception as e:
failed_ids.append(record_id)
diff --git a/api/services/plugin/oauth_service.py b/api/services/plugin/oauth_service.py
index 461247419b..b84dd0afc5 100644
--- a/api/services/plugin/oauth_service.py
+++ b/api/services/plugin/oauth_service.py
@@ -1,7 +1,53 @@
+import json
+import uuid
+
from core.plugin.impl.base import BasePluginClient
+from extensions.ext_redis import redis_client
+
+
+class OAuthProxyService(BasePluginClient):
+ # Default max age for proxy context parameter in seconds
+ __MAX_AGE__ = 5 * 60 # 5 minutes
+ __KEY_PREFIX__ = "oauth_proxy_context:"
+
+ @staticmethod
+ def create_proxy_context(user_id: str, tenant_id: str, plugin_id: str, provider: str):
+ """
+ Create a proxy context for an OAuth 2.0 authorization request.
+
+ This parameter is a crucial security measure to prevent Cross-Site Request
+ Forgery (CSRF) attacks. It works by generating a unique nonce and storing it
+ in a distributed cache (Redis) along with the user's session context.
+ The returned nonce should be included as the 'proxy_context' parameter in the
+ authorization URL. Upon callback, the `use_proxy_context` method
+ is used to verify the state, ensuring the request's integrity and authenticity,
+ and mitigating replay attacks.
+ """
+ context_id = str(uuid.uuid4())
+ data = {
+ "user_id": user_id,
+ "plugin_id": plugin_id,
+ "tenant_id": tenant_id,
+ "provider": provider,
+ }
+ redis_client.setex(
+ f"{OAuthProxyService.__KEY_PREFIX__}{context_id}",
+ OAuthProxyService.__MAX_AGE__,
+ json.dumps(data),
+ )
+ return context_id
-class OAuthService(BasePluginClient):
- @classmethod
- def get_authorization_url(cls, tenant_id: str, user_id: str, provider_name: str) -> str:
- return "1234567890"
+ @staticmethod
+ def use_proxy_context(context_id: str):
+ """
+ Validate the proxy context parameter.
+ This checks if the context_id is valid and not expired.
+ """
+ if not context_id:
+ raise ValueError("context_id is required")
+ # get data from redis
+ data = redis_client.getdel(f"{OAuthProxyService.__KEY_PREFIX__}{context_id}")
+ if not data:
+ raise ValueError("context_id is invalid")
+ return json.loads(data)
diff --git a/api/services/plugin/plugin_parameter_service.py b/api/services/plugin/plugin_parameter_service.py
new file mode 100644
index 0000000000..393213c0e2
--- /dev/null
+++ b/api/services/plugin/plugin_parameter_service.py
@@ -0,0 +1,74 @@
+from collections.abc import Mapping, Sequence
+from typing import Any, Literal
+
+from sqlalchemy.orm import Session
+
+from core.plugin.entities.parameters import PluginParameterOption
+from core.plugin.impl.dynamic_select import DynamicSelectClient
+from core.tools.tool_manager import ToolManager
+from core.tools.utils.configuration import ProviderConfigEncrypter
+from extensions.ext_database import db
+from models.tools import BuiltinToolProvider
+
+
+class PluginParameterService:
+ @staticmethod
+ def get_dynamic_select_options(
+ tenant_id: str,
+ user_id: str,
+ plugin_id: str,
+ provider: str,
+ action: str,
+ parameter: str,
+ provider_type: Literal["tool"],
+ ) -> Sequence[PluginParameterOption]:
+ """
+ Get dynamic select options for a plugin parameter.
+
+ Args:
+ tenant_id: The tenant ID.
+ plugin_id: The plugin ID.
+ provider: The provider name.
+ action: The action name.
+ parameter: The parameter name.
+ """
+ credentials: Mapping[str, Any] = {}
+
+ match provider_type:
+ case "tool":
+ provider_controller = ToolManager.get_builtin_provider(provider, tenant_id)
+ # init tool configuration
+ tool_configuration = ProviderConfigEncrypter(
+ tenant_id=tenant_id,
+ config=[x.to_basic_provider_config() for x in provider_controller.get_credentials_schema()],
+ provider_type=provider_controller.provider_type.value,
+ provider_identity=provider_controller.entity.identity.name,
+ )
+
+ # check if credentials are required
+ if not provider_controller.need_credentials:
+ credentials = {}
+ else:
+ # fetch credentials from db
+ with Session(db.engine) as session:
+ db_record = (
+ session.query(BuiltinToolProvider)
+ .filter(
+ BuiltinToolProvider.tenant_id == tenant_id,
+ BuiltinToolProvider.provider == provider,
+ )
+ .first()
+ )
+
+ if db_record is None:
+ raise ValueError(f"Builtin provider {provider} not found when fetching credentials")
+
+ credentials = tool_configuration.decrypt(db_record.credentials)
+ case _:
+ raise ValueError(f"Invalid provider type: {provider_type}")
+
+ return (
+ DynamicSelectClient()
+ .fetch_dynamic_select_options(tenant_id, user_id, plugin_id, provider, action, credentials, parameter)
+ .options
+ )
diff --git a/api/services/plugin/plugin_service.py b/api/services/plugin/plugin_service.py
index a8b64f27db..0f22afd8dd 100644
--- a/api/services/plugin/plugin_service.py
+++ b/api/services/plugin/plugin_service.py
@@ -17,11 +17,18 @@ from core.plugin.entities.plugin import (
PluginInstallation,
PluginInstallationSource,
)
-from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginListResponse, PluginUploadResponse
+from core.plugin.entities.plugin_daemon import (
+ PluginDecodeResponse,
+ PluginInstallTask,
+ PluginListResponse,
+ PluginVerification,
+)
from core.plugin.impl.asset import PluginAssetManager
from core.plugin.impl.debugging import PluginDebuggingClient
from core.plugin.impl.plugin import PluginInstaller
from extensions.ext_redis import redis_client
+from services.errors.plugin import PluginInstallationForbiddenError
+from services.feature_service import FeatureService, PluginInstallationScope
logger = logging.getLogger(__name__)
@@ -86,6 +93,42 @@ class PluginService:
logger.exception("failed to fetch latest plugin version")
return result
+ @staticmethod
+ def _check_marketplace_only_permission():
+ """
+ Check if the marketplace only permission is enabled
+ """
+ features = FeatureService.get_system_features()
+ if features.plugin_installation_permission.restrict_to_marketplace_only:
+ raise PluginInstallationForbiddenError("Plugin installation is restricted to marketplace only")
+
+ @staticmethod
+ def _check_plugin_installation_scope(plugin_verification: Optional[PluginVerification]):
+ """
+ Check the plugin installation scope
+ """
+ features = FeatureService.get_system_features()
+
+ match features.plugin_installation_permission.plugin_installation_scope:
+ case PluginInstallationScope.OFFICIAL_ONLY:
+ if (
+ plugin_verification is None
+ or plugin_verification.authorized_category != PluginVerification.AuthorizedCategory.Langgenius
+ ):
+ raise PluginInstallationForbiddenError("Plugin installation is restricted to official only")
+ case PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS:
+ if plugin_verification is None or plugin_verification.authorized_category not in [
+ PluginVerification.AuthorizedCategory.Langgenius,
+ PluginVerification.AuthorizedCategory.Partner,
+ ]:
+ raise PluginInstallationForbiddenError(
+ "Plugin installation is restricted to official and specific partners"
+ )
+ case PluginInstallationScope.NONE:
+ raise PluginInstallationForbiddenError("Installing plugins is not allowed")
+ case PluginInstallationScope.ALL:
+ pass
+
@staticmethod
def get_debugging_key(tenant_id: str) -> str:
"""
@@ -208,6 +251,8 @@ class PluginService:
# check if plugin pkg is already downloaded
manager = PluginInstaller()
+ features = FeatureService.get_system_features()
+
try:
manager.fetch_plugin_manifest(tenant_id, new_plugin_unique_identifier)
# already downloaded, skip, and record install event
@@ -215,7 +260,14 @@ class PluginService:
except Exception:
# plugin not installed, download and upload pkg
pkg = download_plugin_pkg(new_plugin_unique_identifier)
- manager.upload_pkg(tenant_id, pkg, verify_signature=False)
+ response = manager.upload_pkg(
+ tenant_id,
+ pkg,
+ verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
+ )
+
+ # check if the plugin is available to install
+ PluginService._check_plugin_installation_scope(response.verification)
return manager.upgrade_plugin(
tenant_id,
@@ -239,6 +291,7 @@ class PluginService:
"""
Upgrade plugin with github
"""
+ PluginService._check_marketplace_only_permission()
manager = PluginInstaller()
return manager.upgrade_plugin(
tenant_id,
@@ -253,33 +306,43 @@ class PluginService:
)
@staticmethod
- def upload_pkg(tenant_id: str, pkg: bytes, verify_signature: bool = False) -> PluginUploadResponse:
+ def upload_pkg(tenant_id: str, pkg: bytes, verify_signature: bool = False) -> PluginDecodeResponse:
"""
Upload plugin package files
returns: plugin_unique_identifier
"""
+ PluginService._check_marketplace_only_permission()
manager = PluginInstaller()
- return manager.upload_pkg(tenant_id, pkg, verify_signature)
+ features = FeatureService.get_system_features()
+ response = manager.upload_pkg(
+ tenant_id,
+ pkg,
+ verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
+ )
+ return response
@staticmethod
def upload_pkg_from_github(
tenant_id: str, repo: str, version: str, package: str, verify_signature: bool = False
- ) -> PluginUploadResponse:
+ ) -> PluginDecodeResponse:
"""
Install plugin from github release package files,
returns plugin_unique_identifier
"""
+ PluginService._check_marketplace_only_permission()
pkg = download_with_size_limit(
f"https://github.com/{repo}/releases/download/{version}/{package}", dify_config.PLUGIN_MAX_PACKAGE_SIZE
)
+ features = FeatureService.get_system_features()
manager = PluginInstaller()
- return manager.upload_pkg(
+ response = manager.upload_pkg(
tenant_id,
pkg,
- verify_signature,
+ verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
)
+ return response
@staticmethod
def upload_bundle(
@@ -289,11 +352,15 @@ class PluginService:
Upload a plugin bundle and return the dependencies.
"""
manager = PluginInstaller()
+ PluginService._check_marketplace_only_permission()
return manager.upload_bundle(tenant_id, bundle, verify_signature)
@staticmethod
def install_from_local_pkg(tenant_id: str, plugin_unique_identifiers: Sequence[str]):
+ PluginService._check_marketplace_only_permission()
+
manager = PluginInstaller()
+
return manager.install_from_identifiers(
tenant_id,
plugin_unique_identifiers,
@@ -307,6 +374,8 @@ class PluginService:
Install plugin from github release package files,
returns plugin_unique_identifier
"""
+ PluginService._check_marketplace_only_permission()
+
manager = PluginInstaller()
return manager.install_from_identifiers(
tenant_id,
@@ -322,28 +391,33 @@ class PluginService:
)
@staticmethod
- def fetch_marketplace_pkg(
- tenant_id: str, plugin_unique_identifier: str, verify_signature: bool = False
- ) -> PluginDeclaration:
+ def fetch_marketplace_pkg(tenant_id: str, plugin_unique_identifier: str) -> PluginDeclaration:
"""
Fetch marketplace package
"""
if not dify_config.MARKETPLACE_ENABLED:
raise ValueError("marketplace is not enabled")
+ features = FeatureService.get_system_features()
+
manager = PluginInstaller()
try:
declaration = manager.fetch_plugin_manifest(tenant_id, plugin_unique_identifier)
except Exception:
pkg = download_plugin_pkg(plugin_unique_identifier)
- declaration = manager.upload_pkg(tenant_id, pkg, verify_signature).manifest
+ response = manager.upload_pkg(
+ tenant_id,
+ pkg,
+ verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
+ )
+ # check if the plugin is available to install
+ PluginService._check_plugin_installation_scope(response.verification)
+ declaration = response.manifest
return declaration
@staticmethod
- def install_from_marketplace_pkg(
- tenant_id: str, plugin_unique_identifiers: Sequence[str], verify_signature: bool = False
- ):
+ def install_from_marketplace_pkg(tenant_id: str, plugin_unique_identifiers: Sequence[str]):
"""
Install plugin from marketplace package files,
returns installation task id
@@ -353,26 +427,40 @@ class PluginService:
manager = PluginInstaller()
+ # collect actual plugin_unique_identifiers
+ actual_plugin_unique_identifiers = []
+ metas = []
+ features = FeatureService.get_system_features()
+
# check if already downloaded
for plugin_unique_identifier in plugin_unique_identifiers:
try:
manager.fetch_plugin_manifest(tenant_id, plugin_unique_identifier)
+ plugin_decode_response = manager.decode_plugin_from_identifier(tenant_id, plugin_unique_identifier)
+ # check if the plugin is available to install
+ PluginService._check_plugin_installation_scope(plugin_decode_response.verification)
# already downloaded, skip
+ actual_plugin_unique_identifiers.append(plugin_unique_identifier)
+ metas.append({"plugin_unique_identifier": plugin_unique_identifier})
except Exception:
# plugin not installed, download and upload pkg
pkg = download_plugin_pkg(plugin_unique_identifier)
- manager.upload_pkg(tenant_id, pkg, verify_signature)
+ response = manager.upload_pkg(
+ tenant_id,
+ pkg,
+ verify_signature=features.plugin_installation_permission.restrict_to_marketplace_only,
+ )
+ # check if the plugin is available to install
+ PluginService._check_plugin_installation_scope(response.verification)
+ # use response plugin_unique_identifier
+ actual_plugin_unique_identifiers.append(response.unique_identifier)
+ metas.append({"plugin_unique_identifier": response.unique_identifier})
return manager.install_from_identifiers(
tenant_id,
- plugin_unique_identifiers,
+ actual_plugin_unique_identifiers,
PluginInstallationSource.Marketplace,
- [
- {
- "plugin_unique_identifier": plugin_unique_identifier,
- }
- for plugin_unique_identifier in plugin_unique_identifiers
- ],
+ metas,
)
@staticmethod
diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py
index 3ccd14415d..58a4b2f179 100644
--- a/api/services/tools/builtin_tools_manage_service.py
+++ b/api/services/tools/builtin_tools_manage_service.py
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
from configs import dify_config
from core.helper.position_helper import is_filtered
from core.model_runtime.utils.encoders import jsonable_encoder
-from core.plugin.entities.plugin import GenericProviderID, ToolProviderID
+from core.plugin.entities.plugin import ToolProviderID
from core.plugin.impl.exc import PluginDaemonClientSideError
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity
@@ -290,7 +290,7 @@ class BuiltinToolManageService:
def _fetch_builtin_provider(provider_name: str, tenant_id: str) -> BuiltinToolProvider | None:
try:
full_provider_name = provider_name
- provider_id_entity = GenericProviderID(provider_name)
+ provider_id_entity = ToolProviderID(provider_name)
provider_name = provider_id_entity.provider_name
if provider_id_entity.organization != "langgenius":
provider_obj = (
@@ -315,7 +315,7 @@ class BuiltinToolManageService:
if provider_obj is None:
return None
- provider_obj.provider = GenericProviderID(provider_obj.provider).to_string()
+ provider_obj.provider = ToolProviderID(provider_obj.provider).to_string()
return provider_obj
except Exception:
# it's an old provider without organization
diff --git a/api/services/tools/mcp_tools_mange_service.py b/api/services/tools/mcp_tools_mange_service.py
new file mode 100644
index 0000000000..7c23abda4b
--- /dev/null
+++ b/api/services/tools/mcp_tools_mange_service.py
@@ -0,0 +1,231 @@
+import hashlib
+import json
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy import or_
+from sqlalchemy.exc import IntegrityError
+
+from core.helper import encrypter
+from core.mcp.error import MCPAuthError, MCPError
+from core.mcp.mcp_client import MCPClient
+from core.tools.entities.api_entities import ToolProviderApiEntity
+from core.tools.entities.common_entities import I18nObject
+from core.tools.entities.tool_entities import ToolProviderType
+from core.tools.mcp_tool.provider import MCPToolProviderController
+from core.tools.utils.configuration import ProviderConfigEncrypter
+from extensions.ext_database import db
+from models.tools import MCPToolProvider
+from services.tools.tools_transform_service import ToolTransformService
+
+UNCHANGED_SERVER_URL_PLACEHOLDER = "[__HIDDEN__]"
+
+
+class MCPToolManageService:
+ """
+ Service class for managing mcp tools.
+ """
+
+ @staticmethod
+ def get_mcp_provider_by_provider_id(provider_id: str, tenant_id: str) -> MCPToolProvider:
+ res = (
+ db.session.query(MCPToolProvider)
+ .filter(MCPToolProvider.tenant_id == tenant_id, MCPToolProvider.id == provider_id)
+ .first()
+ )
+ if not res:
+ raise ValueError("MCP tool not found")
+ return res
+
+ @staticmethod
+ def get_mcp_provider_by_server_identifier(server_identifier: str, tenant_id: str) -> MCPToolProvider:
+ res = (
+ db.session.query(MCPToolProvider)
+ .filter(MCPToolProvider.tenant_id == tenant_id, MCPToolProvider.server_identifier == server_identifier)
+ .first()
+ )
+ if not res:
+ raise ValueError("MCP tool not found")
+ return res
+
+ @staticmethod
+ def create_mcp_provider(
+ tenant_id: str,
+ name: str,
+ server_url: str,
+ user_id: str,
+ icon: str,
+ icon_type: str,
+ icon_background: str,
+ server_identifier: str,
+ ) -> ToolProviderApiEntity:
+ server_url_hash = hashlib.sha256(server_url.encode()).hexdigest()
+ existing_provider = (
+ db.session.query(MCPToolProvider)
+ .filter(
+ MCPToolProvider.tenant_id == tenant_id,
+ or_(
+ MCPToolProvider.name == name,
+ MCPToolProvider.server_url_hash == server_url_hash,
+ MCPToolProvider.server_identifier == server_identifier,
+ ),
+ )
+ .first()
+ )
+ if existing_provider:
+ if existing_provider.name == name:
+ raise ValueError(f"MCP tool {name} already exists")
+ elif existing_provider.server_url_hash == server_url_hash:
+ raise ValueError(f"MCP tool {server_url} already exists")
+ elif existing_provider.server_identifier == server_identifier:
+ raise ValueError(f"MCP tool {server_identifier} already exists")
+ encrypted_server_url = encrypter.encrypt_token(tenant_id, server_url)
+ mcp_tool = MCPToolProvider(
+ tenant_id=tenant_id,
+ name=name,
+ server_url=encrypted_server_url,
+ server_url_hash=server_url_hash,
+ user_id=user_id,
+ authed=False,
+ tools="[]",
+ icon=json.dumps({"content": icon, "background": icon_background}) if icon_type == "emoji" else icon,
+ server_identifier=server_identifier,
+ )
+ db.session.add(mcp_tool)
+ db.session.commit()
+ return ToolTransformService.mcp_provider_to_user_provider(mcp_tool, for_list=True)
+
+ @staticmethod
+ def retrieve_mcp_tools(tenant_id: str, for_list: bool = False) -> list[ToolProviderApiEntity]:
+ mcp_providers = (
+ db.session.query(MCPToolProvider)
+ .filter(MCPToolProvider.tenant_id == tenant_id)
+ .order_by(MCPToolProvider.name)
+ .all()
+ )
+ return [
+ ToolTransformService.mcp_provider_to_user_provider(mcp_provider, for_list=for_list)
+ for mcp_provider in mcp_providers
+ ]
+
+ @classmethod
+ def list_mcp_tool_from_remote_server(cls, tenant_id: str, provider_id: str):
+ mcp_provider = cls.get_mcp_provider_by_provider_id(provider_id, tenant_id)
+
+ try:
+ with MCPClient(
+ mcp_provider.decrypted_server_url, provider_id, tenant_id, authed=mcp_provider.authed, for_list=True
+ ) as mcp_client:
+ tools = mcp_client.list_tools()
+ except MCPAuthError as e:
+ raise ValueError("Please auth the tool first")
+ except MCPError as e:
+ raise ValueError(f"Failed to connect to MCP server: {e}")
+ mcp_provider.tools = json.dumps([tool.model_dump() for tool in tools])
+ mcp_provider.authed = True
+ mcp_provider.updated_at = datetime.now()
+ db.session.commit()
+ user = mcp_provider.load_user()
+ return ToolProviderApiEntity(
+ id=mcp_provider.id,
+ name=mcp_provider.name,
+ tools=ToolTransformService.mcp_tool_to_user_tool(mcp_provider, tools),
+ type=ToolProviderType.MCP,
+ icon=mcp_provider.icon,
+ author=user.name if user else "Anonymous",
+ server_url=mcp_provider.masked_server_url,
+ updated_at=int(mcp_provider.updated_at.timestamp()),
+ description=I18nObject(en_US="", zh_Hans=""),
+ label=I18nObject(en_US=mcp_provider.name, zh_Hans=mcp_provider.name),
+ plugin_unique_identifier=mcp_provider.server_identifier,
+ )
+
+ @classmethod
+ def delete_mcp_tool(cls, tenant_id: str, provider_id: str):
+ mcp_tool = cls.get_mcp_provider_by_provider_id(provider_id, tenant_id)
+
+ db.session.delete(mcp_tool)
+ db.session.commit()
+
+ @classmethod
+ def update_mcp_provider(
+ cls,
+ tenant_id: str,
+ provider_id: str,
+ name: str,
+ server_url: str,
+ icon: str,
+ icon_type: str,
+ icon_background: str,
+ server_identifier: str,
+ ):
+ mcp_provider = cls.get_mcp_provider_by_provider_id(provider_id, tenant_id)
+ mcp_provider.updated_at = datetime.now()
+ mcp_provider.name = name
+ mcp_provider.icon = (
+ json.dumps({"content": icon, "background": icon_background}) if icon_type == "emoji" else icon
+ )
+ mcp_provider.server_identifier = server_identifier
+
+ if UNCHANGED_SERVER_URL_PLACEHOLDER not in server_url:
+ encrypted_server_url = encrypter.encrypt_token(tenant_id, server_url)
+ mcp_provider.server_url = encrypted_server_url
+ server_url_hash = hashlib.sha256(server_url.encode()).hexdigest()
+
+ if server_url_hash != mcp_provider.server_url_hash:
+ cls._re_connect_mcp_provider(mcp_provider, provider_id, tenant_id)
+ mcp_provider.server_url_hash = server_url_hash
+ try:
+ db.session.commit()
+ except IntegrityError as e:
+ db.session.rollback()
+ error_msg = str(e.orig)
+ if "unique_mcp_provider_name" in error_msg:
+ raise ValueError(f"MCP tool {name} already exists")
+ elif "unique_mcp_provider_server_url" in error_msg:
+ raise ValueError(f"MCP tool {server_url} already exists")
+ elif "unique_mcp_provider_server_identifier" in error_msg:
+ raise ValueError(f"MCP tool {server_identifier} already exists")
+ else:
+ raise
+
+ @classmethod
+ def update_mcp_provider_credentials(
+ cls, mcp_provider: MCPToolProvider, credentials: dict[str, Any], authed: bool = False
+ ):
+ provider_controller = MCPToolProviderController._from_db(mcp_provider)
+ tool_configuration = ProviderConfigEncrypter(
+ tenant_id=mcp_provider.tenant_id,
+ config=list(provider_controller.get_credentials_schema()),
+ provider_type=provider_controller.provider_type.value,
+ provider_identity=provider_controller.provider_id,
+ )
+ credentials = tool_configuration.encrypt(credentials)
+ mcp_provider.updated_at = datetime.now()
+ mcp_provider.encrypted_credentials = json.dumps({**mcp_provider.credentials, **credentials})
+ mcp_provider.authed = authed
+ if not authed:
+ mcp_provider.tools = "[]"
+ db.session.commit()
+
+ @classmethod
+ def _re_connect_mcp_provider(cls, mcp_provider: MCPToolProvider, provider_id: str, tenant_id: str):
+ """re-connect mcp provider"""
+ try:
+ with MCPClient(
+ mcp_provider.decrypted_server_url,
+ provider_id,
+ tenant_id,
+ authed=False,
+ for_list=True,
+ ) as mcp_client:
+ tools = mcp_client.list_tools()
+ mcp_provider.authed = True
+ mcp_provider.tools = json.dumps([tool.model_dump() for tool in tools])
+ except MCPAuthError:
+ mcp_provider.authed = False
+ mcp_provider.tools = "[]"
+ except MCPError as e:
+ raise ValueError(f"Failed to re-connect MCP server: {e}") from e
+ # reset credentials
+ mcp_provider.encrypted_credentials = "{}"
diff --git a/api/services/tools/tools_transform_service.py b/api/services/tools/tools_transform_service.py
index 367121125b..3d0c35cd9b 100644
--- a/api/services/tools/tools_transform_service.py
+++ b/api/services/tools/tools_transform_service.py
@@ -1,10 +1,11 @@
import json
import logging
-from typing import Optional, Union, cast
+from typing import Any, Optional, Union, cast
from yarl import URL
from configs import dify_config
+from core.mcp.types import Tool as MCPTool
from core.tools.__base.tool import Tool
from core.tools.__base.tool_runtime import ToolRuntime
from core.tools.builtin_tool.provider import BuiltinToolProviderController
@@ -21,7 +22,7 @@ from core.tools.plugin_tool.provider import PluginToolProviderController
from core.tools.utils.configuration import ProviderConfigEncrypter
from core.tools.workflow_as_tool.provider import WorkflowToolProviderController
from core.tools.workflow_as_tool.tool import WorkflowTool
-from models.tools import ApiToolProvider, BuiltinToolProvider, WorkflowToolProvider
+from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider
logger = logging.getLogger(__name__)
@@ -52,7 +53,8 @@ class ToolTransformService:
return icon
except Exception:
return {"background": "#252525", "content": "\ud83d\ude01"}
-
+ elif provider_type == ToolProviderType.MCP.value:
+ return icon
return ""
@staticmethod
@@ -73,10 +75,18 @@ class ToolTransformService:
provider.icon = ToolTransformService.get_plugin_icon_url(
tenant_id=tenant_id, filename=provider.icon
)
+ if isinstance(provider.icon_dark, str) and provider.icon_dark:
+ provider.icon_dark = ToolTransformService.get_plugin_icon_url(
+ tenant_id=tenant_id, filename=provider.icon_dark
+ )
else:
provider.icon = ToolTransformService.get_tool_provider_icon_url(
provider_type=provider.type.value, provider_name=provider.name, icon=provider.icon
)
+ if provider.icon_dark:
+ provider.icon_dark = ToolTransformService.get_tool_provider_icon_url(
+ provider_type=provider.type.value, provider_name=provider.name, icon=provider.icon_dark
+ )
@classmethod
def builtin_provider_to_user_provider(
@@ -94,6 +104,7 @@ class ToolTransformService:
name=provider_controller.entity.identity.name,
description=provider_controller.entity.identity.description,
icon=provider_controller.entity.identity.icon,
+ icon_dark=provider_controller.entity.identity.icon_dark,
label=provider_controller.entity.identity.label,
type=ToolProviderType.BUILT_IN,
masked_credentials={},
@@ -148,11 +159,16 @@ class ToolTransformService:
convert provider controller to user provider
"""
# package tool provider controller
+ auth_type = ApiProviderAuthType.NONE
+ credentials_auth_type = db_provider.credentials.get("auth_type")
+ if credentials_auth_type in ("api_key_header", "api_key"): # backward compatibility
+ auth_type = ApiProviderAuthType.API_KEY_HEADER
+ elif credentials_auth_type == "api_key_query":
+ auth_type = ApiProviderAuthType.API_KEY_QUERY
+
controller = ApiToolProviderController.from_db(
db_provider=db_provider,
- auth_type=ApiProviderAuthType.API_KEY
- if db_provider.credentials["auth_type"] == "api_key"
- else ApiProviderAuthType.NONE,
+ auth_type=auth_type,
)
return controller
@@ -177,6 +193,7 @@ class ToolTransformService:
name=provider_controller.entity.identity.name,
description=provider_controller.entity.identity.description,
icon=provider_controller.entity.identity.icon,
+ icon_dark=provider_controller.entity.identity.icon_dark,
label=provider_controller.entity.identity.label,
type=ToolProviderType.WORKFLOW,
masked_credentials={},
@@ -187,6 +204,41 @@ class ToolTransformService:
labels=labels or [],
)
+ @staticmethod
+ def mcp_provider_to_user_provider(db_provider: MCPToolProvider, for_list: bool = False) -> ToolProviderApiEntity:
+ user = db_provider.load_user()
+ return ToolProviderApiEntity(
+ id=db_provider.server_identifier if not for_list else db_provider.id,
+ author=user.name if user else "Anonymous",
+ name=db_provider.name,
+ icon=db_provider.provider_icon,
+ type=ToolProviderType.MCP,
+ is_team_authorization=db_provider.authed,
+ server_url=db_provider.masked_server_url,
+ tools=ToolTransformService.mcp_tool_to_user_tool(
+ db_provider, [MCPTool(**tool) for tool in json.loads(db_provider.tools)]
+ ),
+ updated_at=int(db_provider.updated_at.timestamp()),
+ label=I18nObject(en_US=db_provider.name, zh_Hans=db_provider.name),
+ description=I18nObject(en_US="", zh_Hans=""),
+ server_identifier=db_provider.server_identifier,
+ )
+
+ @staticmethod
+ def mcp_tool_to_user_tool(mcp_provider: MCPToolProvider, tools: list[MCPTool]) -> list[ToolApiEntity]:
+ user = mcp_provider.load_user()
+ return [
+ ToolApiEntity(
+ author=user.name if user else "Anonymous",
+ name=tool.name,
+ label=I18nObject(en_US=tool.name, zh_Hans=tool.name),
+ description=I18nObject(en_US=tool.description, zh_Hans=tool.description),
+ parameters=ToolTransformService.convert_mcp_schema_to_parameter(tool.inputSchema),
+ labels=[],
+ )
+ for tool in tools
+ ]
+
@classmethod
def api_provider_to_user_provider(
cls,
@@ -304,3 +356,53 @@ class ToolTransformService:
parameters=tool.parameters,
labels=labels or [],
)
+
+ @staticmethod
+ def convert_mcp_schema_to_parameter(schema: dict) -> list["ToolParameter"]:
+ """
+ Convert MCP JSON schema to tool parameters
+
+ :param schema: JSON schema dictionary
+ :return: list of ToolParameter instances
+ """
+
+ def create_parameter(
+ name: str, description: str, param_type: str, required: bool, input_schema: dict | None = None
+ ) -> ToolParameter:
+ """Create a ToolParameter instance with given attributes"""
+ input_schema_dict: dict[str, Any] = {"input_schema": input_schema} if input_schema else {}
+ return ToolParameter(
+ name=name,
+ llm_description=description,
+ label=I18nObject(en_US=name),
+ form=ToolParameter.ToolParameterForm.LLM,
+ required=required,
+ type=ToolParameter.ToolParameterType(param_type),
+ human_description=I18nObject(en_US=description),
+ **input_schema_dict,
+ )
+
+ def process_properties(props: dict, required: list, prefix: str = "") -> list[ToolParameter]:
+ """Process properties recursively"""
+ TYPE_MAPPING = {"integer": "number", "float": "number"}
+ COMPLEX_TYPES = ["array", "object"]
+
+ parameters = []
+ for name, prop in props.items():
+ current_description = prop.get("description", "")
+ prop_type = prop.get("type", "string")
+
+ if isinstance(prop_type, list):
+ prop_type = prop_type[0]
+ if prop_type in TYPE_MAPPING:
+ prop_type = TYPE_MAPPING[prop_type]
+ input_schema = prop if prop_type in COMPLEX_TYPES else None
+ parameters.append(
+ create_parameter(name, current_description, prop_type, name in required, input_schema)
+ )
+
+ return parameters
+
+ if schema.get("type") == "object" and "properties" in schema:
+ return process_properties(schema["properties"], schema.get("required", []))
+ return []
diff --git a/api/services/vector_service.py b/api/services/vector_service.py
index 19e37f4ee3..9165139193 100644
--- a/api/services/vector_service.py
+++ b/api/services/vector_service.py
@@ -97,16 +97,16 @@ class VectorService:
vector = Vector(dataset=dataset)
vector.delete_by_ids([segment.index_node_id])
vector.add_texts([document], duplicate_check=True)
-
- # update keyword index
- keyword = Keyword(dataset)
- keyword.delete_by_ids([segment.index_node_id])
-
- # save keyword index
- if keywords and len(keywords) > 0:
- keyword.add_texts([document], keywords_list=[keywords])
else:
- keyword.add_texts([document])
+ # update keyword index
+ keyword = Keyword(dataset)
+ keyword.delete_by_ids([segment.index_node_id])
+
+ # save keyword index
+ if keywords and len(keywords) > 0:
+ keyword.add_texts([document], keywords_list=[keywords])
+ else:
+ keyword.add_texts([document])
@classmethod
def generate_child_chunks(
diff --git a/api/services/workflow_app_service.py b/api/services/workflow_app_service.py
index 6b30a70372..6eabf03018 100644
--- a/api/services/workflow_app_service.py
+++ b/api/services/workflow_app_service.py
@@ -5,7 +5,7 @@ from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
from core.workflow.entities.workflow_execution import WorkflowExecutionStatus
-from models import App, EndUser, WorkflowAppLog, WorkflowRun
+from models import Account, App, EndUser, WorkflowAppLog, WorkflowRun
from models.enums import CreatorUserRole
@@ -21,6 +21,8 @@ class WorkflowAppService:
created_at_after: datetime | None = None,
page: int = 1,
limit: int = 20,
+ created_by_end_user_session_id: str | None = None,
+ created_by_account: str | None = None,
) -> dict:
"""
Get paginate workflow app logs using SQLAlchemy 2.0 style
@@ -32,6 +34,8 @@ class WorkflowAppService:
:param created_at_after: filter logs created after this timestamp
:param page: page number
:param limit: items per page
+ :param created_by_end_user_session_id: filter by end user session id
+ :param created_by_account: filter by account email
:return: Pagination object
"""
# Build base statement using SQLAlchemy 2.0 style
@@ -71,6 +75,26 @@ class WorkflowAppService:
if created_at_after:
stmt = stmt.where(WorkflowAppLog.created_at >= created_at_after)
+ # Filter by end user session id or account email
+ if created_by_end_user_session_id:
+ stmt = stmt.join(
+ EndUser,
+ and_(
+ WorkflowAppLog.created_by == EndUser.id,
+ WorkflowAppLog.created_by_role == CreatorUserRole.END_USER,
+ EndUser.session_id == created_by_end_user_session_id,
+ ),
+ )
+ if created_by_account:
+ stmt = stmt.join(
+ Account,
+ and_(
+ WorkflowAppLog.created_by == Account.id,
+ WorkflowAppLog.created_by_role == CreatorUserRole.ACCOUNT,
+ Account.email == created_by_account,
+ ),
+ )
+
stmt = stmt.order_by(WorkflowAppLog.created_at.desc())
# Get total count using the same filters
diff --git a/api/services/workflow_draft_variable_service.py b/api/services/workflow_draft_variable_service.py
new file mode 100644
index 0000000000..f306e1f062
--- /dev/null
+++ b/api/services/workflow_draft_variable_service.py
@@ -0,0 +1,746 @@
+import dataclasses
+import datetime
+import logging
+from collections.abc import Mapping, Sequence
+from enum import StrEnum
+from typing import Any, ClassVar
+
+from sqlalchemy import Engine, orm
+from sqlalchemy.dialects.postgresql import insert
+from sqlalchemy.orm import Session, sessionmaker
+from sqlalchemy.sql.expression import and_, or_
+
+from core.app.entities.app_invoke_entities import InvokeFrom
+from core.file.models import File
+from core.variables import Segment, StringSegment, Variable
+from core.variables.consts import MIN_SELECTORS_LENGTH
+from core.variables.segments import ArrayFileSegment, FileSegment
+from core.variables.types import SegmentType
+from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
+from core.workflow.enums import SystemVariableKey
+from core.workflow.nodes import NodeType
+from core.workflow.nodes.variable_assigner.common.helpers import get_updated_variables
+from core.workflow.variable_loader import VariableLoader
+from factories.file_factory import StorageKeyLoader
+from factories.variable_factory import build_segment, segment_to_variable
+from models import App, Conversation
+from models.enums import DraftVariableType
+from models.workflow import Workflow, WorkflowDraftVariable, is_system_variable_editable
+from repositories.factory import DifyAPIRepositoryFactory
+
+_logger = logging.getLogger(__name__)
+
+
+@dataclasses.dataclass(frozen=True)
+class WorkflowDraftVariableList:
+ variables: list[WorkflowDraftVariable]
+ total: int | None = None
+
+
+class WorkflowDraftVariableError(Exception):
+ pass
+
+
+class VariableResetError(WorkflowDraftVariableError):
+ pass
+
+
+class UpdateNotSupportedError(WorkflowDraftVariableError):
+ pass
+
+
+class DraftVarLoader(VariableLoader):
+ # This implements the VariableLoader interface for loading draft variables.
+ #
+ # ref: core.workflow.variable_loader.VariableLoader
+
+ # Database engine used for loading variables.
+ _engine: Engine
+ # Application ID for which variables are being loaded.
+ _app_id: str
+ _tenant_id: str
+ _fallback_variables: Sequence[Variable]
+
+ def __init__(
+ self,
+ engine: Engine,
+ app_id: str,
+ tenant_id: str,
+ fallback_variables: Sequence[Variable] | None = None,
+ ) -> None:
+ self._engine = engine
+ self._app_id = app_id
+ self._tenant_id = tenant_id
+ self._fallback_variables = fallback_variables or []
+
+ def _selector_to_tuple(self, selector: Sequence[str]) -> tuple[str, str]:
+ return (selector[0], selector[1])
+
+ def load_variables(self, selectors: list[list[str]]) -> list[Variable]:
+ if not selectors:
+ return []
+
+ # Map each selector (as a tuple via `_selector_to_tuple`) to its corresponding Variable instance.
+ variable_by_selector: dict[tuple[str, str], Variable] = {}
+
+ with Session(bind=self._engine, expire_on_commit=False) as session:
+ srv = WorkflowDraftVariableService(session)
+ draft_vars = srv.get_draft_variables_by_selectors(self._app_id, selectors)
+
+ for draft_var in draft_vars:
+ segment = draft_var.get_value()
+ variable = segment_to_variable(
+ segment=segment,
+ selector=draft_var.get_selector(),
+ id=draft_var.id,
+ name=draft_var.name,
+ description=draft_var.description,
+ )
+ selector_tuple = self._selector_to_tuple(variable.selector)
+ variable_by_selector[selector_tuple] = variable
+
+ # Important:
+ files: list[File] = []
+ for draft_var in draft_vars:
+ value = draft_var.get_value()
+ if isinstance(value, FileSegment):
+ files.append(value.value)
+ elif isinstance(value, ArrayFileSegment):
+ files.extend(value.value)
+ with Session(bind=self._engine) as session:
+ storage_key_loader = StorageKeyLoader(session, tenant_id=self._tenant_id)
+ storage_key_loader.load_storage_keys(files)
+
+ return list(variable_by_selector.values())
+
+
+class WorkflowDraftVariableService:
+ _session: Session
+
+ def __init__(self, session: Session) -> None:
+ """
+ Initialize the WorkflowDraftVariableService with a SQLAlchemy session.
+
+ Args:
+ session (Session): The SQLAlchemy session used to execute database queries.
+ The provided session must be bound to an `Engine` object, not a specific `Connection`.
+
+ Raises:
+ AssertionError: If the provided session is not bound to an `Engine` object.
+ """
+ self._session = session
+ engine = session.get_bind()
+ # Ensure the session is bound to a engine.
+ assert isinstance(engine, Engine)
+ session_maker = sessionmaker(bind=engine, expire_on_commit=False)
+ self._api_node_execution_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
+ session_maker
+ )
+
+ def get_variable(self, variable_id: str) -> WorkflowDraftVariable | None:
+ return self._session.query(WorkflowDraftVariable).filter(WorkflowDraftVariable.id == variable_id).first()
+
+ def get_draft_variables_by_selectors(
+ self,
+ app_id: str,
+ selectors: Sequence[list[str]],
+ ) -> list[WorkflowDraftVariable]:
+ ors = []
+ for selector in selectors:
+ assert len(selector) >= MIN_SELECTORS_LENGTH, f"Invalid selector to get: {selector}"
+ node_id, name = selector[:2]
+ ors.append(and_(WorkflowDraftVariable.node_id == node_id, WorkflowDraftVariable.name == name))
+
+ # NOTE(QuantumGhost): Although the number of `or` expressions may be large, as long as
+ # each expression includes conditions on both `node_id` and `name` (which are covered by the unique index),
+ # PostgreSQL can efficiently retrieve the results using a bitmap index scan.
+ #
+ # Alternatively, a `SELECT` statement could be constructed for each selector and
+ # combined using `UNION` to fetch all rows.
+ # Benchmarking indicates that both approaches yield comparable performance.
+ variables = (
+ self._session.query(WorkflowDraftVariable).where(WorkflowDraftVariable.app_id == app_id, or_(*ors)).all()
+ )
+ return variables
+
+ def list_variables_without_values(self, app_id: str, page: int, limit: int) -> WorkflowDraftVariableList:
+ criteria = WorkflowDraftVariable.app_id == app_id
+ total = None
+ query = self._session.query(WorkflowDraftVariable).filter(criteria)
+ if page == 1:
+ total = query.count()
+ variables = (
+ # Do not load the `value` field.
+ query.options(orm.defer(WorkflowDraftVariable.value))
+ .order_by(WorkflowDraftVariable.created_at.desc())
+ .limit(limit)
+ .offset((page - 1) * limit)
+ .all()
+ )
+
+ return WorkflowDraftVariableList(variables=variables, total=total)
+
+ def _list_node_variables(self, app_id: str, node_id: str) -> WorkflowDraftVariableList:
+ criteria = (
+ WorkflowDraftVariable.app_id == app_id,
+ WorkflowDraftVariable.node_id == node_id,
+ )
+ query = self._session.query(WorkflowDraftVariable).filter(*criteria)
+ variables = query.order_by(WorkflowDraftVariable.created_at.desc()).all()
+ return WorkflowDraftVariableList(variables=variables)
+
+ def list_node_variables(self, app_id: str, node_id: str) -> WorkflowDraftVariableList:
+ return self._list_node_variables(app_id, node_id)
+
+ def list_conversation_variables(self, app_id: str) -> WorkflowDraftVariableList:
+ return self._list_node_variables(app_id, CONVERSATION_VARIABLE_NODE_ID)
+
+ def list_system_variables(self, app_id: str) -> WorkflowDraftVariableList:
+ return self._list_node_variables(app_id, SYSTEM_VARIABLE_NODE_ID)
+
+ def get_conversation_variable(self, app_id: str, name: str) -> WorkflowDraftVariable | None:
+ return self._get_variable(app_id=app_id, node_id=CONVERSATION_VARIABLE_NODE_ID, name=name)
+
+ def get_system_variable(self, app_id: str, name: str) -> WorkflowDraftVariable | None:
+ return self._get_variable(app_id=app_id, node_id=SYSTEM_VARIABLE_NODE_ID, name=name)
+
+ def get_node_variable(self, app_id: str, node_id: str, name: str) -> WorkflowDraftVariable | None:
+ return self._get_variable(app_id, node_id, name)
+
+ def _get_variable(self, app_id: str, node_id: str, name: str) -> WorkflowDraftVariable | None:
+ variable = (
+ self._session.query(WorkflowDraftVariable)
+ .where(
+ WorkflowDraftVariable.app_id == app_id,
+ WorkflowDraftVariable.node_id == node_id,
+ WorkflowDraftVariable.name == name,
+ )
+ .first()
+ )
+ return variable
+
+ def update_variable(
+ self,
+ variable: WorkflowDraftVariable,
+ name: str | None = None,
+ value: Segment | None = None,
+ ) -> WorkflowDraftVariable:
+ if not variable.editable:
+ raise UpdateNotSupportedError(f"variable not support updating, id={variable.id}")
+ if name is not None:
+ variable.set_name(name)
+ if value is not None:
+ variable.set_value(value)
+ variable.last_edited_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
+ self._session.flush()
+ return variable
+
+ def _reset_conv_var(self, workflow: Workflow, variable: WorkflowDraftVariable) -> WorkflowDraftVariable | None:
+ conv_var_by_name = {i.name: i for i in workflow.conversation_variables}
+ conv_var = conv_var_by_name.get(variable.name)
+
+ if conv_var is None:
+ self._session.delete(instance=variable)
+ self._session.flush()
+ _logger.warning(
+ "Conversation variable not found for draft variable, id=%s, name=%s", variable.id, variable.name
+ )
+ return None
+
+ variable.set_value(conv_var)
+ variable.last_edited_at = None
+ self._session.add(variable)
+ self._session.flush()
+ return variable
+
+ def _reset_node_var_or_sys_var(
+ self, workflow: Workflow, variable: WorkflowDraftVariable
+ ) -> WorkflowDraftVariable | None:
+ # If a variable does not allow updating, it makes no sence to resetting it.
+ if not variable.editable:
+ return variable
+ # No execution record for this variable, delete the variable instead.
+ if variable.node_execution_id is None:
+ self._session.delete(instance=variable)
+ self._session.flush()
+ _logger.warning("draft variable has no node_execution_id, id=%s, name=%s", variable.id, variable.name)
+ return None
+
+ node_exec = self._api_node_execution_repo.get_execution_by_id(variable.node_execution_id)
+ if node_exec is None:
+ _logger.warning(
+ "Node exectution not found for draft variable, id=%s, name=%s, node_execution_id=%s",
+ variable.id,
+ variable.name,
+ variable.node_execution_id,
+ )
+ self._session.delete(instance=variable)
+ self._session.flush()
+ return None
+
+ outputs_dict = node_exec.outputs_dict or {}
+ # a sentinel value used to check the absent of the output variable key.
+ absent = object()
+
+ if variable.get_variable_type() == DraftVariableType.NODE:
+ # Get node type for proper value extraction
+ node_config = workflow.get_node_config_by_id(variable.node_id)
+ node_type = workflow.get_node_type_from_node_config(node_config)
+
+ # Note: Based on the implementation in `_build_from_variable_assigner_mapping`,
+ # VariableAssignerNode (both v1 and v2) can only create conversation draft variables.
+ # For consistency, we should simply return when processing VARIABLE_ASSIGNER nodes.
+ #
+ # This implementation must remain synchronized with the `_build_from_variable_assigner_mapping`
+ # and `save` methods.
+ if node_type == NodeType.VARIABLE_ASSIGNER:
+ return variable
+ output_value = outputs_dict.get(variable.name, absent)
+ else:
+ output_value = outputs_dict.get(f"sys.{variable.name}", absent)
+
+ # We cannot use `is None` to check the existence of an output variable here as
+ # the value of the output may be `None`.
+ if output_value is absent:
+ # If variable not found in execution data, delete the variable
+ self._session.delete(instance=variable)
+ self._session.flush()
+ return None
+ value_seg = WorkflowDraftVariable.build_segment_with_type(variable.value_type, output_value)
+ # Extract variable value using unified logic
+ variable.set_value(value_seg)
+ variable.last_edited_at = None # Reset to indicate this is a reset operation
+ self._session.flush()
+ return variable
+
+ def reset_variable(self, workflow: Workflow, variable: WorkflowDraftVariable) -> WorkflowDraftVariable | None:
+ variable_type = variable.get_variable_type()
+ if variable_type == DraftVariableType.SYS and not is_system_variable_editable(variable.name):
+ raise VariableResetError(f"cannot reset system variable, variable_id={variable.id}")
+ if variable_type == DraftVariableType.CONVERSATION:
+ return self._reset_conv_var(workflow, variable)
+ else:
+ return self._reset_node_var_or_sys_var(workflow, variable)
+
+ def delete_variable(self, variable: WorkflowDraftVariable):
+ self._session.delete(variable)
+
+ def delete_workflow_variables(self, app_id: str):
+ (
+ self._session.query(WorkflowDraftVariable)
+ .filter(WorkflowDraftVariable.app_id == app_id)
+ .delete(synchronize_session=False)
+ )
+
+ def delete_node_variables(self, app_id: str, node_id: str):
+ return self._delete_node_variables(app_id, node_id)
+
+ def _delete_node_variables(self, app_id: str, node_id: str):
+ self._session.query(WorkflowDraftVariable).where(
+ WorkflowDraftVariable.app_id == app_id,
+ WorkflowDraftVariable.node_id == node_id,
+ ).delete()
+
+ def _get_conversation_id_from_draft_variable(self, app_id: str) -> str | None:
+ draft_var = self._get_variable(
+ app_id=app_id,
+ node_id=SYSTEM_VARIABLE_NODE_ID,
+ name=str(SystemVariableKey.CONVERSATION_ID),
+ )
+ if draft_var is None:
+ return None
+ segment = draft_var.get_value()
+ if not isinstance(segment, StringSegment):
+ _logger.warning(
+ "sys.conversation_id variable is not a string: app_id=%s, id=%s",
+ app_id,
+ draft_var.id,
+ )
+ return None
+ return segment.value
+
+ def get_or_create_conversation(
+ self,
+ account_id: str,
+ app: App,
+ workflow: Workflow,
+ ) -> str:
+ """
+ get_or_create_conversation creates and returns the ID of a conversation for debugging.
+
+ If a conversation already exists, as determined by the following criteria, its ID is returned:
+ - The system variable `sys.conversation_id` exists in the draft variable table, and
+ - A corresponding conversation record is found in the database.
+
+ If no such conversation exists, a new conversation is created and its ID is returned.
+ """
+ conv_id = self._get_conversation_id_from_draft_variable(workflow.app_id)
+
+ if conv_id is not None:
+ conversation = (
+ self._session.query(Conversation)
+ .filter(
+ Conversation.id == conv_id,
+ Conversation.app_id == workflow.app_id,
+ )
+ .first()
+ )
+ # Only return the conversation ID if it exists and is valid (has a correspond conversation record in DB).
+ if conversation is not None:
+ return conv_id
+ conversation = Conversation(
+ app_id=workflow.app_id,
+ app_model_config_id=app.app_model_config_id,
+ model_provider=None,
+ model_id="",
+ override_model_configs=None,
+ mode=app.mode,
+ name="Draft Debugging Conversation",
+ inputs={},
+ introduction="",
+ system_instruction="",
+ system_instruction_tokens=0,
+ status="normal",
+ invoke_from=InvokeFrom.DEBUGGER.value,
+ from_source="console",
+ from_end_user_id=None,
+ from_account_id=account_id,
+ )
+
+ self._session.add(conversation)
+ self._session.flush()
+ return conversation.id
+
+ def prefill_conversation_variable_default_values(self, workflow: Workflow):
+ """"""
+ draft_conv_vars: list[WorkflowDraftVariable] = []
+ for conv_var in workflow.conversation_variables:
+ draft_var = WorkflowDraftVariable.new_conversation_variable(
+ app_id=workflow.app_id,
+ name=conv_var.name,
+ value=conv_var,
+ description=conv_var.description,
+ )
+ draft_conv_vars.append(draft_var)
+ _batch_upsert_draft_varaible(
+ self._session,
+ draft_conv_vars,
+ policy=_UpsertPolicy.IGNORE,
+ )
+
+
+class _UpsertPolicy(StrEnum):
+ IGNORE = "ignore"
+ OVERWRITE = "overwrite"
+
+
+def _batch_upsert_draft_varaible(
+ session: Session,
+ draft_vars: Sequence[WorkflowDraftVariable],
+ policy: _UpsertPolicy = _UpsertPolicy.OVERWRITE,
+) -> None:
+ if not draft_vars:
+ return None
+ # Although we could use SQLAlchemy ORM operations here, we choose not to for several reasons:
+ #
+ # 1. The variable saving process involves writing multiple rows to the
+ # `workflow_draft_variables` table. Batch insertion significantly improves performance.
+ # 2. Using the ORM would require either:
+ #
+ # a. Checking for the existence of each variable before insertion,
+ # resulting in 2n SQL statements for n variables and potential concurrency issues.
+ # b. Attempting insertion first, then updating if a unique index violation occurs,
+ # which still results in n to 2n SQL statements.
+ #
+ # Both approaches are inefficient and suboptimal.
+ # 3. We do not need to retrieve the results of the SQL execution or populate ORM
+ # model instances with the returned values.
+ # 4. Batch insertion with `ON CONFLICT DO UPDATE` allows us to insert or update all
+ # variables in a single SQL statement, avoiding the issues above.
+ #
+ # For these reasons, we use the SQLAlchemy query builder and rely on dialect-specific
+ # insert operations instead of the ORM layer.
+ stmt = insert(WorkflowDraftVariable).values([_model_to_insertion_dict(v) for v in draft_vars])
+ if policy == _UpsertPolicy.OVERWRITE:
+ stmt = stmt.on_conflict_do_update(
+ index_elements=WorkflowDraftVariable.unique_app_id_node_id_name(),
+ set_={
+ # Refresh creation timestamp to ensure updated variables
+ # appear first in chronologically sorted result sets.
+ "created_at": stmt.excluded.created_at,
+ "updated_at": stmt.excluded.updated_at,
+ "last_edited_at": stmt.excluded.last_edited_at,
+ "description": stmt.excluded.description,
+ "value_type": stmt.excluded.value_type,
+ "value": stmt.excluded.value,
+ "visible": stmt.excluded.visible,
+ "editable": stmt.excluded.editable,
+ "node_execution_id": stmt.excluded.node_execution_id,
+ },
+ )
+ elif _UpsertPolicy.IGNORE:
+ stmt = stmt.on_conflict_do_nothing(index_elements=WorkflowDraftVariable.unique_app_id_node_id_name())
+ else:
+ raise Exception("Invalid value for update policy.")
+ session.execute(stmt)
+
+
+def _model_to_insertion_dict(model: WorkflowDraftVariable) -> dict[str, Any]:
+ d: dict[str, Any] = {
+ "app_id": model.app_id,
+ "last_edited_at": None,
+ "node_id": model.node_id,
+ "name": model.name,
+ "selector": model.selector,
+ "value_type": model.value_type,
+ "value": model.value,
+ "node_execution_id": model.node_execution_id,
+ }
+ if model.visible is not None:
+ d["visible"] = model.visible
+ if model.editable is not None:
+ d["editable"] = model.editable
+ if model.created_at is not None:
+ d["created_at"] = model.created_at
+ if model.updated_at is not None:
+ d["updated_at"] = model.updated_at
+ if model.description is not None:
+ d["description"] = model.description
+ return d
+
+
+def _build_segment_for_serialized_values(v: Any) -> Segment:
+ """
+ Reconstructs Segment objects from serialized values, with special handling
+ for FileSegment and ArrayFileSegment types.
+
+ This function should only be used when:
+ 1. No explicit type information is available
+ 2. The input value is in serialized form (dict or list)
+
+ It detects potential file objects in the serialized data and properly rebuilds the
+ appropriate segment type.
+ """
+ return build_segment(WorkflowDraftVariable.rebuild_file_types(v))
+
+
+class DraftVariableSaver:
+ # _DUMMY_OUTPUT_IDENTITY is a placeholder output for workflow nodes.
+ # Its sole possible value is `None`.
+ #
+ # This is used to signal the execution of a workflow node when it has no other outputs.
+ _DUMMY_OUTPUT_IDENTITY: ClassVar[str] = "__dummy__"
+ _DUMMY_OUTPUT_VALUE: ClassVar[None] = None
+
+ # _EXCLUDE_VARIABLE_NAMES_MAPPING maps node types and versions to variable names that
+ # should be excluded when saving draft variables. This prevents certain internal or
+ # technical variables from being exposed in the draft environment, particularly those
+ # that aren't meant to be directly edited or viewed by users.
+ _EXCLUDE_VARIABLE_NAMES_MAPPING: dict[NodeType, frozenset[str]] = {
+ NodeType.LLM: frozenset(["finish_reason"]),
+ NodeType.LOOP: frozenset(["loop_round"]),
+ }
+
+ # Database session used for persisting draft variables.
+ _session: Session
+
+ # The application ID associated with the draft variables.
+ # This should match the `Workflow.app_id` of the workflow to which the current node belongs.
+ _app_id: str
+
+ # The ID of the node for which DraftVariableSaver is saving output variables.
+ _node_id: str
+
+ # The type of the current node (see NodeType).
+ _node_type: NodeType
+
+ #
+ _node_execution_id: str
+
+ # _enclosing_node_id identifies the container node that the current node belongs to.
+ # For example, if the current node is an LLM node inside an Iteration node
+ # or Loop node, then `_enclosing_node_id` refers to the ID of
+ # the containing Iteration or Loop node.
+ #
+ # If the current node is not nested within another node, `_enclosing_node_id` is
+ # `None`.
+ _enclosing_node_id: str | None
+
+ def __init__(
+ self,
+ session: Session,
+ app_id: str,
+ node_id: str,
+ node_type: NodeType,
+ node_execution_id: str,
+ enclosing_node_id: str | None = None,
+ ):
+ # Important: `node_execution_id` parameter refers to the primary key (`id`) of the
+ # WorkflowNodeExecutionModel/WorkflowNodeExecution, not their `node_execution_id`
+ # field. These are distinct database fields with different purposes.
+ self._session = session
+ self._app_id = app_id
+ self._node_id = node_id
+ self._node_type = node_type
+ self._node_execution_id = node_execution_id
+ self._enclosing_node_id = enclosing_node_id
+
+ def _create_dummy_output_variable(self):
+ return WorkflowDraftVariable.new_node_variable(
+ app_id=self._app_id,
+ node_id=self._node_id,
+ name=self._DUMMY_OUTPUT_IDENTITY,
+ node_execution_id=self._node_execution_id,
+ value=build_segment(self._DUMMY_OUTPUT_VALUE),
+ visible=False,
+ editable=False,
+ )
+
+ def _should_save_output_variables_for_draft(self) -> bool:
+ if self._enclosing_node_id is not None and self._node_type != NodeType.VARIABLE_ASSIGNER:
+ # Currently we do not save output variables for nodes inside loop or iteration.
+ return False
+ return True
+
+ def _build_from_variable_assigner_mapping(self, process_data: Mapping[str, Any]) -> list[WorkflowDraftVariable]:
+ draft_vars: list[WorkflowDraftVariable] = []
+ updated_variables = get_updated_variables(process_data) or []
+
+ for item in updated_variables:
+ selector = item.selector
+ if len(selector) < MIN_SELECTORS_LENGTH:
+ raise Exception("selector too short")
+ # NOTE(QuantumGhost): only the following two kinds of variable could be updated by
+ # VariableAssigner: ConversationVariable and iteration variable.
+ # We only save conversation variable here.
+ if selector[0] != CONVERSATION_VARIABLE_NODE_ID:
+ continue
+ segment = WorkflowDraftVariable.build_segment_with_type(segment_type=item.value_type, value=item.new_value)
+ draft_vars.append(
+ WorkflowDraftVariable.new_conversation_variable(
+ app_id=self._app_id,
+ name=item.name,
+ value=segment,
+ )
+ )
+ # Add a dummy output variable to indicate that this node is executed.
+ draft_vars.append(self._create_dummy_output_variable())
+ return draft_vars
+
+ def _build_variables_from_start_mapping(self, output: Mapping[str, Any]) -> list[WorkflowDraftVariable]:
+ draft_vars = []
+ has_non_sys_variables = False
+ for name, value in output.items():
+ value_seg = _build_segment_for_serialized_values(value)
+ node_id, name = self._normalize_variable_for_start_node(name)
+ # If node_id is not `sys`, it means that the variable is a user-defined input field
+ # in `Start` node.
+ if node_id != SYSTEM_VARIABLE_NODE_ID:
+ draft_vars.append(
+ WorkflowDraftVariable.new_node_variable(
+ app_id=self._app_id,
+ node_id=self._node_id,
+ name=name,
+ node_execution_id=self._node_execution_id,
+ value=value_seg,
+ visible=True,
+ editable=True,
+ )
+ )
+ has_non_sys_variables = True
+ else:
+ if name == SystemVariableKey.FILES:
+ # Here we know the type of variable must be `array[file]`, we
+ # just build files from the value.
+ files = [File.model_validate(v) for v in value]
+ if files:
+ value_seg = WorkflowDraftVariable.build_segment_with_type(SegmentType.ARRAY_FILE, files)
+ else:
+ value_seg = ArrayFileSegment(value=[])
+
+ draft_vars.append(
+ WorkflowDraftVariable.new_sys_variable(
+ app_id=self._app_id,
+ name=name,
+ node_execution_id=self._node_execution_id,
+ value=value_seg,
+ editable=self._should_variable_be_editable(node_id, name),
+ )
+ )
+ if not has_non_sys_variables:
+ draft_vars.append(self._create_dummy_output_variable())
+ return draft_vars
+
+ def _normalize_variable_for_start_node(self, name: str) -> tuple[str, str]:
+ if not name.startswith(f"{SYSTEM_VARIABLE_NODE_ID}."):
+ return self._node_id, name
+ _, name_ = name.split(".", maxsplit=1)
+ return SYSTEM_VARIABLE_NODE_ID, name_
+
+ def _build_variables_from_mapping(self, output: Mapping[str, Any]) -> list[WorkflowDraftVariable]:
+ draft_vars = []
+ for name, value in output.items():
+ if not self._should_variable_be_saved(name):
+ _logger.debug(
+ "Skip saving variable as it has been excluded by its node_type, name=%s, node_type=%s",
+ name,
+ self._node_type,
+ )
+ continue
+ if isinstance(value, Segment):
+ value_seg = value
+ else:
+ value_seg = _build_segment_for_serialized_values(value)
+ draft_vars.append(
+ WorkflowDraftVariable.new_node_variable(
+ app_id=self._app_id,
+ node_id=self._node_id,
+ name=name,
+ node_execution_id=self._node_execution_id,
+ value=value_seg,
+ visible=self._should_variable_be_visible(self._node_id, self._node_type, name),
+ )
+ )
+ return draft_vars
+
+ def save(
+ self,
+ process_data: Mapping[str, Any] | None = None,
+ outputs: Mapping[str, Any] | None = None,
+ ):
+ draft_vars: list[WorkflowDraftVariable] = []
+ if outputs is None:
+ outputs = {}
+ if process_data is None:
+ process_data = {}
+ if not self._should_save_output_variables_for_draft():
+ return
+ if self._node_type == NodeType.VARIABLE_ASSIGNER:
+ draft_vars = self._build_from_variable_assigner_mapping(process_data=process_data)
+ elif self._node_type == NodeType.START:
+ draft_vars = self._build_variables_from_start_mapping(outputs)
+ else:
+ draft_vars = self._build_variables_from_mapping(outputs)
+ _batch_upsert_draft_varaible(self._session, draft_vars)
+
+ @staticmethod
+ def _should_variable_be_editable(node_id: str, name: str) -> bool:
+ if node_id in (CONVERSATION_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID):
+ return False
+ if node_id == SYSTEM_VARIABLE_NODE_ID and not is_system_variable_editable(name):
+ return False
+ return True
+
+ @staticmethod
+ def _should_variable_be_visible(node_id: str, node_type: NodeType, name: str) -> bool:
+ if node_type in NodeType.IF_ELSE:
+ return False
+ if node_id == SYSTEM_VARIABLE_NODE_ID and not is_system_variable_editable(name):
+ return False
+ return True
+
+ def _should_variable_be_saved(self, name: str) -> bool:
+ exclude_var_names = self._EXCLUDE_VARIABLE_NAMES_MAPPING.get(self._node_type)
+ if exclude_var_names is None:
+ return True
+ return name not in exclude_var_names
diff --git a/api/services/workflow_run_service.py b/api/services/workflow_run_service.py
index 483c0d3086..e43999a8c9 100644
--- a/api/services/workflow_run_service.py
+++ b/api/services/workflow_run_service.py
@@ -2,9 +2,9 @@ import threading
from collections.abc import Sequence
from typing import Optional
+from sqlalchemy.orm import sessionmaker
+
import contexts
-from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository
-from core.workflow.repositories.workflow_node_execution_repository import OrderConfig
from extensions.ext_database import db
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models import (
@@ -15,10 +15,18 @@ from models import (
WorkflowRun,
WorkflowRunTriggeredFrom,
)
-from models.workflow import WorkflowNodeExecutionTriggeredFrom
+from repositories.factory import DifyAPIRepositoryFactory
class WorkflowRunService:
+ def __init__(self):
+ """Initialize WorkflowRunService with repository dependencies."""
+ session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
+ self._node_execution_service_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
+ session_maker
+ )
+ self._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker)
+
def get_paginate_advanced_chat_workflow_runs(self, app_model: App, args: dict) -> InfiniteScrollPagination:
"""
Get advanced chat app workflow run list
@@ -62,45 +70,16 @@ class WorkflowRunService:
:param args: request args
"""
limit = int(args.get("limit", 20))
+ last_id = args.get("last_id")
- base_query = db.session.query(WorkflowRun).filter(
- WorkflowRun.tenant_id == app_model.tenant_id,
- WorkflowRun.app_id == app_model.id,
- WorkflowRun.triggered_from == WorkflowRunTriggeredFrom.DEBUGGING.value,
+ return self._workflow_run_repo.get_paginated_workflow_runs(
+ tenant_id=app_model.tenant_id,
+ app_id=app_model.id,
+ triggered_from=WorkflowRunTriggeredFrom.DEBUGGING.value,
+ limit=limit,
+ last_id=last_id,
)
- if args.get("last_id"):
- last_workflow_run = base_query.filter(
- WorkflowRun.id == args.get("last_id"),
- ).first()
-
- if not last_workflow_run:
- raise ValueError("Last workflow run not exists")
-
- workflow_runs = (
- base_query.filter(
- WorkflowRun.created_at < last_workflow_run.created_at, WorkflowRun.id != last_workflow_run.id
- )
- .order_by(WorkflowRun.created_at.desc())
- .limit(limit)
- .all()
- )
- else:
- workflow_runs = base_query.order_by(WorkflowRun.created_at.desc()).limit(limit).all()
-
- has_more = False
- if len(workflow_runs) == limit:
- current_page_first_workflow_run = workflow_runs[-1]
- rest_count = base_query.filter(
- WorkflowRun.created_at < current_page_first_workflow_run.created_at,
- WorkflowRun.id != current_page_first_workflow_run.id,
- ).count()
-
- if rest_count > 0:
- has_more = True
-
- return InfiniteScrollPagination(data=workflow_runs, limit=limit, has_more=has_more)
-
def get_workflow_run(self, app_model: App, run_id: str) -> Optional[WorkflowRun]:
"""
Get workflow run detail
@@ -108,18 +87,12 @@ class WorkflowRunService:
:param app_model: app model
:param run_id: workflow run id
"""
- workflow_run = (
- db.session.query(WorkflowRun)
- .filter(
- WorkflowRun.tenant_id == app_model.tenant_id,
- WorkflowRun.app_id == app_model.id,
- WorkflowRun.id == run_id,
- )
- .first()
+ return self._workflow_run_repo.get_workflow_run_by_id(
+ tenant_id=app_model.tenant_id,
+ app_id=app_model.id,
+ run_id=run_id,
)
- return workflow_run
-
def get_workflow_run_node_executions(
self,
app_model: App,
@@ -137,17 +110,13 @@ class WorkflowRunService:
if not workflow_run:
return []
- repository = SQLAlchemyWorkflowNodeExecutionRepository(
- session_factory=db.engine,
- user=user,
- app_id=app_model.id,
- triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
- )
+ # Get tenant_id from user
+ tenant_id = user.tenant_id if isinstance(user, EndUser) else user.current_tenant_id
+ if tenant_id is None:
+ raise ValueError("User tenant_id cannot be None")
- # Use the repository to get the database models directly
- order_config = OrderConfig(order_by=["index"], order_direction="desc")
- workflow_node_executions = repository.get_db_models_by_workflow_run(
- workflow_run_id=run_id, order_config=order_config
+ return self._node_execution_service_repo.get_executions_by_workflow_run(
+ tenant_id=tenant_id,
+ app_id=app_model.id,
+ workflow_run_id=run_id,
)
-
- return workflow_node_executions
diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py
index bc213ccce6..0149d50346 100644
--- a/api/services/workflow_service.py
+++ b/api/services/workflow_service.py
@@ -1,19 +1,24 @@
import json
import time
-from collections.abc import Callable, Generator, Sequence
+import uuid
+from collections.abc import Callable, Generator, Mapping, Sequence
from datetime import UTC, datetime
from typing import Any, Optional
from uuid import uuid4
from sqlalchemy import select
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, sessionmaker
+from core.app.app_config.entities import VariableEntityType
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
-from core.repositories import SQLAlchemyWorkflowNodeExecutionRepository
+from core.file import File
+from core.repositories import DifyCoreRepositoryFactory
from core.variables import Variable
from core.workflow.entities.node_entities import NodeRunResult
+from core.workflow.entities.variable_pool import VariablePool
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecution, WorkflowNodeExecutionStatus
+from core.workflow.enums import SystemVariableKey
from core.workflow.errors import WorkflowNodeRunFailedError
from core.workflow.graph_engine.entities.event import InNodeEvent
from core.workflow.nodes import NodeType
@@ -22,9 +27,11 @@ from core.workflow.nodes.enums import ErrorStrategy
from core.workflow.nodes.event import RunCompletedEvent
from core.workflow.nodes.event.types import NodeEvent
from core.workflow.nodes.node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING
+from core.workflow.nodes.start.entities import StartNodeData
from core.workflow.workflow_entry import WorkflowEntry
from events.app_event import app_draft_workflow_was_synced, app_published_workflow_was_updated
from extensions.ext_database import db
+from factories.file_factory import build_from_mapping, build_from_mappings
from models.account import Account
from models.model import App, AppMode
from models.tools import WorkflowToolProvider
@@ -34,10 +41,16 @@ from models.workflow import (
WorkflowNodeExecutionTriggeredFrom,
WorkflowType,
)
-from services.errors.app import WorkflowHashNotEqualError
+from repositories.factory import DifyAPIRepositoryFactory
+from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError
from services.workflow.workflow_converter import WorkflowConverter
from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
+from .workflow_draft_variable_service import (
+ DraftVariableSaver,
+ DraftVarLoader,
+ WorkflowDraftVariableService,
+)
class WorkflowService:
@@ -45,6 +58,44 @@ class WorkflowService:
Workflow Service
"""
+ def __init__(self, session_maker: sessionmaker | None = None):
+ """Initialize WorkflowService with repository dependencies."""
+ if session_maker is None:
+ session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
+ self._node_execution_service_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(
+ session_maker
+ )
+
+ def get_node_last_run(self, app_model: App, workflow: Workflow, node_id: str) -> WorkflowNodeExecutionModel | None:
+ """
+ Get the most recent execution for a specific node.
+
+ Args:
+ app_model: The application model
+ workflow: The workflow model
+ node_id: The node identifier
+
+ Returns:
+ The most recent WorkflowNodeExecutionModel for the node, or None if not found
+ """
+ return self._node_execution_service_repo.get_node_last_execution(
+ tenant_id=app_model.tenant_id,
+ app_id=app_model.id,
+ workflow_id=workflow.id,
+ node_id=node_id,
+ )
+
+ def is_workflow_exist(self, app_model: App) -> bool:
+ return (
+ db.session.query(Workflow)
+ .filter(
+ Workflow.tenant_id == app_model.tenant_id,
+ Workflow.app_id == app_model.id,
+ Workflow.version == Workflow.VERSION_DRAFT,
+ )
+ .count()
+ ) > 0
+
def get_draft_workflow(self, app_model: App) -> Optional[Workflow]:
"""
Get draft workflow
@@ -61,6 +112,23 @@ class WorkflowService:
# return draft workflow
return workflow
+ def get_published_workflow_by_id(self, app_model: App, workflow_id: str) -> Optional[Workflow]:
+ # fetch published workflow by workflow_id
+ workflow = (
+ db.session.query(Workflow)
+ .filter(
+ Workflow.tenant_id == app_model.tenant_id,
+ Workflow.app_id == app_model.id,
+ Workflow.id == workflow_id,
+ )
+ .first()
+ )
+ if not workflow:
+ return None
+ if workflow.version == Workflow.VERSION_DRAFT:
+ raise IsDraftWorkflowError(f"Workflow is draft version, id={workflow_id}")
+ return workflow
+
def get_published_workflow(self, app_model: App) -> Optional[Workflow]:
"""
Get published workflow
@@ -199,7 +267,7 @@ class WorkflowService:
tenant_id=app_model.tenant_id,
app_id=app_model.id,
type=draft_workflow.type,
- version=str(datetime.now(UTC).replace(tzinfo=None)),
+ version=Workflow.version_from_datetime(datetime.now(UTC).replace(tzinfo=None)),
graph=draft_workflow.graph,
features=draft_workflow.features,
created_by=account.id,
@@ -253,26 +321,85 @@ class WorkflowService:
return default_config
def run_draft_workflow_node(
- self, app_model: App, node_id: str, user_inputs: dict, account: Account
+ self,
+ app_model: App,
+ draft_workflow: Workflow,
+ node_id: str,
+ user_inputs: Mapping[str, Any],
+ account: Account,
+ query: str = "",
+ files: Sequence[File] | None = None,
) -> WorkflowNodeExecutionModel:
"""
Run draft workflow node
"""
- # fetch draft workflow by app_model
- draft_workflow = self.get_draft_workflow(app_model=app_model)
- if not draft_workflow:
- raise ValueError("Workflow not initialized")
+ files = files or []
+
+ with Session(bind=db.engine, expire_on_commit=False) as session, session.begin():
+ draft_var_srv = WorkflowDraftVariableService(session)
+ draft_var_srv.prefill_conversation_variable_default_values(draft_workflow)
+
+ node_config = draft_workflow.get_node_config_by_id(node_id)
+ node_type = Workflow.get_node_type_from_node_config(node_config)
+ node_data = node_config.get("data", {})
+ if node_type == NodeType.START:
+ with Session(bind=db.engine) as session, session.begin():
+ draft_var_srv = WorkflowDraftVariableService(session)
+ conversation_id = draft_var_srv.get_or_create_conversation(
+ account_id=account.id,
+ app=app_model,
+ workflow=draft_workflow,
+ )
+ start_data = StartNodeData.model_validate(node_data)
+ user_inputs = _rebuild_file_for_user_inputs_in_start_node(
+ tenant_id=draft_workflow.tenant_id, start_node_data=start_data, user_inputs=user_inputs
+ )
+ # init variable pool
+ variable_pool = _setup_variable_pool(
+ query=query,
+ files=files or [],
+ user_id=account.id,
+ user_inputs=user_inputs,
+ workflow=draft_workflow,
+ # NOTE(QuantumGhost): We rely on `DraftVarLoader` to load conversation variables.
+ conversation_variables=[],
+ node_type=node_type,
+ conversation_id=conversation_id,
+ )
+
+ else:
+ variable_pool = VariablePool(
+ system_variables={},
+ user_inputs=user_inputs,
+ environment_variables=draft_workflow.environment_variables,
+ conversation_variables=[],
+ )
+
+ variable_loader = DraftVarLoader(
+ engine=db.engine,
+ app_id=app_model.id,
+ tenant_id=app_model.tenant_id,
+ )
+
+ eclosing_node_type_and_id = draft_workflow.get_enclosing_node_type_and_id(node_config)
+ if eclosing_node_type_and_id:
+ _, enclosing_node_id = eclosing_node_type_and_id
+ else:
+ enclosing_node_id = None
+
+ run = WorkflowEntry.single_step_run(
+ workflow=draft_workflow,
+ node_id=node_id,
+ user_inputs=user_inputs,
+ user_id=account.id,
+ variable_pool=variable_pool,
+ variable_loader=variable_loader,
+ )
# run draft workflow node
start_at = time.perf_counter()
-
node_execution = self._handle_node_run_result(
- invoke_node_fn=lambda: WorkflowEntry.single_step_run(
- workflow=draft_workflow,
- node_id=node_id,
- user_inputs=user_inputs,
- user_id=account.id,
- ),
+ invoke_node_fn=lambda: run,
start_at=start_at,
node_id=node_id,
)
@@ -281,7 +408,7 @@ class WorkflowService:
node_execution.workflow_id = draft_workflow.id
# Create repository and save the node execution
- repository = SQLAlchemyWorkflowNodeExecutionRepository(
+ repository = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
session_factory=db.engine,
user=account,
app_id=app_model.id,
@@ -289,8 +416,21 @@ class WorkflowService:
)
repository.save(node_execution)
- # Convert node_execution to WorkflowNodeExecution after save
- workflow_node_execution = repository.to_db_model(node_execution)
+ workflow_node_execution = self._node_execution_service_repo.get_execution_by_id(node_execution.id)
+ if workflow_node_execution is None:
+ raise ValueError(f"WorkflowNodeExecution with id {node_execution.id} not found after saving")
+
+ with Session(bind=db.engine) as session, session.begin():
+ draft_var_saver = DraftVariableSaver(
+ session=session,
+ app_id=app_model.id,
+ node_id=workflow_node_execution.node_id,
+ node_type=NodeType(workflow_node_execution.node_type),
+ enclosing_node_id=enclosing_node_id,
+ node_execution_id=node_execution.id,
+ )
+ draft_var_saver.save(process_data=node_execution.process_data, outputs=node_execution.outputs)
+ session.commit()
return workflow_node_execution
@@ -303,7 +443,7 @@ class WorkflowService:
# run draft workflow node
start_at = time.perf_counter()
- workflow_node_execution = self._handle_node_run_result(
+ node_execution = self._handle_node_run_result(
invoke_node_fn=lambda: WorkflowEntry.run_free_node(
node_id=node_id,
node_data=node_data,
@@ -315,7 +455,7 @@ class WorkflowService:
node_id=node_id,
)
- return workflow_node_execution
+ return node_execution
def _handle_node_run_result(
self,
@@ -332,7 +472,7 @@ class WorkflowService:
node_run_result = event.run_result
# sign output files
- node_run_result.outputs = WorkflowEntry.handle_special_values(node_run_result.outputs)
+ # node_run_result.outputs = WorkflowEntry.handle_special_values(node_run_result.outputs)
break
if not node_run_result:
@@ -394,7 +534,7 @@ class WorkflowService:
if node_run_result.process_data
else None
)
- outputs = WorkflowEntry.handle_special_values(node_run_result.outputs) if node_run_result.outputs else None
+ outputs = node_run_result.outputs
node_execution.inputs = inputs
node_execution.process_data = process_data
@@ -531,3 +671,83 @@ class WorkflowService:
session.delete(workflow)
return True
+
+
+def _setup_variable_pool(
+ query: str,
+ files: Sequence[File],
+ user_id: str,
+ user_inputs: Mapping[str, Any],
+ workflow: Workflow,
+ node_type: NodeType,
+ conversation_id: str,
+ conversation_variables: list[Variable],
+):
+ # Only inject system variables for START node type.
+ if node_type == NodeType.START:
+ # Create a variable pool.
+ system_inputs: dict[SystemVariableKey, Any] = {
+ # From inputs:
+ SystemVariableKey.FILES: files,
+ SystemVariableKey.USER_ID: user_id,
+ # From workflow model
+ SystemVariableKey.APP_ID: workflow.app_id,
+ SystemVariableKey.WORKFLOW_ID: workflow.id,
+ # Randomly generated.
+ SystemVariableKey.WORKFLOW_EXECUTION_ID: str(uuid.uuid4()),
+ }
+
+ # Only add chatflow-specific variables for non-workflow types
+ if workflow.type != WorkflowType.WORKFLOW.value:
+ system_inputs.update(
+ {
+ SystemVariableKey.QUERY: query,
+ SystemVariableKey.CONVERSATION_ID: conversation_id,
+ SystemVariableKey.DIALOGUE_COUNT: 0,
+ }
+ )
+ else:
+ system_inputs = {}
+
+ # init variable pool
+ variable_pool = VariablePool(
+ system_variables=system_inputs,
+ user_inputs=user_inputs,
+ environment_variables=workflow.environment_variables,
+ conversation_variables=conversation_variables,
+ )
+
+ return variable_pool
+
+
+def _rebuild_file_for_user_inputs_in_start_node(
+ tenant_id: str, start_node_data: StartNodeData, user_inputs: Mapping[str, Any]
+) -> Mapping[str, Any]:
+ inputs_copy = dict(user_inputs)
+
+ for variable in start_node_data.variables:
+ if variable.type not in (VariableEntityType.FILE, VariableEntityType.FILE_LIST):
+ continue
+ if variable.variable not in user_inputs:
+ continue
+ value = user_inputs[variable.variable]
+ file = _rebuild_single_file(tenant_id=tenant_id, value=value, variable_entity_type=variable.type)
+ inputs_copy[variable.variable] = file
+ return inputs_copy
+
+
+def _rebuild_single_file(tenant_id: str, value: Any, variable_entity_type: VariableEntityType) -> File | Sequence[File]:
+ if variable_entity_type == VariableEntityType.FILE:
+ if not isinstance(value, dict):
+ raise ValueError(f"expected dict for file object, got {type(value)}")
+ return build_from_mapping(mapping=value, tenant_id=tenant_id)
+ elif variable_entity_type == VariableEntityType.FILE_LIST:
+ if not isinstance(value, list):
+ raise ValueError(f"expected list for file list object, got {type(value)}")
+ if len(value) == 0:
+ return []
+ if not isinstance(value[0], dict):
+ raise ValueError(f"expected dict for first element in the file list, got {type(value)}")
+ return build_from_mappings(mappings=value, tenant_id=tenant_id)
+ else:
+ raise Exception("unreachable")
diff --git a/api/tasks/clean_document_task.py b/api/tasks/clean_document_task.py
index 5824121e8f..c72a3319c1 100644
--- a/api/tasks/clean_document_task.py
+++ b/api/tasks/clean_document_task.py
@@ -72,6 +72,7 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i
DatasetMetadataBinding.dataset_id == dataset_id,
DatasetMetadataBinding.document_id == document_id,
).delete()
+ db.session.commit()
end_at = time.perf_counter()
logging.info(
diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py
index d366efd6f2..179adcbd6e 100644
--- a/api/tasks/remove_app_and_related_data_task.py
+++ b/api/tasks/remove_app_and_related_data_task.py
@@ -6,6 +6,7 @@ import click
from celery import shared_task # type: ignore
from sqlalchemy import delete
from sqlalchemy.exc import SQLAlchemyError
+from sqlalchemy.orm import sessionmaker
from extensions.ext_database import db
from models import (
@@ -13,6 +14,7 @@ from models import (
AppAnnotationHitHistory,
AppAnnotationSetting,
AppDatasetJoin,
+ AppMCPServer,
AppModelConfig,
Conversation,
EndUser,
@@ -30,7 +32,8 @@ from models import (
)
from models.tools import WorkflowToolProvider
from models.web import PinnedConversation, SavedMessage
-from models.workflow import ConversationVariable, Workflow, WorkflowAppLog, WorkflowNodeExecutionModel, WorkflowRun
+from models.workflow import ConversationVariable, Workflow, WorkflowAppLog
+from repositories.factory import DifyAPIRepositoryFactory
@shared_task(queue="app_deletion", bind=True, max_retries=3)
@@ -41,6 +44,7 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str):
# Delete related data
_delete_app_model_configs(tenant_id, app_id)
_delete_app_site(tenant_id, app_id)
+ _delete_app_mcp_servers(tenant_id, app_id)
_delete_app_api_tokens(tenant_id, app_id)
_delete_installed_apps(tenant_id, app_id)
_delete_recommended_apps(tenant_id, app_id)
@@ -89,6 +93,18 @@ def _delete_app_site(tenant_id: str, app_id: str):
_delete_records("""select id from sites where app_id=:app_id limit 1000""", {"app_id": app_id}, del_site, "site")
+def _delete_app_mcp_servers(tenant_id: str, app_id: str):
+ def del_mcp_server(mcp_server_id: str):
+ db.session.query(AppMCPServer).filter(AppMCPServer.id == mcp_server_id).delete(synchronize_session=False)
+
+ _delete_records(
+ """select id from app_mcp_servers where app_id=:app_id limit 1000""",
+ {"app_id": app_id},
+ del_mcp_server,
+ "app mcp server",
+ )
+
+
def _delete_app_api_tokens(tenant_id: str, app_id: str):
def del_api_token(api_token_id: str):
db.session.query(ApiToken).filter(ApiToken.id == api_token_id).delete(synchronize_session=False)
@@ -175,30 +191,32 @@ def _delete_app_workflows(tenant_id: str, app_id: str):
def _delete_app_workflow_runs(tenant_id: str, app_id: str):
- def del_workflow_run(workflow_run_id: str):
- db.session.query(WorkflowRun).filter(WorkflowRun.id == workflow_run_id).delete(synchronize_session=False)
-
- _delete_records(
- """select id from workflow_runs where tenant_id=:tenant_id and app_id=:app_id limit 1000""",
- {"tenant_id": tenant_id, "app_id": app_id},
- del_workflow_run,
- "workflow run",
+ """Delete all workflow runs for an app using the service repository."""
+ session_maker = sessionmaker(bind=db.engine)
+ workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker)
+
+ deleted_count = workflow_run_repo.delete_runs_by_app(
+ tenant_id=tenant_id,
+ app_id=app_id,
+ batch_size=1000,
)
+ logging.info(f"Deleted {deleted_count} workflow runs for app {app_id}")
-def _delete_app_workflow_node_executions(tenant_id: str, app_id: str):
- def del_workflow_node_execution(workflow_node_execution_id: str):
- db.session.query(WorkflowNodeExecutionModel).filter(
- WorkflowNodeExecutionModel.id == workflow_node_execution_id
- ).delete(synchronize_session=False)
- _delete_records(
- """select id from workflow_node_executions where tenant_id=:tenant_id and app_id=:app_id limit 1000""",
- {"tenant_id": tenant_id, "app_id": app_id},
- del_workflow_node_execution,
- "workflow node execution",
+def _delete_app_workflow_node_executions(tenant_id: str, app_id: str):
+ """Delete all workflow node executions for an app using the service repository."""
+ session_maker = sessionmaker(bind=db.engine)
+ node_execution_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository(session_maker)
+
+ deleted_count = node_execution_repo.delete_executions_by_app(
+ tenant_id=tenant_id,
+ app_id=app_id,
+ batch_size=1000,
)
+ logging.info(f"Deleted {deleted_count} workflow node executions for app {app_id}")
+
def _delete_app_workflow_app_logs(tenant_id: str, app_id: str):
def del_workflow_app_log(workflow_app_log_id: str):
diff --git a/api/tasks/retry_document_indexing_task.py b/api/tasks/retry_document_indexing_task.py
index a6e7092216..8f8c3f9d81 100644
--- a/api/tasks/retry_document_indexing_task.py
+++ b/api/tasks/retry_document_indexing_task.py
@@ -30,11 +30,11 @@ def retry_document_indexing_task(dataset_id: str, document_ids: list[str]):
logging.info(click.style("Dataset not found: {}".format(dataset_id), fg="red"))
db.session.close()
return
-
+ tenant_id = dataset.tenant_id
for document_id in document_ids:
retry_indexing_cache_key = "document_{}_is_retried".format(document_id)
# check document limit
- features = FeatureService.get_features(dataset.tenant_id)
+ features = FeatureService.get_features(tenant_id)
try:
if features.billing.enabled:
vector_space = features.vector_space
diff --git a/api/tests/integration_tests/.env.example b/api/tests/integration_tests/.env.example
index 9e40a8494d..4046096c27 100644
--- a/api/tests/integration_tests/.env.example
+++ b/api/tests/integration_tests/.env.example
@@ -1,107 +1,217 @@
-# OpenAI API Key
-OPENAI_API_KEY=
+FLASK_APP=app.py
+FLASK_DEBUG=0
+SECRET_KEY='uhySf6a3aZuvRNfAlcr47paOw9TRYBY6j8ZHXpVw1yx5RP27Yj3w2uvI'
+
+CONSOLE_API_URL=http://127.0.0.1:5001
+CONSOLE_WEB_URL=http://127.0.0.1:3000
+
+# Service API base URL
+SERVICE_API_URL=http://127.0.0.1:5001
+
+# Web APP base URL
+APP_WEB_URL=http://127.0.0.1:3000
+
+# Files URL
+FILES_URL=http://127.0.0.1:5001
+
+# The time in seconds after the signature is rejected
+FILES_ACCESS_TIMEOUT=300
+
+# Access token expiration time in minutes
+ACCESS_TOKEN_EXPIRE_MINUTES=60
+
+# Refresh token expiration time in days
+REFRESH_TOKEN_EXPIRE_DAYS=30
+
+# celery configuration
+CELERY_BROKER_URL=redis://:difyai123456@localhost:6379/1
+
+# redis configuration
+REDIS_HOST=localhost
+REDIS_PORT=6379
+REDIS_USERNAME=
+REDIS_PASSWORD=difyai123456
+REDIS_USE_SSL=false
+REDIS_DB=0
+
+# PostgreSQL database configuration
+DB_USERNAME=postgres
+DB_PASSWORD=difyai123456
+DB_HOST=localhost
+DB_PORT=5432
+DB_DATABASE=dify
+
+# Storage configuration
+# use for store upload files, private keys...
+# storage type: opendal, s3, aliyun-oss, azure-blob, baidu-obs, google-storage, huawei-obs, oci-storage, tencent-cos, volcengine-tos, supabase
+STORAGE_TYPE=opendal
+
+# Apache OpenDAL storage configuration, refer to https://github.com/apache/opendal
+OPENDAL_SCHEME=fs
+OPENDAL_FS_ROOT=storage
+
+# CORS configuration
+WEB_API_CORS_ALLOW_ORIGINS=http://127.0.0.1:3000,*
+CONSOLE_CORS_ALLOW_ORIGINS=http://127.0.0.1:3000,*
+
+# Vector database configuration
+# support: weaviate, qdrant, milvus, myscale, relyt, pgvecto_rs, pgvector, pgvector, chroma, opensearch, tidb_vector, couchbase, vikingdb, upstash, lindorm, oceanbase
+VECTOR_STORE=weaviate
+# Weaviate configuration
+WEAVIATE_ENDPOINT=http://localhost:8080
+WEAVIATE_API_KEY=WVF5YThaHlkYwhGUSmCRgsX3tD5ngdN8pkih
+WEAVIATE_GRPC_ENABLED=false
+WEAVIATE_BATCH_SIZE=100
+
+
+# Upload configuration
+UPLOAD_FILE_SIZE_LIMIT=15
+UPLOAD_FILE_BATCH_LIMIT=5
+UPLOAD_IMAGE_FILE_SIZE_LIMIT=10
+UPLOAD_VIDEO_FILE_SIZE_LIMIT=100
+UPLOAD_AUDIO_FILE_SIZE_LIMIT=50
+
+# Model configuration
+MULTIMODAL_SEND_FORMAT=base64
+PROMPT_GENERATION_MAX_TOKENS=4096
+CODE_GENERATION_MAX_TOKENS=1024
+
+# Mail configuration, support: resend, smtp
+MAIL_TYPE=
+MAIL_DEFAULT_SEND_FROM=no-reply
+RESEND_API_KEY=
+RESEND_API_URL=https://api.resend.com
+# smtp configuration
+SMTP_SERVER=smtp.example.com
+SMTP_PORT=465
+SMTP_USERNAME=123
+SMTP_PASSWORD=abc
+SMTP_USE_TLS=true
+SMTP_OPPORTUNISTIC_TLS=false
+
+# Sentry configuration
+SENTRY_DSN=
+
+# DEBUG
+DEBUG=false
+SQLALCHEMY_ECHO=false
+
+# Notion import configuration, support public and internal
+NOTION_INTEGRATION_TYPE=public
+NOTION_CLIENT_SECRET=you-client-secret
+NOTION_CLIENT_ID=you-client-id
+NOTION_INTERNAL_SECRET=you-internal-secret
+
+ETL_TYPE=dify
+UNSTRUCTURED_API_URL=
+UNSTRUCTURED_API_KEY=
+SCARF_NO_ANALYTICS=false
+
+#ssrf
+SSRF_PROXY_HTTP_URL=
+SSRF_PROXY_HTTPS_URL=
+SSRF_DEFAULT_MAX_RETRIES=3
+SSRF_DEFAULT_TIME_OUT=5
+SSRF_DEFAULT_CONNECT_TIME_OUT=5
+SSRF_DEFAULT_READ_TIME_OUT=5
+SSRF_DEFAULT_WRITE_TIME_OUT=5
+
+BATCH_UPLOAD_LIMIT=10
+KEYWORD_DATA_SOURCE_TYPE=database
+
+# Workflow file upload limit
+WORKFLOW_FILE_UPLOAD_LIMIT=10
-# Azure OpenAI API Base Endpoint & API Key
-AZURE_OPENAI_API_BASE=
-AZURE_OPENAI_API_KEY=
-
-# Anthropic API Key
-ANTHROPIC_API_KEY=
-
-# Replicate API Key
-REPLICATE_API_KEY=
-
-# Hugging Face API Key
-HUGGINGFACE_API_KEY=
-HUGGINGFACE_TEXT_GEN_ENDPOINT_URL=
-HUGGINGFACE_TEXT2TEXT_GEN_ENDPOINT_URL=
-HUGGINGFACE_EMBEDDINGS_ENDPOINT_URL=
-
-# Minimax Credentials
-MINIMAX_API_KEY=
-MINIMAX_GROUP_ID=
-
-# Spark Credentials
-SPARK_APP_ID=
-SPARK_API_KEY=
-SPARK_API_SECRET=
-
-# Tongyi Credentials
-TONGYI_DASHSCOPE_API_KEY=
-
-# Wenxin Credentials
-WENXIN_API_KEY=
-WENXIN_SECRET_KEY=
-
-# ZhipuAI Credentials
-ZHIPUAI_API_KEY=
-
-# Baichuan Credentials
-BAICHUAN_API_KEY=
-BAICHUAN_SECRET_KEY=
-
-# ChatGLM Credentials
-CHATGLM_API_BASE=
-
-# Xinference Credentials
-XINFERENCE_SERVER_URL=
-XINFERENCE_GENERATION_MODEL_UID=
-XINFERENCE_CHAT_MODEL_UID=
-XINFERENCE_EMBEDDINGS_MODEL_UID=
-XINFERENCE_RERANK_MODEL_UID=
-
-# OpenLLM Credentials
-OPENLLM_SERVER_URL=
-
-# LocalAI Credentials
-LOCALAI_SERVER_URL=
-
-# Cohere Credentials
-COHERE_API_KEY=
-
-# Jina Credentials
-JINA_API_KEY=
-
-# Ollama Credentials
-OLLAMA_BASE_URL=
+# CODE EXECUTION CONFIGURATION
+CODE_EXECUTION_ENDPOINT=http://127.0.0.1:8194
+CODE_EXECUTION_API_KEY=dify-sandbox
+CODE_MAX_NUMBER=9223372036854775807
+CODE_MIN_NUMBER=-9223372036854775808
+CODE_MAX_STRING_LENGTH=80000
+TEMPLATE_TRANSFORM_MAX_LENGTH=80000
+CODE_MAX_STRING_ARRAY_LENGTH=30
+CODE_MAX_OBJECT_ARRAY_LENGTH=30
+CODE_MAX_NUMBER_ARRAY_LENGTH=1000
+
+# API Tool configuration
+API_TOOL_DEFAULT_CONNECT_TIMEOUT=10
+API_TOOL_DEFAULT_READ_TIMEOUT=60
+
+# HTTP Node configuration
+HTTP_REQUEST_MAX_CONNECT_TIMEOUT=300
+HTTP_REQUEST_MAX_READ_TIMEOUT=600
+HTTP_REQUEST_MAX_WRITE_TIMEOUT=600
+HTTP_REQUEST_NODE_MAX_BINARY_SIZE=10485760
+HTTP_REQUEST_NODE_MAX_TEXT_SIZE=1048576
+
+# Respect X-* headers to redirect clients
+RESPECT_XFORWARD_HEADERS_ENABLED=false
+
+# Log file path
+LOG_FILE=
+# Log file max size, the unit is MB
+LOG_FILE_MAX_SIZE=20
+# Log file max backup count
+LOG_FILE_BACKUP_COUNT=5
+# Log dateformat
+LOG_DATEFORMAT=%Y-%m-%d %H:%M:%S
+# Log Timezone
+LOG_TZ=UTC
+# Log format
+LOG_FORMAT=%(asctime)s,%(msecs)d %(levelname)-2s [%(filename)s:%(lineno)d] %(req_id)s %(message)s
+
+# Indexing configuration
+INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
+
+# Workflow runtime configuration
+WORKFLOW_MAX_EXECUTION_STEPS=500
+WORKFLOW_MAX_EXECUTION_TIME=1200
+WORKFLOW_CALL_MAX_DEPTH=5
+WORKFLOW_PARALLEL_DEPTH_LIMIT=3
+MAX_VARIABLE_SIZE=204800
+
+# App configuration
+APP_MAX_EXECUTION_TIME=1200
+APP_MAX_ACTIVE_REQUESTS=0
+
+# Celery beat configuration
+CELERY_BEAT_SCHEDULER_TIME=1
+
+# Position configuration
+POSITION_TOOL_PINS=
+POSITION_TOOL_INCLUDES=
+POSITION_TOOL_EXCLUDES=
+
+POSITION_PROVIDER_PINS=
+POSITION_PROVIDER_INCLUDES=
+POSITION_PROVIDER_EXCLUDES=
-# Together API Key
-TOGETHER_API_KEY=
+# Plugin configuration
+PLUGIN_DAEMON_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi
+PLUGIN_DAEMON_URL=http://127.0.0.1:5002
+PLUGIN_REMOTE_INSTALL_PORT=5003
+PLUGIN_REMOTE_INSTALL_HOST=localhost
+PLUGIN_MAX_PACKAGE_SIZE=15728640
+INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
-# Mock Switch
-MOCK_SWITCH=false
+# Marketplace configuration
+MARKETPLACE_ENABLED=true
+MARKETPLACE_API_URL=https://marketplace.dify.ai
-# CODE EXECUTION CONFIGURATION
-CODE_EXECUTION_ENDPOINT=
-CODE_EXECUTION_API_KEY=
+# Endpoint configuration
+ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id}
-# Volcengine MaaS Credentials
-VOLC_API_KEY=
-VOLC_SECRET_KEY=
-VOLC_MODEL_ENDPOINT_ID=
-VOLC_EMBEDDING_ENDPOINT_ID=
+# Reset password token expiry minutes
+RESET_PASSWORD_TOKEN_EXPIRY_MINUTES=5
-# 360 AI Credentials
-ZHINAO_API_KEY=
+CREATE_TIDB_SERVICE_JOB_ENABLED=false
-# Plugin configuration
-PLUGIN_DAEMON_KEY=
-PLUGIN_DAEMON_URL=
+# Maximum number of submitted thread count in a ThreadPool for parallel node execution
+MAX_SUBMIT_COUNT=100
+# Lockout duration in seconds
+LOGIN_LOCKOUT_DURATION=86400
-# Marketplace configuration
-MARKETPLACE_API_URL=
-# VESSL AI Credentials
-VESSL_AI_MODEL_NAME=
-VESSL_AI_API_KEY=
-VESSL_AI_ENDPOINT_URL=
-
-# GPUStack Credentials
-GPUSTACK_SERVER_URL=
-GPUSTACK_API_KEY=
-
-# Gitee AI Credentials
-GITEE_AI_API_KEY=
-
-# xAI Credentials
-XAI_API_KEY=
-XAI_API_BASE=
+HTTP_PROXY='http://127.0.0.1:1092'
+HTTPS_PROXY='http://127.0.0.1:1092'
+NO_PROXY='localhost,127.0.0.1'
+LOG_LEVEL=INFO
diff --git a/api/tests/integration_tests/conftest.py b/api/tests/integration_tests/conftest.py
index 6e3ab4b74b..d9f90f992e 100644
--- a/api/tests/integration_tests/conftest.py
+++ b/api/tests/integration_tests/conftest.py
@@ -1,19 +1,91 @@
-import os
+import pathlib
+import random
+import secrets
+from collections.abc import Generator
-# Getting the absolute path of the current file's directory
-ABS_PATH = os.path.dirname(os.path.abspath(__file__))
+import pytest
+from flask import Flask
+from flask.testing import FlaskClient
+from sqlalchemy.orm import Session
-# Getting the absolute path of the project's root directory
-PROJECT_DIR = os.path.abspath(os.path.join(ABS_PATH, os.pardir, os.pardir))
+from app_factory import create_app
+from models import Account, DifySetup, Tenant, TenantAccountJoin, db
+from services.account_service import AccountService, RegisterService
# Loading the .env file if it exists
def _load_env() -> None:
- dotenv_path = os.path.join(PROJECT_DIR, "tests", "integration_tests", ".env")
- if os.path.exists(dotenv_path):
+ current_file_path = pathlib.Path(__file__).absolute()
+ # Items later in the list have higher precedence.
+ files_to_load = [".env", "vdb.env"]
+
+ env_file_paths = [current_file_path.parent / i for i in files_to_load]
+ for path in env_file_paths:
+ if not path.exists():
+ continue
+
from dotenv import load_dotenv
- load_dotenv(dotenv_path)
+ # Set `override=True` to ensure values from `vdb.env` take priority over values from `.env`
+ load_dotenv(str(path), override=True)
_load_env()
+
+_CACHED_APP = create_app()
+
+
+@pytest.fixture
+def flask_app() -> Flask:
+ return _CACHED_APP
+
+
+@pytest.fixture(scope="session")
+def setup_account(request) -> Generator[Account, None, None]:
+ """`dify_setup` completes the setup process for the Dify application.
+
+ It creates `Account` and `Tenant`, and inserts a `DifySetup` record into the database.
+
+ Most tests in the `controllers` package may require dify has been successfully setup.
+ """
+ with _CACHED_APP.test_request_context():
+ rand_suffix = random.randint(int(1e6), int(1e7)) # noqa
+ name = f"test-user-{rand_suffix}"
+ email = f"{name}@example.com"
+ RegisterService.setup(
+ email=email,
+ name=name,
+ password=secrets.token_hex(16),
+ ip_address="localhost",
+ )
+
+ with _CACHED_APP.test_request_context():
+ with Session(bind=db.engine, expire_on_commit=False) as session:
+ account = session.query(Account).filter_by(email=email).one()
+
+ yield account
+
+ with _CACHED_APP.test_request_context():
+ db.session.query(DifySetup).delete()
+ db.session.query(TenantAccountJoin).delete()
+ db.session.query(Account).delete()
+ db.session.query(Tenant).delete()
+ db.session.commit()
+
+
+@pytest.fixture
+def flask_req_ctx():
+ with _CACHED_APP.test_request_context():
+ yield
+
+
+@pytest.fixture
+def auth_header(setup_account) -> dict[str, str]:
+ token = AccountService.get_account_jwt_token(setup_account)
+ return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.fixture
+def test_client() -> Generator[FlaskClient, None, None]:
+ with _CACHED_APP.test_client() as client:
+ yield client
diff --git a/api/tests/integration_tests/controllers/app_fixture.py b/api/tests/integration_tests/controllers/app_fixture.py
deleted file mode 100644
index 32e8c11d19..0000000000
--- a/api/tests/integration_tests/controllers/app_fixture.py
+++ /dev/null
@@ -1,25 +0,0 @@
-import pytest
-
-from app_factory import create_app
-from configs import dify_config
-
-mock_user = type(
- "MockUser",
- (object,),
- {
- "is_authenticated": True,
- "id": "123",
- "is_editor": True,
- "is_dataset_editor": True,
- "status": "active",
- "get_id": "123",
- "current_tenant_id": "9d2074fc-6f86-45a9-b09d-6ecc63b9056b",
- },
-)
-
-
-@pytest.fixture
-def app():
- app = create_app()
- dify_config.LOGIN_DISABLED = True
- return app
diff --git a/api/tests/integration_tests/controllers/console/__init__.py b/api/tests/integration_tests/controllers/console/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/tests/integration_tests/controllers/console/app/__init__.py b/api/tests/integration_tests/controllers/console/app/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/tests/integration_tests/controllers/console/app/test_workflow_draft_variable.py b/api/tests/integration_tests/controllers/console/app/test_workflow_draft_variable.py
new file mode 100644
index 0000000000..038f37af5f
--- /dev/null
+++ b/api/tests/integration_tests/controllers/console/app/test_workflow_draft_variable.py
@@ -0,0 +1,47 @@
+import uuid
+from unittest import mock
+
+from controllers.console.app import workflow_draft_variable as draft_variable_api
+from controllers.console.app import wraps
+from factories.variable_factory import build_segment
+from models import App, AppMode
+from models.workflow import WorkflowDraftVariable
+from services.workflow_draft_variable_service import WorkflowDraftVariableList, WorkflowDraftVariableService
+
+
+def _get_mock_srv_class() -> type[WorkflowDraftVariableService]:
+ return mock.create_autospec(WorkflowDraftVariableService)
+
+
+class TestWorkflowDraftNodeVariableListApi:
+ def test_get(self, test_client, auth_header, monkeypatch):
+ srv_class = _get_mock_srv_class()
+ mock_app_model: App = App()
+ mock_app_model.id = str(uuid.uuid4())
+ test_node_id = "test_node_id"
+ mock_app_model.mode = AppMode.ADVANCED_CHAT
+ mock_load_app_model = mock.Mock(return_value=mock_app_model)
+
+ monkeypatch.setattr(draft_variable_api, "WorkflowDraftVariableService", srv_class)
+ monkeypatch.setattr(wraps, "_load_app_model", mock_load_app_model)
+
+ var1 = WorkflowDraftVariable.new_node_variable(
+ app_id="test_app_1",
+ node_id="test_node_1",
+ name="str_var",
+ value=build_segment("str_value"),
+ node_execution_id=str(uuid.uuid4()),
+ )
+ srv_instance = mock.create_autospec(WorkflowDraftVariableService, instance=True)
+ srv_class.return_value = srv_instance
+ srv_instance.list_node_variables.return_value = WorkflowDraftVariableList(variables=[var1])
+
+ response = test_client.get(
+ f"/console/api/apps/{mock_app_model.id}/workflows/draft/nodes/{test_node_id}/variables",
+ headers=auth_header,
+ )
+ assert response.status_code == 200
+ response_dict = response.json
+ assert isinstance(response_dict, dict)
+ assert "items" in response_dict
+ assert len(response_dict["items"]) == 1
diff --git a/api/tests/integration_tests/controllers/test_controllers.py b/api/tests/integration_tests/controllers/test_controllers.py
deleted file mode 100644
index 276ad3a7ed..0000000000
--- a/api/tests/integration_tests/controllers/test_controllers.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from unittest.mock import patch
-
-from app_fixture import mock_user # type: ignore
-
-
-def test_post_requires_login(app):
- with app.test_client() as client, patch("flask_login.utils._get_user", mock_user):
- response = client.get("/console/api/data-source/integrates")
- assert response.status_code == 200
diff --git a/api/tests/integration_tests/factories/__init__.py b/api/tests/integration_tests/factories/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/tests/integration_tests/factories/test_storage_key_loader.py b/api/tests/integration_tests/factories/test_storage_key_loader.py
new file mode 100644
index 0000000000..fecb3f6d95
--- /dev/null
+++ b/api/tests/integration_tests/factories/test_storage_key_loader.py
@@ -0,0 +1,371 @@
+import unittest
+from datetime import UTC, datetime
+from typing import Optional
+from unittest.mock import patch
+from uuid import uuid4
+
+import pytest
+from sqlalchemy.orm import Session
+
+from core.file import File, FileTransferMethod, FileType
+from extensions.ext_database import db
+from factories.file_factory import StorageKeyLoader
+from models import ToolFile, UploadFile
+from models.enums import CreatorUserRole
+
+
+@pytest.mark.usefixtures("flask_req_ctx")
+class TestStorageKeyLoader(unittest.TestCase):
+ """
+ Integration tests for StorageKeyLoader class.
+
+ Tests the batched loading of storage keys from the database for files
+ with different transfer methods: LOCAL_FILE, REMOTE_URL, and TOOL_FILE.
+ """
+
+ def setUp(self):
+ """Set up test data before each test method."""
+ self.session = db.session()
+ self.tenant_id = str(uuid4())
+ self.user_id = str(uuid4())
+ self.conversation_id = str(uuid4())
+
+ # Create test data that will be cleaned up after each test
+ self.test_upload_files = []
+ self.test_tool_files = []
+
+ # Create StorageKeyLoader instance
+ self.loader = StorageKeyLoader(self.session, self.tenant_id)
+
+ def tearDown(self):
+ """Clean up test data after each test method."""
+ self.session.rollback()
+
+ def _create_upload_file(
+ self, file_id: Optional[str] = None, storage_key: Optional[str] = None, tenant_id: Optional[str] = None
+ ) -> UploadFile:
+ """Helper method to create an UploadFile record for testing."""
+ if file_id is None:
+ file_id = str(uuid4())
+ if storage_key is None:
+ storage_key = f"test_storage_key_{uuid4()}"
+ if tenant_id is None:
+ tenant_id = self.tenant_id
+
+ upload_file = UploadFile(
+ tenant_id=tenant_id,
+ storage_type="local",
+ key=storage_key,
+ name="test_file.txt",
+ size=1024,
+ extension=".txt",
+ mime_type="text/plain",
+ created_by_role=CreatorUserRole.ACCOUNT,
+ created_by=self.user_id,
+ created_at=datetime.now(UTC),
+ used=False,
+ )
+ upload_file.id = file_id
+
+ self.session.add(upload_file)
+ self.session.flush()
+ self.test_upload_files.append(upload_file)
+
+ return upload_file
+
+ def _create_tool_file(
+ self, file_id: Optional[str] = None, file_key: Optional[str] = None, tenant_id: Optional[str] = None
+ ) -> ToolFile:
+ """Helper method to create a ToolFile record for testing."""
+ if file_id is None:
+ file_id = str(uuid4())
+ if file_key is None:
+ file_key = f"test_file_key_{uuid4()}"
+ if tenant_id is None:
+ tenant_id = self.tenant_id
+
+ tool_file = ToolFile()
+ tool_file.id = file_id
+ tool_file.user_id = self.user_id
+ tool_file.tenant_id = tenant_id
+ tool_file.conversation_id = self.conversation_id
+ tool_file.file_key = file_key
+ tool_file.mimetype = "text/plain"
+ tool_file.original_url = "http://example.com/file.txt"
+ tool_file.name = "test_tool_file.txt"
+ tool_file.size = 2048
+
+ self.session.add(tool_file)
+ self.session.flush()
+ self.test_tool_files.append(tool_file)
+
+ return tool_file
+
+ def _create_file(
+ self, related_id: str, transfer_method: FileTransferMethod, tenant_id: Optional[str] = None
+ ) -> File:
+ """Helper method to create a File object for testing."""
+ if tenant_id is None:
+ tenant_id = self.tenant_id
+
+ # Set related_id for LOCAL_FILE and TOOL_FILE transfer methods
+ file_related_id = None
+ remote_url = None
+
+ if transfer_method in (FileTransferMethod.LOCAL_FILE, FileTransferMethod.TOOL_FILE):
+ file_related_id = related_id
+ elif transfer_method == FileTransferMethod.REMOTE_URL:
+ remote_url = "https://example.com/test_file.txt"
+ file_related_id = related_id
+
+ return File(
+ id=str(uuid4()), # Generate new UUID for File.id
+ tenant_id=tenant_id,
+ type=FileType.DOCUMENT,
+ transfer_method=transfer_method,
+ related_id=file_related_id,
+ remote_url=remote_url,
+ filename="test_file.txt",
+ extension=".txt",
+ mime_type="text/plain",
+ size=1024,
+ storage_key="initial_key",
+ )
+
+ def test_load_storage_keys_local_file(self):
+ """Test loading storage keys for LOCAL_FILE transfer method."""
+ # Create test data
+ upload_file = self._create_upload_file()
+ file = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE)
+
+ # Load storage keys
+ self.loader.load_storage_keys([file])
+
+ # Verify storage key was loaded correctly
+ assert file._storage_key == upload_file.key
+
+ def test_load_storage_keys_remote_url(self):
+ """Test loading storage keys for REMOTE_URL transfer method."""
+ # Create test data
+ upload_file = self._create_upload_file()
+ file = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.REMOTE_URL)
+
+ # Load storage keys
+ self.loader.load_storage_keys([file])
+
+ # Verify storage key was loaded correctly
+ assert file._storage_key == upload_file.key
+
+ def test_load_storage_keys_tool_file(self):
+ """Test loading storage keys for TOOL_FILE transfer method."""
+ # Create test data
+ tool_file = self._create_tool_file()
+ file = self._create_file(related_id=tool_file.id, transfer_method=FileTransferMethod.TOOL_FILE)
+
+ # Load storage keys
+ self.loader.load_storage_keys([file])
+
+ # Verify storage key was loaded correctly
+ assert file._storage_key == tool_file.file_key
+
+ def test_load_storage_keys_mixed_methods(self):
+ """Test batch loading with mixed transfer methods."""
+ # Create test data for different transfer methods
+ upload_file1 = self._create_upload_file()
+ upload_file2 = self._create_upload_file()
+ tool_file = self._create_tool_file()
+
+ file1 = self._create_file(related_id=upload_file1.id, transfer_method=FileTransferMethod.LOCAL_FILE)
+ file2 = self._create_file(related_id=upload_file2.id, transfer_method=FileTransferMethod.REMOTE_URL)
+ file3 = self._create_file(related_id=tool_file.id, transfer_method=FileTransferMethod.TOOL_FILE)
+
+ files = [file1, file2, file3]
+
+ # Load storage keys
+ self.loader.load_storage_keys(files)
+
+ # Verify all storage keys were loaded correctly
+ assert file1._storage_key == upload_file1.key
+ assert file2._storage_key == upload_file2.key
+ assert file3._storage_key == tool_file.file_key
+
+ def test_load_storage_keys_empty_list(self):
+ """Test with empty file list."""
+ # Should not raise any exceptions
+ self.loader.load_storage_keys([])
+
+ def test_load_storage_keys_tenant_mismatch(self):
+ """Test tenant_id validation."""
+ # Create file with different tenant_id
+ upload_file = self._create_upload_file()
+ file = self._create_file(
+ related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE, tenant_id=str(uuid4())
+ )
+
+ # Should raise ValueError for tenant mismatch
+ with pytest.raises(ValueError) as context:
+ self.loader.load_storage_keys([file])
+
+ assert "invalid file, expected tenant_id" in str(context.value)
+
+ def test_load_storage_keys_missing_file_id(self):
+ """Test with None file.related_id."""
+ # Create a file with valid parameters first, then manually set related_id to None
+ file = self._create_file(related_id=str(uuid4()), transfer_method=FileTransferMethod.LOCAL_FILE)
+ file.related_id = None
+
+ # Should raise ValueError for None file related_id
+ with pytest.raises(ValueError) as context:
+ self.loader.load_storage_keys([file])
+
+ assert str(context.value) == "file id should not be None."
+
+ def test_load_storage_keys_nonexistent_upload_file_records(self):
+ """Test with missing UploadFile database records."""
+ # Create file with non-existent upload file id
+ non_existent_id = str(uuid4())
+ file = self._create_file(related_id=non_existent_id, transfer_method=FileTransferMethod.LOCAL_FILE)
+
+ # Should raise ValueError for missing record
+ with pytest.raises(ValueError):
+ self.loader.load_storage_keys([file])
+
+ def test_load_storage_keys_nonexistent_tool_file_records(self):
+ """Test with missing ToolFile database records."""
+ # Create file with non-existent tool file id
+ non_existent_id = str(uuid4())
+ file = self._create_file(related_id=non_existent_id, transfer_method=FileTransferMethod.TOOL_FILE)
+
+ # Should raise ValueError for missing record
+ with pytest.raises(ValueError):
+ self.loader.load_storage_keys([file])
+
+ def test_load_storage_keys_invalid_uuid(self):
+ """Test with invalid UUID format."""
+ # Create a file with valid parameters first, then manually set invalid related_id
+ file = self._create_file(related_id=str(uuid4()), transfer_method=FileTransferMethod.LOCAL_FILE)
+ file.related_id = "invalid-uuid-format"
+
+ # Should raise ValueError for invalid UUID
+ with pytest.raises(ValueError):
+ self.loader.load_storage_keys([file])
+
+ def test_load_storage_keys_batch_efficiency(self):
+ """Test batched operations use efficient queries."""
+ # Create multiple files of different types
+ upload_files = [self._create_upload_file() for _ in range(3)]
+ tool_files = [self._create_tool_file() for _ in range(2)]
+
+ files = []
+ files.extend(
+ [self._create_file(related_id=uf.id, transfer_method=FileTransferMethod.LOCAL_FILE) for uf in upload_files]
+ )
+ files.extend(
+ [self._create_file(related_id=tf.id, transfer_method=FileTransferMethod.TOOL_FILE) for tf in tool_files]
+ )
+
+ # Mock the session to count queries
+ with patch.object(self.session, "scalars", wraps=self.session.scalars) as mock_scalars:
+ self.loader.load_storage_keys(files)
+
+ # Should make exactly 2 queries (one for upload_files, one for tool_files)
+ assert mock_scalars.call_count == 2
+
+ # Verify all storage keys were loaded correctly
+ for i, file in enumerate(files[:3]):
+ assert file._storage_key == upload_files[i].key
+ for i, file in enumerate(files[3:]):
+ assert file._storage_key == tool_files[i].file_key
+
+ def test_load_storage_keys_tenant_isolation(self):
+ """Test that tenant isolation works correctly."""
+ # Create files for different tenants
+ other_tenant_id = str(uuid4())
+
+ # Create upload file for current tenant
+ upload_file_current = self._create_upload_file()
+ file_current = self._create_file(
+ related_id=upload_file_current.id, transfer_method=FileTransferMethod.LOCAL_FILE
+ )
+
+ # Create upload file for other tenant (but don't add to cleanup list)
+ upload_file_other = UploadFile(
+ tenant_id=other_tenant_id,
+ storage_type="local",
+ key="other_tenant_key",
+ name="other_file.txt",
+ size=1024,
+ extension=".txt",
+ mime_type="text/plain",
+ created_by_role=CreatorUserRole.ACCOUNT,
+ created_by=self.user_id,
+ created_at=datetime.now(UTC),
+ used=False,
+ )
+ upload_file_other.id = str(uuid4())
+ self.session.add(upload_file_other)
+ self.session.flush()
+
+ # Create file for other tenant but try to load with current tenant's loader
+ file_other = self._create_file(
+ related_id=upload_file_other.id, transfer_method=FileTransferMethod.LOCAL_FILE, tenant_id=other_tenant_id
+ )
+
+ # Should raise ValueError due to tenant mismatch
+ with pytest.raises(ValueError) as context:
+ self.loader.load_storage_keys([file_other])
+
+ assert "invalid file, expected tenant_id" in str(context.value)
+
+ # Current tenant's file should still work
+ self.loader.load_storage_keys([file_current])
+ assert file_current._storage_key == upload_file_current.key
+
+ def test_load_storage_keys_mixed_tenant_batch(self):
+ """Test batch with mixed tenant files (should fail on first mismatch)."""
+ # Create files for current tenant
+ upload_file_current = self._create_upload_file()
+ file_current = self._create_file(
+ related_id=upload_file_current.id, transfer_method=FileTransferMethod.LOCAL_FILE
+ )
+
+ # Create file for different tenant
+ other_tenant_id = str(uuid4())
+ file_other = self._create_file(
+ related_id=str(uuid4()), transfer_method=FileTransferMethod.LOCAL_FILE, tenant_id=other_tenant_id
+ )
+
+ # Should raise ValueError on tenant mismatch
+ with pytest.raises(ValueError) as context:
+ self.loader.load_storage_keys([file_current, file_other])
+
+ assert "invalid file, expected tenant_id" in str(context.value)
+
+ def test_load_storage_keys_duplicate_file_ids(self):
+ """Test handling of duplicate file IDs in the batch."""
+ # Create upload file
+ upload_file = self._create_upload_file()
+
+ # Create two File objects with same related_id
+ file1 = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE)
+ file2 = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE)
+
+ # Should handle duplicates gracefully
+ self.loader.load_storage_keys([file1, file2])
+
+ # Both files should have the same storage key
+ assert file1._storage_key == upload_file.key
+ assert file2._storage_key == upload_file.key
+
+ def test_load_storage_keys_session_isolation(self):
+ """Test that the loader uses the provided session correctly."""
+ # Create test data
+ upload_file = self._create_upload_file()
+ file = self._create_file(related_id=upload_file.id, transfer_method=FileTransferMethod.LOCAL_FILE)
+
+ # Create loader with different session (same underlying connection)
+
+ with Session(bind=db.engine) as other_session:
+ other_loader = StorageKeyLoader(other_session, self.tenant_id)
+ with pytest.raises(ValueError):
+ other_loader.load_storage_keys([file])
diff --git a/api/tests/integration_tests/services/__init__.py b/api/tests/integration_tests/services/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/tests/integration_tests/services/test_workflow_draft_variable_service.py b/api/tests/integration_tests/services/test_workflow_draft_variable_service.py
new file mode 100644
index 0000000000..30cd2e60cb
--- /dev/null
+++ b/api/tests/integration_tests/services/test_workflow_draft_variable_service.py
@@ -0,0 +1,501 @@
+import json
+import unittest
+import uuid
+
+import pytest
+from sqlalchemy.orm import Session
+
+from core.variables.variables import StringVariable
+from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
+from core.workflow.nodes import NodeType
+from factories.variable_factory import build_segment
+from libs import datetime_utils
+from models import db
+from models.workflow import Workflow, WorkflowDraftVariable, WorkflowNodeExecutionModel
+from services.workflow_draft_variable_service import DraftVarLoader, VariableResetError, WorkflowDraftVariableService
+
+
+@pytest.mark.usefixtures("flask_req_ctx")
+class TestWorkflowDraftVariableService(unittest.TestCase):
+ _test_app_id: str
+ _session: Session
+ _node1_id = "test_node_1"
+ _node2_id = "test_node_2"
+ _node_exec_id = str(uuid.uuid4())
+
+ def setUp(self):
+ self._test_app_id = str(uuid.uuid4())
+ self._session: Session = db.session()
+ sys_var = WorkflowDraftVariable.new_sys_variable(
+ app_id=self._test_app_id,
+ name="sys_var",
+ value=build_segment("sys_value"),
+ node_execution_id=self._node_exec_id,
+ )
+ conv_var = WorkflowDraftVariable.new_conversation_variable(
+ app_id=self._test_app_id,
+ name="conv_var",
+ value=build_segment("conv_value"),
+ )
+ node2_vars = [
+ WorkflowDraftVariable.new_node_variable(
+ app_id=self._test_app_id,
+ node_id=self._node2_id,
+ name="int_var",
+ value=build_segment(1),
+ visible=False,
+ node_execution_id=self._node_exec_id,
+ ),
+ WorkflowDraftVariable.new_node_variable(
+ app_id=self._test_app_id,
+ node_id=self._node2_id,
+ name="str_var",
+ value=build_segment("str_value"),
+ visible=True,
+ node_execution_id=self._node_exec_id,
+ ),
+ ]
+ node1_var = WorkflowDraftVariable.new_node_variable(
+ app_id=self._test_app_id,
+ node_id=self._node1_id,
+ name="str_var",
+ value=build_segment("str_value"),
+ visible=True,
+ node_execution_id=self._node_exec_id,
+ )
+ _variables = list(node2_vars)
+ _variables.extend(
+ [
+ node1_var,
+ sys_var,
+ conv_var,
+ ]
+ )
+
+ db.session.add_all(_variables)
+ db.session.flush()
+ self._variable_ids = [v.id for v in _variables]
+ self._node1_str_var_id = node1_var.id
+ self._sys_var_id = sys_var.id
+ self._conv_var_id = conv_var.id
+ self._node2_var_ids = [v.id for v in node2_vars]
+
+ def _get_test_srv(self) -> WorkflowDraftVariableService:
+ return WorkflowDraftVariableService(session=self._session)
+
+ def tearDown(self):
+ self._session.rollback()
+
+ def test_list_variables(self):
+ srv = self._get_test_srv()
+ var_list = srv.list_variables_without_values(self._test_app_id, page=1, limit=2)
+ assert var_list.total == 5
+ assert len(var_list.variables) == 2
+ page1_var_ids = {v.id for v in var_list.variables}
+ assert page1_var_ids.issubset(self._variable_ids)
+
+ var_list_2 = srv.list_variables_without_values(self._test_app_id, page=2, limit=2)
+ assert var_list_2.total is None
+ assert len(var_list_2.variables) == 2
+ page2_var_ids = {v.id for v in var_list_2.variables}
+ assert page2_var_ids.isdisjoint(page1_var_ids)
+ assert page2_var_ids.issubset(self._variable_ids)
+
+ def test_get_node_variable(self):
+ srv = self._get_test_srv()
+ node_var = srv.get_node_variable(self._test_app_id, self._node1_id, "str_var")
+ assert node_var is not None
+ assert node_var.id == self._node1_str_var_id
+ assert node_var.name == "str_var"
+ assert node_var.get_value() == build_segment("str_value")
+
+ def test_get_system_variable(self):
+ srv = self._get_test_srv()
+ sys_var = srv.get_system_variable(self._test_app_id, "sys_var")
+ assert sys_var is not None
+ assert sys_var.id == self._sys_var_id
+ assert sys_var.name == "sys_var"
+ assert sys_var.get_value() == build_segment("sys_value")
+
+ def test_get_conversation_variable(self):
+ srv = self._get_test_srv()
+ conv_var = srv.get_conversation_variable(self._test_app_id, "conv_var")
+ assert conv_var is not None
+ assert conv_var.id == self._conv_var_id
+ assert conv_var.name == "conv_var"
+ assert conv_var.get_value() == build_segment("conv_value")
+
+ def test_delete_node_variables(self):
+ srv = self._get_test_srv()
+ srv.delete_node_variables(self._test_app_id, self._node2_id)
+ node2_var_count = (
+ self._session.query(WorkflowDraftVariable)
+ .where(
+ WorkflowDraftVariable.app_id == self._test_app_id,
+ WorkflowDraftVariable.node_id == self._node2_id,
+ )
+ .count()
+ )
+ assert node2_var_count == 0
+
+ def test_delete_variable(self):
+ srv = self._get_test_srv()
+ node_1_var = (
+ self._session.query(WorkflowDraftVariable).where(WorkflowDraftVariable.id == self._node1_str_var_id).one()
+ )
+ srv.delete_variable(node_1_var)
+ exists = bool(
+ self._session.query(WorkflowDraftVariable).where(WorkflowDraftVariable.id == self._node1_str_var_id).first()
+ )
+ assert exists is False
+
+ def test__list_node_variables(self):
+ srv = self._get_test_srv()
+ node_vars = srv._list_node_variables(self._test_app_id, self._node2_id)
+ assert len(node_vars.variables) == 2
+ assert {v.id for v in node_vars.variables} == set(self._node2_var_ids)
+
+ def test_get_draft_variables_by_selectors(self):
+ srv = self._get_test_srv()
+ selectors = [
+ [self._node1_id, "str_var"],
+ [self._node2_id, "str_var"],
+ [self._node2_id, "int_var"],
+ ]
+ variables = srv.get_draft_variables_by_selectors(self._test_app_id, selectors)
+ assert len(variables) == 3
+ assert {v.id for v in variables} == {self._node1_str_var_id} | set(self._node2_var_ids)
+
+
+@pytest.mark.usefixtures("flask_req_ctx")
+class TestDraftVariableLoader(unittest.TestCase):
+ _test_app_id: str
+ _test_tenant_id: str
+
+ _node1_id = "test_loader_node_1"
+ _node_exec_id = str(uuid.uuid4())
+
+ def setUp(self):
+ self._test_app_id = str(uuid.uuid4())
+ self._test_tenant_id = str(uuid.uuid4())
+ sys_var = WorkflowDraftVariable.new_sys_variable(
+ app_id=self._test_app_id,
+ name="sys_var",
+ value=build_segment("sys_value"),
+ node_execution_id=self._node_exec_id,
+ )
+ conv_var = WorkflowDraftVariable.new_conversation_variable(
+ app_id=self._test_app_id,
+ name="conv_var",
+ value=build_segment("conv_value"),
+ )
+ node_var = WorkflowDraftVariable.new_node_variable(
+ app_id=self._test_app_id,
+ node_id=self._node1_id,
+ name="str_var",
+ value=build_segment("str_value"),
+ visible=True,
+ node_execution_id=self._node_exec_id,
+ )
+ _variables = [
+ node_var,
+ sys_var,
+ conv_var,
+ ]
+
+ with Session(bind=db.engine, expire_on_commit=False) as session:
+ session.add_all(_variables)
+ session.flush()
+ session.commit()
+ self._variable_ids = [v.id for v in _variables]
+ self._node_var_id = node_var.id
+ self._sys_var_id = sys_var.id
+ self._conv_var_id = conv_var.id
+
+ def tearDown(self):
+ with Session(bind=db.engine, expire_on_commit=False) as session:
+ session.query(WorkflowDraftVariable).filter(WorkflowDraftVariable.app_id == self._test_app_id).delete(
+ synchronize_session=False
+ )
+ session.commit()
+
+ def test_variable_loader_with_empty_selector(self):
+ var_loader = DraftVarLoader(engine=db.engine, app_id=self._test_app_id, tenant_id=self._test_tenant_id)
+ variables = var_loader.load_variables([])
+ assert len(variables) == 0
+
+ def test_variable_loader_with_non_empty_selector(self):
+ var_loader = DraftVarLoader(engine=db.engine, app_id=self._test_app_id, tenant_id=self._test_tenant_id)
+ variables = var_loader.load_variables(
+ [
+ [SYSTEM_VARIABLE_NODE_ID, "sys_var"],
+ [CONVERSATION_VARIABLE_NODE_ID, "conv_var"],
+ [self._node1_id, "str_var"],
+ ]
+ )
+ assert len(variables) == 3
+ conv_var = next(v for v in variables if v.selector[0] == CONVERSATION_VARIABLE_NODE_ID)
+ assert conv_var.id == self._conv_var_id
+ sys_var = next(v for v in variables if v.selector[0] == SYSTEM_VARIABLE_NODE_ID)
+ assert sys_var.id == self._sys_var_id
+ node1_var = next(v for v in variables if v.selector[0] == self._node1_id)
+ assert node1_var.id == self._node_var_id
+
+
+@pytest.mark.usefixtures("flask_req_ctx")
+class TestWorkflowDraftVariableServiceResetVariable(unittest.TestCase):
+ """Integration tests for reset_variable functionality using real database"""
+
+ _test_app_id: str
+ _test_tenant_id: str
+ _test_workflow_id: str
+ _session: Session
+ _node_id = "test_reset_node"
+ _node_exec_id: str
+ _workflow_node_exec_id: str
+
+ def setUp(self):
+ self._test_app_id = str(uuid.uuid4())
+ self._test_tenant_id = str(uuid.uuid4())
+ self._test_workflow_id = str(uuid.uuid4())
+ self._node_exec_id = str(uuid.uuid4())
+ self._workflow_node_exec_id = str(uuid.uuid4())
+ self._session: Session = db.session()
+
+ # Create a workflow node execution record with outputs
+ # Note: The WorkflowNodeExecutionModel.id should match the node_execution_id in WorkflowDraftVariable
+ self._workflow_node_execution = WorkflowNodeExecutionModel(
+ id=self._node_exec_id, # This should match the node_execution_id in the variable
+ tenant_id=self._test_tenant_id,
+ app_id=self._test_app_id,
+ workflow_id=self._test_workflow_id,
+ triggered_from="workflow-run",
+ workflow_run_id=str(uuid.uuid4()),
+ index=1,
+ node_execution_id=self._node_exec_id,
+ node_id=self._node_id,
+ node_type=NodeType.LLM.value,
+ title="Test Node",
+ inputs='{"input": "test input"}',
+ process_data='{"test_var": "process_value", "other_var": "other_process"}',
+ outputs='{"test_var": "output_value", "other_var": "other_output"}',
+ status="succeeded",
+ elapsed_time=1.5,
+ created_by_role="account",
+ created_by=str(uuid.uuid4()),
+ )
+
+ # Create conversation variables for the workflow
+ self._conv_variables = [
+ StringVariable(
+ id=str(uuid.uuid4()),
+ name="conv_var_1",
+ description="Test conversation variable 1",
+ value="default_value_1",
+ ),
+ StringVariable(
+ id=str(uuid.uuid4()),
+ name="conv_var_2",
+ description="Test conversation variable 2",
+ value="default_value_2",
+ ),
+ ]
+
+ # Create test variables
+ self._node_var_with_exec = WorkflowDraftVariable.new_node_variable(
+ app_id=self._test_app_id,
+ node_id=self._node_id,
+ name="test_var",
+ value=build_segment("old_value"),
+ node_execution_id=self._node_exec_id,
+ )
+ self._node_var_with_exec.last_edited_at = datetime_utils.naive_utc_now()
+
+ self._node_var_without_exec = WorkflowDraftVariable.new_node_variable(
+ app_id=self._test_app_id,
+ node_id=self._node_id,
+ name="no_exec_var",
+ value=build_segment("some_value"),
+ node_execution_id="temp_exec_id",
+ )
+ # Manually set node_execution_id to None after creation
+ self._node_var_without_exec.node_execution_id = None
+
+ self._node_var_missing_exec = WorkflowDraftVariable.new_node_variable(
+ app_id=self._test_app_id,
+ node_id=self._node_id,
+ name="missing_exec_var",
+ value=build_segment("some_value"),
+ node_execution_id=str(uuid.uuid4()), # Use a valid UUID that doesn't exist in database
+ )
+
+ self._conv_var = WorkflowDraftVariable.new_conversation_variable(
+ app_id=self._test_app_id,
+ name="conv_var_1",
+ value=build_segment("old_conv_value"),
+ )
+ self._conv_var.last_edited_at = datetime_utils.naive_utc_now()
+
+ # Add all to database
+ db.session.add_all(
+ [
+ self._workflow_node_execution,
+ self._node_var_with_exec,
+ self._node_var_without_exec,
+ self._node_var_missing_exec,
+ self._conv_var,
+ ]
+ )
+ db.session.flush()
+
+ # Store IDs for assertions
+ self._node_var_with_exec_id = self._node_var_with_exec.id
+ self._node_var_without_exec_id = self._node_var_without_exec.id
+ self._node_var_missing_exec_id = self._node_var_missing_exec.id
+ self._conv_var_id = self._conv_var.id
+
+ def _get_test_srv(self) -> WorkflowDraftVariableService:
+ return WorkflowDraftVariableService(session=self._session)
+
+ def _create_mock_workflow(self) -> Workflow:
+ """Create a real workflow with conversation variables and graph"""
+ conversation_vars = self._conv_variables
+
+ # Create a simple graph with the test node
+ graph = {
+ "nodes": [{"id": "test_reset_node", "type": "llm", "title": "Test Node", "data": {"type": "llm"}}],
+ "edges": [],
+ }
+
+ workflow = Workflow.new(
+ tenant_id=str(uuid.uuid4()),
+ app_id=self._test_app_id,
+ type="workflow",
+ version="1.0",
+ graph=json.dumps(graph),
+ features="{}",
+ created_by=str(uuid.uuid4()),
+ environment_variables=[],
+ conversation_variables=conversation_vars,
+ )
+ return workflow
+
+ def tearDown(self):
+ self._session.rollback()
+
+ def test_reset_node_variable_with_valid_execution_record(self):
+ """Test resetting a node variable with valid execution record - should restore from execution"""
+ srv = self._get_test_srv()
+ mock_workflow = self._create_mock_workflow()
+
+ # Get the variable before reset
+ variable = srv.get_variable(self._node_var_with_exec_id)
+ assert variable is not None
+ assert variable.get_value().value == "old_value"
+ assert variable.last_edited_at is not None
+
+ # Reset the variable
+ result = srv.reset_variable(mock_workflow, variable)
+
+ # Should return the updated variable
+ assert result is not None
+ assert result.id == self._node_var_with_exec_id
+ assert result.node_execution_id == self._workflow_node_execution.id
+ assert result.last_edited_at is None # Should be reset to None
+
+ # The returned variable should have the updated value from execution record
+ assert result.get_value().value == "output_value"
+
+ # Verify the variable was updated in database
+ updated_variable = srv.get_variable(self._node_var_with_exec_id)
+ assert updated_variable is not None
+ # The value should be updated from the execution record's outputs
+ assert updated_variable.get_value().value == "output_value"
+ assert updated_variable.last_edited_at is None
+ assert updated_variable.node_execution_id == self._workflow_node_execution.id
+
+ def test_reset_node_variable_with_no_execution_id(self):
+ """Test resetting a node variable with no execution ID - should delete variable"""
+ srv = self._get_test_srv()
+ mock_workflow = self._create_mock_workflow()
+
+ # Get the variable before reset
+ variable = srv.get_variable(self._node_var_without_exec_id)
+ assert variable is not None
+
+ # Reset the variable
+ result = srv.reset_variable(mock_workflow, variable)
+
+ # Should return None (variable deleted)
+ assert result is None
+
+ # Verify the variable was deleted
+ deleted_variable = srv.get_variable(self._node_var_without_exec_id)
+ assert deleted_variable is None
+
+ def test_reset_node_variable_with_missing_execution_record(self):
+ """Test resetting a node variable when execution record doesn't exist"""
+ srv = self._get_test_srv()
+ mock_workflow = self._create_mock_workflow()
+
+ # Get the variable before reset
+ variable = srv.get_variable(self._node_var_missing_exec_id)
+ assert variable is not None
+
+ # Reset the variable
+ result = srv.reset_variable(mock_workflow, variable)
+
+ # Should return None (variable deleted)
+ assert result is None
+
+ # Verify the variable was deleted
+ deleted_variable = srv.get_variable(self._node_var_missing_exec_id)
+ assert deleted_variable is None
+
+ def test_reset_conversation_variable(self):
+ """Test resetting a conversation variable"""
+ srv = self._get_test_srv()
+ mock_workflow = self._create_mock_workflow()
+
+ # Get the variable before reset
+ variable = srv.get_variable(self._conv_var_id)
+ assert variable is not None
+ assert variable.get_value().value == "old_conv_value"
+ assert variable.last_edited_at is not None
+
+ # Reset the variable
+ result = srv.reset_variable(mock_workflow, variable)
+
+ # Should return the updated variable
+ assert result is not None
+ assert result.id == self._conv_var_id
+ assert result.last_edited_at is None # Should be reset to None
+
+ # Verify the variable was updated with default value from workflow
+ updated_variable = srv.get_variable(self._conv_var_id)
+ assert updated_variable is not None
+ # The value should be updated from the workflow's conversation variable default
+ assert updated_variable.get_value().value == "default_value_1"
+ assert updated_variable.last_edited_at is None
+
+ def test_reset_system_variable_raises_error(self):
+ """Test that resetting a system variable raises an error"""
+ srv = self._get_test_srv()
+ mock_workflow = self._create_mock_workflow()
+
+ # Create a system variable
+ sys_var = WorkflowDraftVariable.new_sys_variable(
+ app_id=self._test_app_id,
+ name="sys_var",
+ value=build_segment("sys_value"),
+ node_execution_id=self._node_exec_id,
+ )
+ db.session.add(sys_var)
+ db.session.flush()
+
+ # Attempt to reset the system variable
+ with pytest.raises(VariableResetError) as exc_info:
+ srv.reset_variable(mock_workflow, sys_var)
+
+ assert "cannot reset system variable" in str(exc_info.value)
+ assert sys_var.id in str(exc_info.value)
diff --git a/api/tests/integration_tests/vdb/couchbase/test_couchbase.py b/api/tests/integration_tests/vdb/couchbase/test_couchbase.py
index d76c34ba0e..eef1ee4e75 100644
--- a/api/tests/integration_tests/vdb/couchbase/test_couchbase.py
+++ b/api/tests/integration_tests/vdb/couchbase/test_couchbase.py
@@ -4,7 +4,6 @@ import time
from core.rag.datasource.vdb.couchbase.couchbase_vector import CouchbaseConfig, CouchbaseVector
from tests.integration_tests.vdb.test_vector_store import (
AbstractVectorTest,
- get_example_text,
setup_mock_redis,
)
diff --git a/api/tests/integration_tests/vdb/matrixone/__init__.py b/api/tests/integration_tests/vdb/matrixone/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/tests/integration_tests/vdb/matrixone/test_matrixone.py b/api/tests/integration_tests/vdb/matrixone/test_matrixone.py
new file mode 100644
index 0000000000..c4056db63e
--- /dev/null
+++ b/api/tests/integration_tests/vdb/matrixone/test_matrixone.py
@@ -0,0 +1,24 @@
+from core.rag.datasource.vdb.matrixone.matrixone_vector import MatrixoneConfig, MatrixoneVector
+from tests.integration_tests.vdb.test_vector_store import (
+ AbstractVectorTest,
+ setup_mock_redis,
+)
+
+
+class MatrixoneVectorTest(AbstractVectorTest):
+ def __init__(self):
+ super().__init__()
+ self.vector = MatrixoneVector(
+ collection_name=self.collection_name,
+ config=MatrixoneConfig(
+ host="localhost", port=6001, user="dump", password="111", database="dify", metric="l2"
+ ),
+ )
+
+ def get_ids_by_metadata_field(self):
+ ids = self.vector.get_ids_by_metadata_field(key="document_id", value=self.example_doc_id)
+ assert len(ids) == 1
+
+
+def test_matrixone_vector(setup_mock_redis):
+ MatrixoneVectorTest().run_all_tests()
diff --git a/api/tests/integration_tests/vdb/oceanbase/test_oceanbase.py b/api/tests/integration_tests/vdb/oceanbase/test_oceanbase.py
index ebcb134168..8fbbbe61b8 100644
--- a/api/tests/integration_tests/vdb/oceanbase/test_oceanbase.py
+++ b/api/tests/integration_tests/vdb/oceanbase/test_oceanbase.py
@@ -1,15 +1,11 @@
-from unittest.mock import MagicMock, patch
-
import pytest
from core.rag.datasource.vdb.oceanbase.oceanbase_vector import (
OceanBaseVector,
OceanBaseVectorConfig,
)
-from tests.integration_tests.vdb.__mock.tcvectordb import setup_tcvectordb_mock
from tests.integration_tests.vdb.test_vector_store import (
AbstractVectorTest,
- get_example_text,
setup_mock_redis,
)
@@ -20,10 +16,11 @@ def oceanbase_vector():
"dify_test_collection",
config=OceanBaseVectorConfig(
host="127.0.0.1",
- port="2881",
- user="root@test",
+ port=2881,
+ user="root",
database="test",
- password="test",
+ password="difyai123456",
+ enable_hybrid_search=True,
),
)
@@ -33,39 +30,13 @@ class OceanBaseVectorTest(AbstractVectorTest):
super().__init__()
self.vector = vector
- def search_by_vector(self):
- hits_by_vector = self.vector.search_by_vector(query_vector=self.example_embedding)
- assert len(hits_by_vector) == 0
-
- def search_by_full_text(self):
- hits_by_full_text = self.vector.search_by_full_text(query=get_example_text())
- assert len(hits_by_full_text) == 0
-
- def text_exists(self):
- exist = self.vector.text_exists(self.example_doc_id)
- assert exist == True
-
def get_ids_by_metadata_field(self):
ids = self.vector.get_ids_by_metadata_field(key="document_id", value=self.example_doc_id)
- assert len(ids) == 0
-
-
-@pytest.fixture
-def setup_mock_oceanbase_client():
- with patch("core.rag.datasource.vdb.oceanbase.oceanbase_vector.ObVecClient", new_callable=MagicMock) as mock_client:
- yield mock_client
-
-
-@pytest.fixture
-def setup_mock_oceanbase_vector(oceanbase_vector):
- with patch.object(oceanbase_vector, "_client"):
- yield oceanbase_vector
+ assert len(ids) == 1
def test_oceanbase_vector(
setup_mock_redis,
- setup_mock_oceanbase_client,
- setup_mock_oceanbase_vector,
oceanbase_vector,
):
OceanBaseVectorTest(oceanbase_vector).run_all_tests()
diff --git a/api/tests/integration_tests/vdb/opengauss/test_opengauss.py b/api/tests/integration_tests/vdb/opengauss/test_opengauss.py
index f2013848bf..2a1129493c 100644
--- a/api/tests/integration_tests/vdb/opengauss/test_opengauss.py
+++ b/api/tests/integration_tests/vdb/opengauss/test_opengauss.py
@@ -5,7 +5,6 @@ import psycopg2 # type: ignore
from core.rag.datasource.vdb.opengauss.opengauss import OpenGauss, OpenGaussConfig
from tests.integration_tests.vdb.test_vector_store import (
AbstractVectorTest,
- get_example_text,
setup_mock_redis,
)
diff --git a/api/tests/integration_tests/vdb/pyvastbase/test_vastbase_vector.py b/api/tests/integration_tests/vdb/pyvastbase/test_vastbase_vector.py
index 3d7873442b..02931fef5a 100644
--- a/api/tests/integration_tests/vdb/pyvastbase/test_vastbase_vector.py
+++ b/api/tests/integration_tests/vdb/pyvastbase/test_vastbase_vector.py
@@ -1,7 +1,6 @@
from core.rag.datasource.vdb.pyvastbase.vastbase_vector import VastbaseVector, VastbaseVectorConfig
from tests.integration_tests.vdb.test_vector_store import (
AbstractVectorTest,
- get_example_text,
setup_mock_redis,
)
diff --git a/api/tests/integration_tests/workflow/nodes/test_llm.py b/api/tests/integration_tests/workflow/nodes/test_llm.py
index 6aa48b1cbb..8acaa54b9c 100644
--- a/api/tests/integration_tests/workflow/nodes/test_llm.py
+++ b/api/tests/integration_tests/workflow/nodes/test_llm.py
@@ -1,5 +1,4 @@
import json
-import os
import time
import uuid
from collections.abc import Generator
@@ -8,9 +7,8 @@ from unittest.mock import MagicMock, patch
import pytest
-from app_factory import create_app
-from configs import dify_config
from core.app.entities.app_invoke_entities import InvokeFrom
+from core.llm_generator.output_parser.structured_output import _parse_structured_output
from core.model_runtime.entities.llm_entities import LLMResult, LLMUsage
from core.model_runtime.entities.message_entities import AssistantPromptMessage
from core.workflow.entities.variable_pool import VariablePool
@@ -30,21 +28,6 @@ from tests.integration_tests.model_runtime.__mock.plugin_daemon import setup_mod
from tests.integration_tests.workflow.nodes.__mock.code_executor import setup_code_executor_mock
-@pytest.fixture(scope="session")
-def app():
- # Set up storage configuration
- os.environ["STORAGE_TYPE"] = "opendal"
- os.environ["OPENDAL_SCHEME"] = "fs"
- os.environ["OPENDAL_FS_ROOT"] = "storage"
-
- # Ensure storage directory exists
- os.makedirs("storage", exist_ok=True)
-
- app = create_app()
- dify_config.LOGIN_DISABLED = True
- return app
-
-
def init_llm_node(config: dict) -> LLMNode:
graph_config = {
"edges": [
@@ -102,200 +85,99 @@ def init_llm_node(config: dict) -> LLMNode:
return node
-def test_execute_llm(app):
- with app.app_context():
- node = init_llm_node(
- config={
- "id": "llm",
- "data": {
- "title": "123",
- "type": "llm",
- "model": {
- "provider": "langgenius/openai/openai",
- "name": "gpt-3.5-turbo",
- "mode": "chat",
- "completion_params": {},
- },
- "prompt_template": [
- {
- "role": "system",
- "text": "you are a helpful assistant.\ntoday's weather is {{#abc.output#}}.",
- },
- {"role": "user", "text": "{{#sys.query#}}"},
- ],
- "memory": None,
- "context": {"enabled": False},
- "vision": {"enabled": False},
+def test_execute_llm(flask_req_ctx):
+ node = init_llm_node(
+ config={
+ "id": "llm",
+ "data": {
+ "title": "123",
+ "type": "llm",
+ "model": {
+ "provider": "langgenius/openai/openai",
+ "name": "gpt-3.5-turbo",
+ "mode": "chat",
+ "completion_params": {},
},
+ "prompt_template": [
+ {
+ "role": "system",
+ "text": "you are a helpful assistant.\ntoday's weather is {{#abc.output#}}.",
+ },
+ {"role": "user", "text": "{{#sys.query#}}"},
+ ],
+ "memory": None,
+ "context": {"enabled": False},
+ "vision": {"enabled": False},
},
- )
-
- credentials = {"openai_api_key": os.environ.get("OPENAI_API_KEY")}
-
- # Create a proper LLM result with real entities
- mock_usage = LLMUsage(
- prompt_tokens=30,
- prompt_unit_price=Decimal("0.001"),
- prompt_price_unit=Decimal("1000"),
- prompt_price=Decimal("0.00003"),
- completion_tokens=20,
- completion_unit_price=Decimal("0.002"),
- completion_price_unit=Decimal("1000"),
- completion_price=Decimal("0.00004"),
- total_tokens=50,
- total_price=Decimal("0.00007"),
- currency="USD",
- latency=0.5,
- )
-
- mock_message = AssistantPromptMessage(content="This is a test response from the mocked LLM.")
-
- mock_llm_result = LLMResult(
- model="gpt-3.5-turbo",
- prompt_messages=[],
- message=mock_message,
- usage=mock_usage,
- )
-
- # Create a simple mock model instance that doesn't call real providers
- mock_model_instance = MagicMock()
- mock_model_instance.invoke_llm.return_value = mock_llm_result
-
- # Create a simple mock model config with required attributes
- mock_model_config = MagicMock()
- mock_model_config.mode = "chat"
- mock_model_config.provider = "langgenius/openai/openai"
- mock_model_config.model = "gpt-3.5-turbo"
- mock_model_config.provider_model_bundle.configuration.tenant_id = "9d2074fc-6f86-45a9-b09d-6ecc63b9056b"
-
- # Mock the _fetch_model_config method
- def mock_fetch_model_config_func(_node_data_model):
- return mock_model_instance, mock_model_config
-
- # Also mock ModelManager.get_model_instance to avoid database calls
- def mock_get_model_instance(_self, **kwargs):
- return mock_model_instance
-
- with (
- patch.object(node, "_fetch_model_config", mock_fetch_model_config_func),
- patch("core.model_manager.ModelManager.get_model_instance", mock_get_model_instance),
- ):
- # execute node
- result = node._run()
- assert isinstance(result, Generator)
-
- for item in result:
- if isinstance(item, RunCompletedEvent):
- assert item.run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED
- assert item.run_result.process_data is not None
- assert item.run_result.outputs is not None
- assert item.run_result.outputs.get("text") is not None
- assert item.run_result.outputs.get("usage", {})["total_tokens"] > 0
+ },
+ )
+
+ # Create a proper LLM result with real entities
+ mock_usage = LLMUsage(
+ prompt_tokens=30,
+ prompt_unit_price=Decimal("0.001"),
+ prompt_price_unit=Decimal(1000),
+ prompt_price=Decimal("0.00003"),
+ completion_tokens=20,
+ completion_unit_price=Decimal("0.002"),
+ completion_price_unit=Decimal(1000),
+ completion_price=Decimal("0.00004"),
+ total_tokens=50,
+ total_price=Decimal("0.00007"),
+ currency="USD",
+ latency=0.5,
+ )
+
+ mock_message = AssistantPromptMessage(content="This is a test response from the mocked LLM.")
+
+ mock_llm_result = LLMResult(
+ model="gpt-3.5-turbo",
+ prompt_messages=[],
+ message=mock_message,
+ usage=mock_usage,
+ )
+
+ # Create a simple mock model instance that doesn't call real providers
+ mock_model_instance = MagicMock()
+ mock_model_instance.invoke_llm.return_value = mock_llm_result
+
+ # Create a simple mock model config with required attributes
+ mock_model_config = MagicMock()
+ mock_model_config.mode = "chat"
+ mock_model_config.provider = "langgenius/openai/openai"
+ mock_model_config.model = "gpt-3.5-turbo"
+ mock_model_config.provider_model_bundle.configuration.tenant_id = "9d2074fc-6f86-45a9-b09d-6ecc63b9056b"
+
+ # Mock the _fetch_model_config method
+ def mock_fetch_model_config_func(_node_data_model):
+ return mock_model_instance, mock_model_config
+
+ # Also mock ModelManager.get_model_instance to avoid database calls
+ def mock_get_model_instance(_self, **kwargs):
+ return mock_model_instance
+
+ with (
+ patch.object(node, "_fetch_model_config", mock_fetch_model_config_func),
+ patch("core.model_manager.ModelManager.get_model_instance", mock_get_model_instance),
+ ):
+ # execute node
+ result = node._run()
+ assert isinstance(result, Generator)
+
+ for item in result:
+ if isinstance(item, RunCompletedEvent):
+ assert item.run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED
+ assert item.run_result.process_data is not None
+ assert item.run_result.outputs is not None
+ assert item.run_result.outputs.get("text") is not None
+ assert item.run_result.outputs.get("usage", {})["total_tokens"] > 0
@pytest.mark.parametrize("setup_code_executor_mock", [["none"]], indirect=True)
-def test_execute_llm_with_jinja2(app, setup_code_executor_mock):
+def test_execute_llm_with_jinja2(flask_req_ctx, setup_code_executor_mock):
"""
Test execute LLM node with jinja2
"""
- with app.app_context():
- node = init_llm_node(
- config={
- "id": "llm",
- "data": {
- "title": "123",
- "type": "llm",
- "model": {"provider": "openai", "name": "gpt-3.5-turbo", "mode": "chat", "completion_params": {}},
- "prompt_config": {
- "jinja2_variables": [
- {"variable": "sys_query", "value_selector": ["sys", "query"]},
- {"variable": "output", "value_selector": ["abc", "output"]},
- ]
- },
- "prompt_template": [
- {
- "role": "system",
- "text": "you are a helpful assistant.\ntoday's weather is {{#abc.output#}}",
- "jinja2_text": "you are a helpful assistant.\ntoday's weather is {{output}}.",
- "edition_type": "jinja2",
- },
- {
- "role": "user",
- "text": "{{#sys.query#}}",
- "jinja2_text": "{{sys_query}}",
- "edition_type": "basic",
- },
- ],
- "memory": None,
- "context": {"enabled": False},
- "vision": {"enabled": False},
- },
- },
- )
-
- # Mock db.session.close()
- db.session.close = MagicMock()
-
- # Create a proper LLM result with real entities
- mock_usage = LLMUsage(
- prompt_tokens=30,
- prompt_unit_price=Decimal("0.001"),
- prompt_price_unit=Decimal("1000"),
- prompt_price=Decimal("0.00003"),
- completion_tokens=20,
- completion_unit_price=Decimal("0.002"),
- completion_price_unit=Decimal("1000"),
- completion_price=Decimal("0.00004"),
- total_tokens=50,
- total_price=Decimal("0.00007"),
- currency="USD",
- latency=0.5,
- )
-
- mock_message = AssistantPromptMessage(content="Test response: sunny weather and what's the weather today?")
-
- mock_llm_result = LLMResult(
- model="gpt-3.5-turbo",
- prompt_messages=[],
- message=mock_message,
- usage=mock_usage,
- )
-
- # Create a simple mock model instance that doesn't call real providers
- mock_model_instance = MagicMock()
- mock_model_instance.invoke_llm.return_value = mock_llm_result
-
- # Create a simple mock model config with required attributes
- mock_model_config = MagicMock()
- mock_model_config.mode = "chat"
- mock_model_config.provider = "openai"
- mock_model_config.model = "gpt-3.5-turbo"
- mock_model_config.provider_model_bundle.configuration.tenant_id = "9d2074fc-6f86-45a9-b09d-6ecc63b9056b"
-
- # Mock the _fetch_model_config method
- def mock_fetch_model_config_func(_node_data_model):
- return mock_model_instance, mock_model_config
-
- # Also mock ModelManager.get_model_instance to avoid database calls
- def mock_get_model_instance(_self, **kwargs):
- return mock_model_instance
-
- with (
- patch.object(node, "_fetch_model_config", mock_fetch_model_config_func),
- patch("core.model_manager.ModelManager.get_model_instance", mock_get_model_instance),
- ):
- # execute node
- result = node._run()
-
- for item in result:
- if isinstance(item, RunCompletedEvent):
- assert item.run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED
- assert item.run_result.process_data is not None
- assert "sunny" in json.dumps(item.run_result.process_data)
- assert "what's the weather today?" in json.dumps(item.run_result.process_data)
-
-
-def test_extract_json():
node = init_llm_node(
config={
"id": "llm",
@@ -304,21 +186,95 @@ def test_extract_json():
"type": "llm",
"model": {"provider": "openai", "name": "gpt-3.5-turbo", "mode": "chat", "completion_params": {}},
"prompt_config": {
- "structured_output": {
- "enabled": True,
- "schema": {
- "type": "object",
- "properties": {"name": {"type": "string"}, "age": {"type": "number"}},
- },
- }
+ "jinja2_variables": [
+ {"variable": "sys_query", "value_selector": ["sys", "query"]},
+ {"variable": "output", "value_selector": ["abc", "output"]},
+ ]
},
- "prompt_template": [{"role": "user", "text": "{{#sys.query#}}"}],
+ "prompt_template": [
+ {
+ "role": "system",
+ "text": "you are a helpful assistant.\ntoday's weather is {{#abc.output#}}",
+ "jinja2_text": "you are a helpful assistant.\ntoday's weather is {{output}}.",
+ "edition_type": "jinja2",
+ },
+ {
+ "role": "user",
+ "text": "{{#sys.query#}}",
+ "jinja2_text": "{{sys_query}}",
+ "edition_type": "basic",
+ },
+ ],
"memory": None,
"context": {"enabled": False},
"vision": {"enabled": False},
},
},
)
+
+ # Mock db.session.close()
+ db.session.close = MagicMock()
+
+ # Create a proper LLM result with real entities
+ mock_usage = LLMUsage(
+ prompt_tokens=30,
+ prompt_unit_price=Decimal("0.001"),
+ prompt_price_unit=Decimal(1000),
+ prompt_price=Decimal("0.00003"),
+ completion_tokens=20,
+ completion_unit_price=Decimal("0.002"),
+ completion_price_unit=Decimal(1000),
+ completion_price=Decimal("0.00004"),
+ total_tokens=50,
+ total_price=Decimal("0.00007"),
+ currency="USD",
+ latency=0.5,
+ )
+
+ mock_message = AssistantPromptMessage(content="Test response: sunny weather and what's the weather today?")
+
+ mock_llm_result = LLMResult(
+ model="gpt-3.5-turbo",
+ prompt_messages=[],
+ message=mock_message,
+ usage=mock_usage,
+ )
+
+ # Create a simple mock model instance that doesn't call real providers
+ mock_model_instance = MagicMock()
+ mock_model_instance.invoke_llm.return_value = mock_llm_result
+
+ # Create a simple mock model config with required attributes
+ mock_model_config = MagicMock()
+ mock_model_config.mode = "chat"
+ mock_model_config.provider = "openai"
+ mock_model_config.model = "gpt-3.5-turbo"
+ mock_model_config.provider_model_bundle.configuration.tenant_id = "9d2074fc-6f86-45a9-b09d-6ecc63b9056b"
+
+ # Mock the _fetch_model_config method
+ def mock_fetch_model_config_func(_node_data_model):
+ return mock_model_instance, mock_model_config
+
+ # Also mock ModelManager.get_model_instance to avoid database calls
+ def mock_get_model_instance(_self, **kwargs):
+ return mock_model_instance
+
+ with (
+ patch.object(node, "_fetch_model_config", mock_fetch_model_config_func),
+ patch("core.model_manager.ModelManager.get_model_instance", mock_get_model_instance),
+ ):
+ # execute node
+ result = node._run()
+
+ for item in result:
+ if isinstance(item, RunCompletedEvent):
+ assert item.run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED
+ assert item.run_result.process_data is not None
+ assert "sunny" in json.dumps(item.run_result.process_data)
+ assert "what's the weather today?" in json.dumps(item.run_result.process_data)
+
+
+def test_extract_json():
llm_texts = [
'\n\n{"name": "test", "age": 123', # resoning model (deepseek-r1)
'{"name":"test","age":123}', # json schema model (gpt-4o)
@@ -327,4 +283,4 @@ def test_extract_json():
'{"name":"test",age:123}', # without quotes (qwen-2.5-0.5b)
]
result = {"name": "test", "age": 123}
- assert all(node._parse_structured_output(item) == result for item in llm_texts)
+ assert all(_parse_structured_output(item) == result for item in llm_texts)
diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py
index cac0a688cd..e9d4ee1935 100644
--- a/api/tests/unit_tests/configs/test_dify_config.py
+++ b/api/tests/unit_tests/configs/test_dify_config.py
@@ -1,6 +1,7 @@
import os
from flask import Flask
+from packaging.version import Version
from yarl import URL
from configs.app_config import DifyConfig
@@ -40,6 +41,9 @@ def test_dify_config(monkeypatch):
assert config.WORKFLOW_PARALLEL_DEPTH_LIMIT == 3
+ # values from pyproject.toml
+ assert Version(config.project.version) >= Version("1.0.0")
+
# NOTE: If there is a `.env` file in your Workspace, this test might not succeed as expected.
# This is due to `pymilvus` loading all the variables from the `.env` file into `os.environ`.
@@ -84,6 +88,7 @@ def test_flask_configs(monkeypatch):
"pool_pre_ping": False,
"pool_recycle": 3600,
"pool_size": 30,
+ "pool_use_lifo": False,
}
assert config["CONSOLE_WEB_URL"] == "https://example.com"
diff --git a/api/tests/unit_tests/conftest.py b/api/tests/unit_tests/conftest.py
index e09acc4c39..077ffe3408 100644
--- a/api/tests/unit_tests/conftest.py
+++ b/api/tests/unit_tests/conftest.py
@@ -1,4 +1,5 @@
import os
+from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
@@ -11,6 +12,24 @@ PROJECT_DIR = os.path.abspath(os.path.join(ABS_PATH, os.pardir, os.pardir))
CACHED_APP = Flask(__name__)
+# set global mock for Redis client
+redis_mock = MagicMock()
+redis_mock.get = MagicMock(return_value=None)
+redis_mock.setex = MagicMock()
+redis_mock.setnx = MagicMock()
+redis_mock.delete = MagicMock()
+redis_mock.lock = MagicMock()
+redis_mock.exists = MagicMock(return_value=False)
+redis_mock.set = MagicMock()
+redis_mock.expire = MagicMock()
+redis_mock.hgetall = MagicMock(return_value={})
+redis_mock.hdel = MagicMock()
+redis_mock.incr = MagicMock(return_value=1)
+
+# apply the mock to the Redis client in the Flask app
+redis_patcher = patch("extensions.ext_redis.redis_client", redis_mock)
+redis_patcher.start()
+
@pytest.fixture
def app() -> Flask:
@@ -21,3 +40,19 @@ def app() -> Flask:
def _provide_app_context(app: Flask):
with app.app_context():
yield
+
+
+@pytest.fixture(autouse=True)
+def reset_redis_mock():
+ """reset the Redis mock before each test"""
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = None
+ redis_mock.setex.return_value = None
+ redis_mock.setnx.return_value = None
+ redis_mock.delete.return_value = None
+ redis_mock.exists.return_value = False
+ redis_mock.set.return_value = None
+ redis_mock.expire.return_value = None
+ redis_mock.hgetall.return_value = {}
+ redis_mock.hdel.return_value = None
+ redis_mock.incr.return_value = 1
diff --git a/api/tests/unit_tests/controllers/console/app/workflow_draft_variables_test.py b/api/tests/unit_tests/controllers/console/app/workflow_draft_variables_test.py
new file mode 100644
index 0000000000..f26be6702a
--- /dev/null
+++ b/api/tests/unit_tests/controllers/console/app/workflow_draft_variables_test.py
@@ -0,0 +1,302 @@
+import datetime
+import uuid
+from collections import OrderedDict
+from typing import Any, NamedTuple
+
+from flask_restful import marshal
+
+from controllers.console.app.workflow_draft_variable import (
+ _WORKFLOW_DRAFT_VARIABLE_FIELDS,
+ _WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS,
+ _WORKFLOW_DRAFT_VARIABLE_LIST_WITHOUT_VALUE_FIELDS,
+ _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS,
+)
+from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
+from factories.variable_factory import build_segment
+from models.workflow import WorkflowDraftVariable
+from services.workflow_draft_variable_service import WorkflowDraftVariableList
+
+_TEST_APP_ID = "test_app_id"
+_TEST_NODE_EXEC_ID = str(uuid.uuid4())
+
+
+class TestWorkflowDraftVariableFields:
+ def test_conversation_variable(self):
+ conv_var = WorkflowDraftVariable.new_conversation_variable(
+ app_id=_TEST_APP_ID, name="conv_var", value=build_segment(1)
+ )
+
+ conv_var.id = str(uuid.uuid4())
+ conv_var.visible = True
+
+ expected_without_value: OrderedDict[str, Any] = OrderedDict(
+ {
+ "id": str(conv_var.id),
+ "type": conv_var.get_variable_type().value,
+ "name": "conv_var",
+ "description": "",
+ "selector": [CONVERSATION_VARIABLE_NODE_ID, "conv_var"],
+ "value_type": "number",
+ "edited": False,
+ "visible": True,
+ }
+ )
+
+ assert marshal(conv_var, _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS) == expected_without_value
+ expected_with_value = expected_without_value.copy()
+ expected_with_value["value"] = 1
+ assert marshal(conv_var, _WORKFLOW_DRAFT_VARIABLE_FIELDS) == expected_with_value
+
+ def test_create_sys_variable(self):
+ sys_var = WorkflowDraftVariable.new_sys_variable(
+ app_id=_TEST_APP_ID,
+ name="sys_var",
+ value=build_segment("a"),
+ editable=True,
+ node_execution_id=_TEST_NODE_EXEC_ID,
+ )
+
+ sys_var.id = str(uuid.uuid4())
+ sys_var.last_edited_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
+ sys_var.visible = True
+
+ expected_without_value = OrderedDict(
+ {
+ "id": str(sys_var.id),
+ "type": sys_var.get_variable_type().value,
+ "name": "sys_var",
+ "description": "",
+ "selector": [SYSTEM_VARIABLE_NODE_ID, "sys_var"],
+ "value_type": "string",
+ "edited": True,
+ "visible": True,
+ }
+ )
+ assert marshal(sys_var, _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS) == expected_without_value
+ expected_with_value = expected_without_value.copy()
+ expected_with_value["value"] = "a"
+ assert marshal(sys_var, _WORKFLOW_DRAFT_VARIABLE_FIELDS) == expected_with_value
+
+ def test_node_variable(self):
+ node_var = WorkflowDraftVariable.new_node_variable(
+ app_id=_TEST_APP_ID,
+ node_id="test_node",
+ name="node_var",
+ value=build_segment([1, "a"]),
+ visible=False,
+ node_execution_id=_TEST_NODE_EXEC_ID,
+ )
+
+ node_var.id = str(uuid.uuid4())
+ node_var.last_edited_at = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
+
+ expected_without_value: OrderedDict[str, Any] = OrderedDict(
+ {
+ "id": str(node_var.id),
+ "type": node_var.get_variable_type().value,
+ "name": "node_var",
+ "description": "",
+ "selector": ["test_node", "node_var"],
+ "value_type": "array[any]",
+ "edited": True,
+ "visible": False,
+ }
+ )
+
+ assert marshal(node_var, _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS) == expected_without_value
+ expected_with_value = expected_without_value.copy()
+ expected_with_value["value"] = [1, "a"]
+ assert marshal(node_var, _WORKFLOW_DRAFT_VARIABLE_FIELDS) == expected_with_value
+
+
+class TestWorkflowDraftVariableList:
+ def test_workflow_draft_variable_list(self):
+ class TestCase(NamedTuple):
+ name: str
+ var_list: WorkflowDraftVariableList
+ expected: dict
+
+ node_var = WorkflowDraftVariable.new_node_variable(
+ app_id=_TEST_APP_ID,
+ node_id="test_node",
+ name="test_var",
+ value=build_segment("a"),
+ visible=True,
+ node_execution_id=_TEST_NODE_EXEC_ID,
+ )
+ node_var.id = str(uuid.uuid4())
+ node_var_dict = OrderedDict(
+ {
+ "id": str(node_var.id),
+ "type": node_var.get_variable_type().value,
+ "name": "test_var",
+ "description": "",
+ "selector": ["test_node", "test_var"],
+ "value_type": "string",
+ "edited": False,
+ "visible": True,
+ }
+ )
+
+ cases = [
+ TestCase(
+ name="empty variable list",
+ var_list=WorkflowDraftVariableList(variables=[]),
+ expected=OrderedDict(
+ {
+ "items": [],
+ "total": None,
+ }
+ ),
+ ),
+ TestCase(
+ name="empty variable list with total",
+ var_list=WorkflowDraftVariableList(variables=[], total=10),
+ expected=OrderedDict(
+ {
+ "items": [],
+ "total": 10,
+ }
+ ),
+ ),
+ TestCase(
+ name="non-empty variable list",
+ var_list=WorkflowDraftVariableList(variables=[node_var], total=None),
+ expected=OrderedDict(
+ {
+ "items": [node_var_dict],
+ "total": None,
+ }
+ ),
+ ),
+ TestCase(
+ name="non-empty variable list with total",
+ var_list=WorkflowDraftVariableList(variables=[node_var], total=10),
+ expected=OrderedDict(
+ {
+ "items": [node_var_dict],
+ "total": 10,
+ }
+ ),
+ ),
+ ]
+
+ for idx, case in enumerate(cases, 1):
+ assert marshal(case.var_list, _WORKFLOW_DRAFT_VARIABLE_LIST_WITHOUT_VALUE_FIELDS) == case.expected, (
+ f"Test case {idx} failed, {case.name=}"
+ )
+
+
+def test_workflow_node_variables_fields():
+ conv_var = WorkflowDraftVariable.new_conversation_variable(
+ app_id=_TEST_APP_ID, name="conv_var", value=build_segment(1)
+ )
+ resp = marshal(WorkflowDraftVariableList(variables=[conv_var]), _WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS)
+ assert isinstance(resp, dict)
+ assert len(resp["items"]) == 1
+ item_dict = resp["items"][0]
+ assert item_dict["name"] == "conv_var"
+ assert item_dict["value"] == 1
+
+
+def test_workflow_file_variable_with_signed_url():
+ """Test that File type variables include signed URLs in API responses."""
+ from core.file.enums import FileTransferMethod, FileType
+ from core.file.models import File
+
+ # Create a File object with LOCAL_FILE transfer method (which generates signed URLs)
+ test_file = File(
+ id="test_file_id",
+ tenant_id="test_tenant_id",
+ type=FileType.IMAGE,
+ transfer_method=FileTransferMethod.LOCAL_FILE,
+ related_id="test_upload_file_id",
+ filename="test.jpg",
+ extension=".jpg",
+ mime_type="image/jpeg",
+ size=12345,
+ )
+
+ # Create a WorkflowDraftVariable with the File
+ file_var = WorkflowDraftVariable.new_node_variable(
+ app_id=_TEST_APP_ID,
+ node_id="test_node",
+ name="file_var",
+ value=build_segment(test_file),
+ node_execution_id=_TEST_NODE_EXEC_ID,
+ )
+
+ # Marshal the variable using the API fields
+ resp = marshal(WorkflowDraftVariableList(variables=[file_var]), _WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS)
+
+ # Verify the response structure
+ assert isinstance(resp, dict)
+ assert len(resp["items"]) == 1
+ item_dict = resp["items"][0]
+ assert item_dict["name"] == "file_var"
+
+ # Verify the value is a dict (File.to_dict() result) and contains expected fields
+ value = item_dict["value"]
+ assert isinstance(value, dict)
+
+ # Verify the File fields are preserved
+ assert value["id"] == test_file.id
+ assert value["filename"] == test_file.filename
+ assert value["type"] == test_file.type.value
+ assert value["transfer_method"] == test_file.transfer_method.value
+ assert value["size"] == test_file.size
+
+ # Verify the URL is present (it should be a signed URL for LOCAL_FILE transfer method)
+ remote_url = value["remote_url"]
+ assert remote_url is not None
+
+ assert isinstance(remote_url, str)
+ # For LOCAL_FILE, the URL should contain signature parameters
+ assert "timestamp=" in remote_url
+ assert "nonce=" in remote_url
+ assert "sign=" in remote_url
+
+
+def test_workflow_file_variable_remote_url():
+ """Test that File type variables with REMOTE_URL transfer method return the remote URL."""
+ from core.file.enums import FileTransferMethod, FileType
+ from core.file.models import File
+
+ # Create a File object with REMOTE_URL transfer method
+ test_file = File(
+ id="test_file_id",
+ tenant_id="test_tenant_id",
+ type=FileType.IMAGE,
+ transfer_method=FileTransferMethod.REMOTE_URL,
+ remote_url="https://example.com/test.jpg",
+ filename="test.jpg",
+ extension=".jpg",
+ mime_type="image/jpeg",
+ size=12345,
+ )
+
+ # Create a WorkflowDraftVariable with the File
+ file_var = WorkflowDraftVariable.new_node_variable(
+ app_id=_TEST_APP_ID,
+ node_id="test_node",
+ name="file_var",
+ value=build_segment(test_file),
+ node_execution_id=_TEST_NODE_EXEC_ID,
+ )
+
+ # Marshal the variable using the API fields
+ resp = marshal(WorkflowDraftVariableList(variables=[file_var]), _WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS)
+
+ # Verify the response structure
+ assert isinstance(resp, dict)
+ assert len(resp["items"]) == 1
+ item_dict = resp["items"][0]
+ assert item_dict["name"] == "file_var"
+
+ # Verify the value is a dict (File.to_dict() result) and contains expected fields
+ value = item_dict["value"]
+ assert isinstance(value, dict)
+ remote_url = value["remote_url"]
+
+ # For REMOTE_URL, the URL should be the original remote URL
+ assert remote_url == test_file.remote_url
diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter.py
new file mode 100644
index 0000000000..b88a57bfd4
--- /dev/null
+++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter.py
@@ -0,0 +1,259 @@
+from collections.abc import Mapping, Sequence
+
+from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
+from core.file import FILE_MODEL_IDENTITY, File, FileTransferMethod, FileType
+from core.variables.segments import ArrayFileSegment, FileSegment
+
+
+class TestWorkflowResponseConverterFetchFilesFromVariableValue:
+ """Test class for WorkflowResponseConverter._fetch_files_from_variable_value method"""
+
+ def create_test_file(self, file_id: str = "test_file_1") -> File:
+ """Create a test File object"""
+ return File(
+ id=file_id,
+ tenant_id="test_tenant",
+ type=FileType.DOCUMENT,
+ transfer_method=FileTransferMethod.LOCAL_FILE,
+ related_id="related_123",
+ filename=f"{file_id}.txt",
+ extension=".txt",
+ mime_type="text/plain",
+ size=1024,
+ storage_key="storage_key_123",
+ )
+
+ def create_file_dict(self, file_id: str = "test_file_dict") -> dict:
+ """Create a file dictionary with correct dify_model_identity"""
+ return {
+ "dify_model_identity": FILE_MODEL_IDENTITY,
+ "id": file_id,
+ "tenant_id": "test_tenant",
+ "type": "document",
+ "transfer_method": "local_file",
+ "related_id": "related_456",
+ "filename": f"{file_id}.txt",
+ "extension": ".txt",
+ "mime_type": "text/plain",
+ "size": 2048,
+ "url": "http://example.com/file.txt",
+ }
+
+ def test_fetch_files_from_variable_value_with_none(self):
+ """Test with None input"""
+ # The method signature expects Union[dict, list, Segment], but implementation handles None
+ # We'll test the actual behavior by passing an empty dict instead
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(None) # type: ignore
+ assert result == []
+
+ def test_fetch_files_from_variable_value_with_empty_dict(self):
+ """Test with empty dictionary"""
+ result = WorkflowResponseConverter._fetch_files_from_variable_value({})
+ assert result == []
+
+ def test_fetch_files_from_variable_value_with_empty_list(self):
+ """Test with empty list"""
+ result = WorkflowResponseConverter._fetch_files_from_variable_value([])
+ assert result == []
+
+ def test_fetch_files_from_variable_value_with_file_segment(self):
+ """Test with valid FileSegment"""
+ test_file = self.create_test_file("segment_file")
+ file_segment = FileSegment(value=test_file)
+
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(file_segment)
+
+ assert len(result) == 1
+ assert isinstance(result[0], dict)
+ assert result[0]["id"] == "segment_file"
+ assert result[0]["dify_model_identity"] == FILE_MODEL_IDENTITY
+
+ def test_fetch_files_from_variable_value_with_array_file_segment_single(self):
+ """Test with ArrayFileSegment containing single file"""
+ test_file = self.create_test_file("array_file_1")
+ array_segment = ArrayFileSegment(value=[test_file])
+
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(array_segment)
+
+ assert len(result) == 1
+ assert isinstance(result[0], dict)
+ assert result[0]["id"] == "array_file_1"
+
+ def test_fetch_files_from_variable_value_with_array_file_segment_multiple(self):
+ """Test with ArrayFileSegment containing multiple files"""
+ test_file_1 = self.create_test_file("array_file_1")
+ test_file_2 = self.create_test_file("array_file_2")
+ array_segment = ArrayFileSegment(value=[test_file_1, test_file_2])
+
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(array_segment)
+
+ assert len(result) == 2
+ assert result[0]["id"] == "array_file_1"
+ assert result[1]["id"] == "array_file_2"
+
+ def test_fetch_files_from_variable_value_with_array_file_segment_empty(self):
+ """Test with ArrayFileSegment containing empty array"""
+ array_segment = ArrayFileSegment(value=[])
+
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(array_segment)
+
+ assert result == []
+
+ def test_fetch_files_from_variable_value_with_list_of_file_dicts(self):
+ """Test with list containing file dictionaries"""
+ file_dict_1 = self.create_file_dict("list_file_1")
+ file_dict_2 = self.create_file_dict("list_file_2")
+ test_list = [file_dict_1, file_dict_2]
+
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(test_list)
+
+ assert len(result) == 2
+ assert result[0]["id"] == "list_file_1"
+ assert result[1]["id"] == "list_file_2"
+
+ def test_fetch_files_from_variable_value_with_list_of_file_objects(self):
+ """Test with list containing File objects"""
+ file_obj_1 = self.create_test_file("list_obj_1")
+ file_obj_2 = self.create_test_file("list_obj_2")
+ test_list = [file_obj_1, file_obj_2]
+
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(test_list)
+
+ assert len(result) == 2
+ assert result[0]["id"] == "list_obj_1"
+ assert result[1]["id"] == "list_obj_2"
+
+ def test_fetch_files_from_variable_value_with_list_mixed_valid_invalid(self):
+ """Test with list containing mix of valid files and invalid items"""
+ file_dict = self.create_file_dict("mixed_file")
+ invalid_dict = {"not_a_file": "value"}
+ test_list = [file_dict, invalid_dict, "string_item", 123]
+
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(test_list)
+
+ assert len(result) == 1
+ assert result[0]["id"] == "mixed_file"
+
+ def test_fetch_files_from_variable_value_with_list_nested_structures(self):
+ """Test with list containing nested structures"""
+ file_dict = self.create_file_dict("nested_file")
+ nested_list = [file_dict, ["inner_list"]]
+ test_list = [nested_list, {"nested": "dict"}]
+
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(test_list)
+
+ # Should not process nested structures in list items
+ assert result == []
+
+ def test_fetch_files_from_variable_value_with_dict_incorrect_identity(self):
+ """Test with dictionary having incorrect dify_model_identity"""
+ invalid_dict = {"dify_model_identity": "wrong_identity", "id": "invalid_file", "filename": "test.txt"}
+
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(invalid_dict)
+
+ assert result == []
+
+ def test_fetch_files_from_variable_value_with_dict_missing_identity(self):
+ """Test with dictionary missing dify_model_identity"""
+ invalid_dict = {"id": "no_identity_file", "filename": "test.txt"}
+
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(invalid_dict)
+
+ assert result == []
+
+ def test_fetch_files_from_variable_value_with_dict_file_object(self):
+ """Test with dictionary containing File object"""
+ file_obj = self.create_test_file("dict_obj_file")
+ test_dict = {"file_key": file_obj}
+
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(test_dict)
+
+ # Should not extract File objects from dict values
+ assert result == []
+
+ def test_fetch_files_from_variable_value_with_mixed_data_types(self):
+ """Test with various mixed data types"""
+ mixed_data = {"string": "text", "number": 42, "boolean": True, "null": None, "dify_model_identity": "wrong"}
+
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(mixed_data)
+
+ assert result == []
+
+ def test_fetch_files_from_variable_value_with_invalid_objects(self):
+ """Test with invalid objects that are not supported types"""
+ # Test with an invalid dict that doesn't match expected patterns
+ invalid_dict = {"custom_key": "custom_value"}
+
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(invalid_dict)
+
+ assert result == []
+
+ def test_fetch_files_from_variable_value_with_string_input(self):
+ """Test with string input (unsupported type)"""
+ # Since method expects Union[dict, list, Segment], test with empty list instead
+ result = WorkflowResponseConverter._fetch_files_from_variable_value([])
+
+ assert result == []
+
+ def test_fetch_files_from_variable_value_with_number_input(self):
+ """Test with number input (unsupported type)"""
+ # Test with list containing numbers (should be ignored)
+ result = WorkflowResponseConverter._fetch_files_from_variable_value([42, "string", None])
+
+ assert result == []
+
+ def test_fetch_files_from_variable_value_return_type_is_sequence(self):
+ """Test that return type is Sequence[Mapping[str, Any]]"""
+ file_dict = self.create_file_dict("type_test_file")
+
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(file_dict)
+
+ assert isinstance(result, Sequence)
+ assert len(result) == 1
+ assert isinstance(result[0], Mapping)
+ assert all(isinstance(key, str) for key in result[0])
+
+ def test_fetch_files_from_variable_value_preserves_file_properties(self):
+ """Test that all file properties are preserved in the result"""
+ original_file = self.create_test_file("property_test")
+ file_segment = FileSegment(value=original_file)
+
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(file_segment)
+
+ assert len(result) == 1
+ file_dict = result[0]
+ assert file_dict["id"] == "property_test"
+ assert file_dict["tenant_id"] == "test_tenant"
+ assert file_dict["type"] == "document"
+ assert file_dict["transfer_method"] == "local_file"
+ assert file_dict["filename"] == "property_test.txt"
+ assert file_dict["extension"] == ".txt"
+ assert file_dict["mime_type"] == "text/plain"
+ assert file_dict["size"] == 1024
+
+ def test_fetch_files_from_variable_value_with_complex_nested_scenario(self):
+ """Test complex scenario with nested valid and invalid data"""
+ file_dict = self.create_file_dict("complex_file")
+ file_obj = self.create_test_file("complex_obj")
+
+ # Complex nested structure
+ complex_data = [
+ file_dict, # Valid file dict
+ file_obj, # Valid file object
+ { # Invalid dict
+ "not_file": "data",
+ "nested": {"deep": "value"},
+ },
+ [ # Nested list (should be ignored)
+ self.create_file_dict("nested_file")
+ ],
+ "string", # Invalid string
+ None, # None value
+ 42, # Invalid number
+ ]
+
+ result = WorkflowResponseConverter._fetch_files_from_variable_value(complex_data)
+
+ assert len(result) == 2
+ assert result[0]["id"] == "complex_file"
+ assert result[1]["id"] == "complex_obj"
diff --git a/api/tests/unit_tests/core/app/segments/test_factory.py b/api/tests/unit_tests/core/app/segments/test_factory.py
deleted file mode 100644
index e6e289c12a..0000000000
--- a/api/tests/unit_tests/core/app/segments/test_factory.py
+++ /dev/null
@@ -1,165 +0,0 @@
-from uuid import uuid4
-
-import pytest
-
-from core.variables import (
- ArrayNumberVariable,
- ArrayObjectVariable,
- ArrayStringVariable,
- FloatVariable,
- IntegerVariable,
- ObjectSegment,
- SecretVariable,
- StringVariable,
-)
-from core.variables.exc import VariableError
-from core.variables.segments import ArrayAnySegment
-from factories import variable_factory
-
-
-def test_string_variable():
- test_data = {"value_type": "string", "name": "test_text", "value": "Hello, World!"}
- result = variable_factory.build_conversation_variable_from_mapping(test_data)
- assert isinstance(result, StringVariable)
-
-
-def test_integer_variable():
- test_data = {"value_type": "number", "name": "test_int", "value": 42}
- result = variable_factory.build_conversation_variable_from_mapping(test_data)
- assert isinstance(result, IntegerVariable)
-
-
-def test_float_variable():
- test_data = {"value_type": "number", "name": "test_float", "value": 3.14}
- result = variable_factory.build_conversation_variable_from_mapping(test_data)
- assert isinstance(result, FloatVariable)
-
-
-def test_secret_variable():
- test_data = {"value_type": "secret", "name": "test_secret", "value": "secret_value"}
- result = variable_factory.build_conversation_variable_from_mapping(test_data)
- assert isinstance(result, SecretVariable)
-
-
-def test_invalid_value_type():
- test_data = {"value_type": "unknown", "name": "test_invalid", "value": "value"}
- with pytest.raises(VariableError):
- variable_factory.build_conversation_variable_from_mapping(test_data)
-
-
-def test_build_a_blank_string():
- result = variable_factory.build_conversation_variable_from_mapping(
- {
- "value_type": "string",
- "name": "blank",
- "value": "",
- }
- )
- assert isinstance(result, StringVariable)
- assert result.value == ""
-
-
-def test_build_a_object_variable_with_none_value():
- var = variable_factory.build_segment(
- {
- "key1": None,
- }
- )
- assert isinstance(var, ObjectSegment)
- assert var.value["key1"] is None
-
-
-def test_object_variable():
- mapping = {
- "id": str(uuid4()),
- "value_type": "object",
- "name": "test_object",
- "description": "Description of the variable.",
- "value": {
- "key1": "text",
- "key2": 2,
- },
- }
- variable = variable_factory.build_conversation_variable_from_mapping(mapping)
- assert isinstance(variable, ObjectSegment)
- assert isinstance(variable.value["key1"], str)
- assert isinstance(variable.value["key2"], int)
-
-
-def test_array_string_variable():
- mapping = {
- "id": str(uuid4()),
- "value_type": "array[string]",
- "name": "test_array",
- "description": "Description of the variable.",
- "value": [
- "text",
- "text",
- ],
- }
- variable = variable_factory.build_conversation_variable_from_mapping(mapping)
- assert isinstance(variable, ArrayStringVariable)
- assert isinstance(variable.value[0], str)
- assert isinstance(variable.value[1], str)
-
-
-def test_array_number_variable():
- mapping = {
- "id": str(uuid4()),
- "value_type": "array[number]",
- "name": "test_array",
- "description": "Description of the variable.",
- "value": [
- 1,
- 2.0,
- ],
- }
- variable = variable_factory.build_conversation_variable_from_mapping(mapping)
- assert isinstance(variable, ArrayNumberVariable)
- assert isinstance(variable.value[0], int)
- assert isinstance(variable.value[1], float)
-
-
-def test_array_object_variable():
- mapping = {
- "id": str(uuid4()),
- "value_type": "array[object]",
- "name": "test_array",
- "description": "Description of the variable.",
- "value": [
- {
- "key1": "text",
- "key2": 1,
- },
- {
- "key1": "text",
- "key2": 1,
- },
- ],
- }
- variable = variable_factory.build_conversation_variable_from_mapping(mapping)
- assert isinstance(variable, ArrayObjectVariable)
- assert isinstance(variable.value[0], dict)
- assert isinstance(variable.value[1], dict)
- assert isinstance(variable.value[0]["key1"], str)
- assert isinstance(variable.value[0]["key2"], int)
- assert isinstance(variable.value[1]["key1"], str)
- assert isinstance(variable.value[1]["key2"], int)
-
-
-def test_variable_cannot_large_than_200_kb():
- with pytest.raises(VariableError):
- variable_factory.build_conversation_variable_from_mapping(
- {
- "id": str(uuid4()),
- "value_type": "string",
- "name": "test_text",
- "value": "a" * 1024 * 201,
- }
- )
-
-
-def test_array_none_variable():
- var = variable_factory.build_segment([None, None, None, None])
- assert isinstance(var, ArrayAnySegment)
- assert var.value == [None, None, None, None]
diff --git a/api/tests/unit_tests/core/file/test_models.py b/api/tests/unit_tests/core/file/test_models.py
new file mode 100644
index 0000000000..3ada2087c6
--- /dev/null
+++ b/api/tests/unit_tests/core/file/test_models.py
@@ -0,0 +1,25 @@
+from core.file import File, FileTransferMethod, FileType
+
+
+def test_file():
+ file = File(
+ id="test-file",
+ tenant_id="test-tenant-id",
+ type=FileType.IMAGE,
+ transfer_method=FileTransferMethod.TOOL_FILE,
+ related_id="test-related-id",
+ filename="image.png",
+ extension=".png",
+ mime_type="image/png",
+ size=67,
+ storage_key="test-storage-key",
+ url="https://example.com/image.png",
+ )
+ assert file.tenant_id == "test-tenant-id"
+ assert file.type == FileType.IMAGE
+ assert file.transfer_method == FileTransferMethod.TOOL_FILE
+ assert file.related_id == "test-related-id"
+ assert file.filename == "image.png"
+ assert file.extension == ".png"
+ assert file.mime_type == "image/png"
+ assert file.size == 67
diff --git a/api/tests/unit_tests/core/helper/test_encrypter.py b/api/tests/unit_tests/core/helper/test_encrypter.py
new file mode 100644
index 0000000000..61cf8f255d
--- /dev/null
+++ b/api/tests/unit_tests/core/helper/test_encrypter.py
@@ -0,0 +1,280 @@
+import base64
+import binascii
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from core.helper.encrypter import (
+ batch_decrypt_token,
+ decrypt_token,
+ encrypt_token,
+ get_decrypt_decoding,
+ obfuscated_token,
+)
+from libs.rsa import PrivkeyNotFoundError
+
+
+class TestObfuscatedToken:
+ @pytest.mark.parametrize(
+ ("token", "expected"),
+ [
+ ("", ""), # Empty token
+ ("1234567", "*" * 20), # Short token (<8 chars)
+ ("12345678", "*" * 20), # Boundary case (8 chars)
+ ("123456789abcdef", "123456" + "*" * 12 + "ef"), # Long token
+ ("abc!@#$%^&*()def", "abc!@#" + "*" * 12 + "ef"), # Special chars
+ ],
+ )
+ def test_obfuscation_logic(self, token, expected):
+ """Test core obfuscation logic for various token lengths"""
+ assert obfuscated_token(token) == expected
+
+ def test_sensitive_data_protection(self):
+ """Ensure obfuscation never reveals full sensitive data"""
+ token = "api_key_secret_12345"
+ obfuscated = obfuscated_token(token)
+ assert token not in obfuscated
+ assert "*" * 12 in obfuscated
+
+
+class TestEncryptToken:
+ @patch("models.engine.db.session.query")
+ @patch("libs.rsa.encrypt")
+ def test_successful_encryption(self, mock_encrypt, mock_query):
+ """Test successful token encryption"""
+ mock_tenant = MagicMock()
+ mock_tenant.encrypt_public_key = "mock_public_key"
+ mock_query.return_value.filter.return_value.first.return_value = mock_tenant
+ mock_encrypt.return_value = b"encrypted_data"
+
+ result = encrypt_token("tenant-123", "test_token")
+
+ assert result == base64.b64encode(b"encrypted_data").decode()
+ mock_encrypt.assert_called_with("test_token", "mock_public_key")
+
+ @patch("models.engine.db.session.query")
+ def test_tenant_not_found(self, mock_query):
+ """Test error when tenant doesn't exist"""
+ mock_query.return_value.filter.return_value.first.return_value = None
+
+ with pytest.raises(ValueError) as exc_info:
+ encrypt_token("invalid-tenant", "test_token")
+
+ assert "Tenant with id invalid-tenant not found" in str(exc_info.value)
+
+
+class TestDecryptToken:
+ @patch("libs.rsa.decrypt")
+ def test_successful_decryption(self, mock_decrypt):
+ """Test successful token decryption"""
+ mock_decrypt.return_value = "decrypted_token"
+ encrypted_data = base64.b64encode(b"encrypted_data").decode()
+
+ result = decrypt_token("tenant-123", encrypted_data)
+
+ assert result == "decrypted_token"
+ mock_decrypt.assert_called_once_with(b"encrypted_data", "tenant-123")
+
+ def test_invalid_base64(self):
+ """Test handling of invalid base64 input"""
+ with pytest.raises(binascii.Error):
+ decrypt_token("tenant-123", "invalid_base64!!!")
+
+
+class TestBatchDecryptToken:
+ @patch("libs.rsa.get_decrypt_decoding")
+ @patch("libs.rsa.decrypt_token_with_decoding")
+ def test_batch_decryption(self, mock_decrypt_with_decoding, mock_get_decoding):
+ """Test batch decryption functionality"""
+ mock_rsa_key = MagicMock()
+ mock_cipher_rsa = MagicMock()
+ mock_get_decoding.return_value = (mock_rsa_key, mock_cipher_rsa)
+
+ # Test multiple tokens
+ mock_decrypt_with_decoding.side_effect = ["token1", "token2", "token3"]
+ tokens = [
+ base64.b64encode(b"encrypted1").decode(),
+ base64.b64encode(b"encrypted2").decode(),
+ base64.b64encode(b"encrypted3").decode(),
+ ]
+ result = batch_decrypt_token("tenant-123", tokens)
+
+ assert result == ["token1", "token2", "token3"]
+ # Key should only be loaded once
+ mock_get_decoding.assert_called_once_with("tenant-123")
+
+
+class TestGetDecryptDecoding:
+ @patch("extensions.ext_redis.redis_client.get")
+ @patch("extensions.ext_storage.storage.load")
+ def test_private_key_not_found(self, mock_storage_load, mock_redis_get):
+ """Test error when private key file doesn't exist"""
+ mock_redis_get.return_value = None
+ mock_storage_load.side_effect = FileNotFoundError()
+
+ with pytest.raises(PrivkeyNotFoundError) as exc_info:
+ get_decrypt_decoding("tenant-123")
+
+ assert "Private key not found, tenant_id: tenant-123" in str(exc_info.value)
+
+
+class TestEncryptDecryptIntegration:
+ @patch("models.engine.db.session.query")
+ @patch("libs.rsa.encrypt")
+ @patch("libs.rsa.decrypt")
+ def test_should_encrypt_and_decrypt_consistently(self, mock_decrypt, mock_encrypt, mock_query):
+ """Test that encryption and decryption are consistent"""
+ # Setup mock tenant
+ mock_tenant = MagicMock()
+ mock_tenant.encrypt_public_key = "mock_public_key"
+ mock_query.return_value.filter.return_value.first.return_value = mock_tenant
+
+ # Setup mock encryption/decryption
+ original_token = "test_token_123"
+ mock_encrypt.return_value = b"encrypted_data"
+ mock_decrypt.return_value = original_token
+
+ # Test encryption
+ encrypted = encrypt_token("tenant-123", original_token)
+
+ # Test decryption
+ decrypted = decrypt_token("tenant-123", encrypted)
+
+ assert decrypted == original_token
+
+
+class TestSecurity:
+ """Critical security tests for encryption system"""
+
+ @patch("models.engine.db.session.query")
+ @patch("libs.rsa.encrypt")
+ def test_cross_tenant_isolation(self, mock_encrypt, mock_query):
+ """Ensure tokens encrypted for one tenant cannot be used by another"""
+ # Setup mock tenant
+ mock_tenant = MagicMock()
+ mock_tenant.encrypt_public_key = "tenant1_public_key"
+ mock_query.return_value.filter.return_value.first.return_value = mock_tenant
+ mock_encrypt.return_value = b"encrypted_for_tenant1"
+
+ # Encrypt token for tenant1
+ encrypted = encrypt_token("tenant-123", "sensitive_data")
+
+ # Attempt to decrypt with different tenant should fail
+ with patch("libs.rsa.decrypt") as mock_decrypt:
+ mock_decrypt.side_effect = Exception("Invalid tenant key")
+
+ with pytest.raises(Exception, match="Invalid tenant key"):
+ decrypt_token("different-tenant", encrypted)
+
+ @patch("libs.rsa.decrypt")
+ def test_tampered_ciphertext_rejection(self, mock_decrypt):
+ """Detect and reject tampered ciphertext"""
+ valid_encrypted = base64.b64encode(b"valid_data").decode()
+
+ # Tamper with ciphertext
+ tampered_bytes = bytearray(base64.b64decode(valid_encrypted))
+ tampered_bytes[0] ^= 0xFF
+ tampered = base64.b64encode(bytes(tampered_bytes)).decode()
+
+ mock_decrypt.side_effect = Exception("Decryption error")
+
+ with pytest.raises(Exception, match="Decryption error"):
+ decrypt_token("tenant-123", tampered)
+
+ @patch("models.engine.db.session.query")
+ @patch("libs.rsa.encrypt")
+ def test_encryption_randomness(self, mock_encrypt, mock_query):
+ """Ensure same plaintext produces different ciphertext"""
+ mock_tenant = MagicMock(encrypt_public_key="key")
+ mock_query.return_value.filter.return_value.first.return_value = mock_tenant
+
+ # Different outputs for same input
+ mock_encrypt.side_effect = [b"enc1", b"enc2", b"enc3"]
+
+ results = [encrypt_token("tenant-123", "token") for _ in range(3)]
+
+ # All results should be different
+ assert len(set(results)) == 3
+
+
+class TestEdgeCases:
+ """Additional security-focused edge case tests"""
+
+ def test_should_handle_empty_string_in_obfuscation(self):
+ """Test handling of empty string in obfuscation"""
+ # Test empty string (which is a valid str type)
+ assert obfuscated_token("") == ""
+
+ @patch("models.engine.db.session.query")
+ @patch("libs.rsa.encrypt")
+ def test_should_handle_empty_token_encryption(self, mock_encrypt, mock_query):
+ """Test encryption of empty token"""
+ mock_tenant = MagicMock()
+ mock_tenant.encrypt_public_key = "mock_public_key"
+ mock_query.return_value.filter.return_value.first.return_value = mock_tenant
+ mock_encrypt.return_value = b"encrypted_empty"
+
+ result = encrypt_token("tenant-123", "")
+
+ assert result == base64.b64encode(b"encrypted_empty").decode()
+ mock_encrypt.assert_called_with("", "mock_public_key")
+
+ @patch("models.engine.db.session.query")
+ @patch("libs.rsa.encrypt")
+ def test_should_handle_special_characters_in_token(self, mock_encrypt, mock_query):
+ """Test tokens containing special/unicode characters"""
+ mock_tenant = MagicMock()
+ mock_tenant.encrypt_public_key = "mock_public_key"
+ mock_query.return_value.filter.return_value.first.return_value = mock_tenant
+ mock_encrypt.return_value = b"encrypted_special"
+
+ # Test various special characters
+ special_tokens = [
+ "token\x00with\x00null", # Null bytes
+ "token_with_emoji_😀🎉", # Unicode emoji
+ "token\nwith\nnewlines", # Newlines
+ "token\twith\ttabs", # Tabs
+ "token_with_中文字符", # Chinese characters
+ ]
+
+ for token in special_tokens:
+ result = encrypt_token("tenant-123", token)
+ assert result == base64.b64encode(b"encrypted_special").decode()
+ mock_encrypt.assert_called_with(token, "mock_public_key")
+
+ @patch("models.engine.db.session.query")
+ @patch("libs.rsa.encrypt")
+ def test_should_handle_rsa_size_limits(self, mock_encrypt, mock_query):
+ """Test behavior when token exceeds RSA encryption limits"""
+ mock_tenant = MagicMock()
+ mock_tenant.encrypt_public_key = "mock_public_key"
+ mock_query.return_value.filter.return_value.first.return_value = mock_tenant
+
+ # RSA 2048-bit can only encrypt ~245 bytes
+ # The actual limit depends on padding scheme
+ mock_encrypt.side_effect = ValueError("Message too long for RSA key size")
+
+ # Create a token that would exceed RSA limits
+ long_token = "x" * 300
+
+ with pytest.raises(ValueError, match="Message too long for RSA key size"):
+ encrypt_token("tenant-123", long_token)
+
+ @patch("libs.rsa.get_decrypt_decoding")
+ @patch("libs.rsa.decrypt_token_with_decoding")
+ def test_batch_decrypt_loads_key_only_once(self, mock_decrypt_with_decoding, mock_get_decoding):
+ """Verify batch decryption optimization - loads key only once"""
+ mock_rsa_key = MagicMock()
+ mock_cipher_rsa = MagicMock()
+ mock_get_decoding.return_value = (mock_rsa_key, mock_cipher_rsa)
+
+ # Test with multiple tokens
+ mock_decrypt_with_decoding.side_effect = ["token1", "token2", "token3", "token4", "token5"]
+ tokens = [base64.b64encode(f"encrypted{i}".encode()).decode() for i in range(5)]
+
+ result = batch_decrypt_token("tenant-123", tokens)
+
+ assert result == ["token1", "token2", "token3", "token4", "token5"]
+ # Key should only be loaded once regardless of token count
+ mock_get_decoding.assert_called_once_with("tenant-123")
+ assert mock_decrypt_with_decoding.call_count == 5
diff --git a/api/tests/unit_tests/core/helper/test_url_signer.py b/api/tests/unit_tests/core/helper/test_url_signer.py
new file mode 100644
index 0000000000..5af24777de
--- /dev/null
+++ b/api/tests/unit_tests/core/helper/test_url_signer.py
@@ -0,0 +1,194 @@
+from unittest.mock import patch
+from urllib.parse import parse_qs, urlparse
+
+import pytest
+
+from core.helper.url_signer import SignedUrlParams, UrlSigner
+
+
+class TestUrlSigner:
+ """Test cases for UrlSigner class"""
+
+ @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
+ def test_should_generate_signed_url_params(self):
+ """Test generation of signed URL parameters with all required fields"""
+ sign_key = "test-sign-key"
+ prefix = "test-prefix"
+
+ params = UrlSigner.get_signed_url_params(sign_key, prefix)
+
+ # Verify the returned object and required fields
+ assert isinstance(params, SignedUrlParams)
+ assert params.sign_key == sign_key
+ assert params.timestamp is not None
+ assert params.nonce is not None
+ assert params.sign is not None
+
+ # Verify nonce format (32 character hex string)
+ assert len(params.nonce) == 32
+ assert all(c in "0123456789abcdef" for c in params.nonce)
+
+ @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
+ def test_should_generate_complete_signed_url(self):
+ """Test generation of complete signed URL with query parameters"""
+ base_url = "https://example.com/api/test"
+ sign_key = "test-sign-key"
+ prefix = "test-prefix"
+
+ signed_url = UrlSigner.get_signed_url(base_url, sign_key, prefix)
+
+ # Parse URL and verify structure
+ parsed = urlparse(signed_url)
+ assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == base_url
+
+ # Verify query parameters
+ query_params = parse_qs(parsed.query)
+ assert "timestamp" in query_params
+ assert "nonce" in query_params
+ assert "sign" in query_params
+
+ # Verify each parameter has exactly one value
+ assert len(query_params["timestamp"]) == 1
+ assert len(query_params["nonce"]) == 1
+ assert len(query_params["sign"]) == 1
+
+ # Verify parameter values are not empty
+ assert query_params["timestamp"][0]
+ assert query_params["nonce"][0]
+ assert query_params["sign"][0]
+
+ @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
+ def test_should_verify_valid_signature(self):
+ """Test verification of valid signature"""
+ sign_key = "test-sign-key"
+ prefix = "test-prefix"
+
+ # Generate and verify signature
+ params = UrlSigner.get_signed_url_params(sign_key, prefix)
+
+ is_valid = UrlSigner.verify(
+ sign_key=sign_key, timestamp=params.timestamp, nonce=params.nonce, sign=params.sign, prefix=prefix
+ )
+
+ assert is_valid is True
+
+ @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
+ @pytest.mark.parametrize(
+ ("field", "modifier"),
+ [
+ ("sign_key", lambda _: "wrong-sign-key"),
+ ("timestamp", lambda t: str(int(t) + 1000)),
+ ("nonce", lambda _: "different-nonce-123456789012345"),
+ ("prefix", lambda _: "wrong-prefix"),
+ ("sign", lambda s: s + "tampered"),
+ ],
+ )
+ def test_should_reject_invalid_signature_params(self, field, modifier):
+ """Test signature verification rejects invalid parameters"""
+ sign_key = "test-sign-key"
+ prefix = "test-prefix"
+
+ # Generate valid signed parameters
+ params = UrlSigner.get_signed_url_params(sign_key, prefix)
+
+ # Prepare verification parameters
+ verify_params = {
+ "sign_key": sign_key,
+ "timestamp": params.timestamp,
+ "nonce": params.nonce,
+ "sign": params.sign,
+ "prefix": prefix,
+ }
+
+ # Modify the specific field
+ verify_params[field] = modifier(verify_params[field])
+
+ # Verify should fail
+ is_valid = UrlSigner.verify(**verify_params)
+ assert is_valid is False
+
+ @patch("configs.dify_config.SECRET_KEY", None)
+ def test_should_raise_error_without_secret_key(self):
+ """Test that signing fails when SECRET_KEY is not configured"""
+ with pytest.raises(Exception) as exc_info:
+ UrlSigner.get_signed_url_params("key", "prefix")
+
+ assert "SECRET_KEY is not set" in str(exc_info.value)
+
+ @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
+ def test_should_generate_unique_signatures(self):
+ """Test that different inputs produce different signatures"""
+ params1 = UrlSigner.get_signed_url_params("key1", "prefix1")
+ params2 = UrlSigner.get_signed_url_params("key2", "prefix2")
+
+ # Different inputs should produce different signatures
+ assert params1.sign != params2.sign
+ assert params1.nonce != params2.nonce
+
+ @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
+ def test_should_handle_special_characters(self):
+ """Test handling of special characters in parameters"""
+ special_cases = [
+ "test with spaces",
+ "test/with/slashes",
+ "test中文字符",
+ ]
+
+ for sign_key in special_cases:
+ params = UrlSigner.get_signed_url_params(sign_key, "prefix")
+
+ # Should generate valid signature and verify correctly
+ is_valid = UrlSigner.verify(
+ sign_key=sign_key, timestamp=params.timestamp, nonce=params.nonce, sign=params.sign, prefix="prefix"
+ )
+ assert is_valid is True
+
+ @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
+ def test_should_ensure_nonce_randomness(self):
+ """Test that nonce is random for each generation - critical for security"""
+ sign_key = "test-sign-key"
+ prefix = "test-prefix"
+
+ # Generate multiple nonces
+ nonces = set()
+ for _ in range(5):
+ params = UrlSigner.get_signed_url_params(sign_key, prefix)
+ nonces.add(params.nonce)
+
+ # All nonces should be unique
+ assert len(nonces) == 5
+
+ @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
+ @patch("time.time", return_value=1234567890)
+ @patch("os.urandom", return_value=b"\xab\xcd\xef\x12\x34\x56\x78\x90\xab\xcd\xef\x12\x34\x56\x78\x90")
+ def test_should_produce_consistent_signatures(self, mock_urandom, mock_time):
+ """Test that same inputs produce same signature - ensures deterministic behavior"""
+ sign_key = "test-sign-key"
+ prefix = "test-prefix"
+
+ # Generate signature multiple times with same inputs (time and nonce are mocked)
+ params1 = UrlSigner.get_signed_url_params(sign_key, prefix)
+ params2 = UrlSigner.get_signed_url_params(sign_key, prefix)
+
+ # With mocked time and random, should produce identical results
+ assert params1.timestamp == params2.timestamp
+ assert params1.nonce == params2.nonce
+ assert params1.sign == params2.sign
+
+ # Verify the signature is valid
+ assert UrlSigner.verify(
+ sign_key=sign_key, timestamp=params1.timestamp, nonce=params1.nonce, sign=params1.sign, prefix=prefix
+ )
+
+ @patch("configs.dify_config.SECRET_KEY", "test-secret-key-12345")
+ def test_should_handle_empty_strings(self):
+ """Test handling of empty string parameters - common edge case"""
+ # Empty sign_key and prefix should still work
+ params = UrlSigner.get_signed_url_params("", "")
+ assert params.sign is not None
+
+ # Should verify correctly
+ is_valid = UrlSigner.verify(
+ sign_key="", timestamp=params.timestamp, nonce=params.nonce, sign=params.sign, prefix=""
+ )
+ assert is_valid is True
diff --git a/api/tests/unit_tests/core/mcp/client/test_session.py b/api/tests/unit_tests/core/mcp/client/test_session.py
new file mode 100644
index 0000000000..c84169bf15
--- /dev/null
+++ b/api/tests/unit_tests/core/mcp/client/test_session.py
@@ -0,0 +1,471 @@
+import queue
+import threading
+from typing import Any
+
+from core.mcp import types
+from core.mcp.entities import RequestContext
+from core.mcp.session.base_session import RequestResponder
+from core.mcp.session.client_session import DEFAULT_CLIENT_INFO, ClientSession
+from core.mcp.types import (
+ LATEST_PROTOCOL_VERSION,
+ ClientNotification,
+ ClientRequest,
+ Implementation,
+ InitializedNotification,
+ InitializeRequest,
+ InitializeResult,
+ JSONRPCMessage,
+ JSONRPCNotification,
+ JSONRPCRequest,
+ JSONRPCResponse,
+ ServerCapabilities,
+ ServerResult,
+ SessionMessage,
+)
+
+
+def test_client_session_initialize():
+ # Create synchronous queues to replace async streams
+ client_to_server: queue.Queue[SessionMessage] = queue.Queue()
+ server_to_client: queue.Queue[SessionMessage] = queue.Queue()
+
+ initialized_notification = None
+
+ def mock_server():
+ nonlocal initialized_notification
+
+ # Receive initialization request
+ session_message = client_to_server.get(timeout=5.0)
+ jsonrpc_request = session_message.message
+ assert isinstance(jsonrpc_request.root, JSONRPCRequest)
+ request = ClientRequest.model_validate(
+ jsonrpc_request.root.model_dump(by_alias=True, mode="json", exclude_none=True)
+ )
+ assert isinstance(request.root, InitializeRequest)
+
+ # Create response
+ result = ServerResult(
+ InitializeResult(
+ protocolVersion=LATEST_PROTOCOL_VERSION,
+ capabilities=ServerCapabilities(
+ logging=None,
+ resources=None,
+ tools=None,
+ experimental=None,
+ prompts=None,
+ ),
+ serverInfo=Implementation(name="mock-server", version="0.1.0"),
+ instructions="The server instructions.",
+ )
+ )
+
+ # Send response
+ server_to_client.put(
+ SessionMessage(
+ message=JSONRPCMessage(
+ JSONRPCResponse(
+ jsonrpc="2.0",
+ id=jsonrpc_request.root.id,
+ result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
+ )
+ )
+ )
+ )
+
+ # Receive initialized notification
+ session_notification = client_to_server.get(timeout=5.0)
+ jsonrpc_notification = session_notification.message
+ assert isinstance(jsonrpc_notification.root, JSONRPCNotification)
+ initialized_notification = ClientNotification.model_validate(
+ jsonrpc_notification.root.model_dump(by_alias=True, mode="json", exclude_none=True)
+ )
+
+ # Create message handler
+ def message_handler(
+ message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
+ ) -> None:
+ if isinstance(message, Exception):
+ raise message
+
+ # Start mock server thread
+ server_thread = threading.Thread(target=mock_server, daemon=True)
+ server_thread.start()
+
+ # Create and use client session
+ with ClientSession(
+ server_to_client,
+ client_to_server,
+ message_handler=message_handler,
+ ) as session:
+ result = session.initialize()
+
+ # Wait for server thread to complete
+ server_thread.join(timeout=10.0)
+
+ # Assert results
+ assert isinstance(result, InitializeResult)
+ assert result.protocolVersion == LATEST_PROTOCOL_VERSION
+ assert isinstance(result.capabilities, ServerCapabilities)
+ assert result.serverInfo == Implementation(name="mock-server", version="0.1.0")
+ assert result.instructions == "The server instructions."
+
+ # Check that client sent initialized notification
+ assert initialized_notification
+ assert isinstance(initialized_notification.root, InitializedNotification)
+
+
+def test_client_session_custom_client_info():
+ # Create synchronous queues to replace async streams
+ client_to_server: queue.Queue[SessionMessage] = queue.Queue()
+ server_to_client: queue.Queue[SessionMessage] = queue.Queue()
+
+ custom_client_info = Implementation(name="test-client", version="1.2.3")
+ received_client_info = None
+
+ def mock_server():
+ nonlocal received_client_info
+
+ session_message = client_to_server.get(timeout=5.0)
+ jsonrpc_request = session_message.message
+ assert isinstance(jsonrpc_request.root, JSONRPCRequest)
+ request = ClientRequest.model_validate(
+ jsonrpc_request.root.model_dump(by_alias=True, mode="json", exclude_none=True)
+ )
+ assert isinstance(request.root, InitializeRequest)
+ received_client_info = request.root.params.clientInfo
+
+ result = ServerResult(
+ InitializeResult(
+ protocolVersion=LATEST_PROTOCOL_VERSION,
+ capabilities=ServerCapabilities(),
+ serverInfo=Implementation(name="mock-server", version="0.1.0"),
+ )
+ )
+
+ server_to_client.put(
+ SessionMessage(
+ message=JSONRPCMessage(
+ JSONRPCResponse(
+ jsonrpc="2.0",
+ id=jsonrpc_request.root.id,
+ result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
+ )
+ )
+ )
+ )
+ # Receive initialized notification
+ client_to_server.get(timeout=5.0)
+
+ # Start mock server thread
+ server_thread = threading.Thread(target=mock_server, daemon=True)
+ server_thread.start()
+
+ with ClientSession(
+ server_to_client,
+ client_to_server,
+ client_info=custom_client_info,
+ ) as session:
+ session.initialize()
+
+ # Wait for server thread to complete
+ server_thread.join(timeout=10.0)
+
+ # Assert that custom client info was sent
+ assert received_client_info == custom_client_info
+
+
+def test_client_session_default_client_info():
+ # Create synchronous queues to replace async streams
+ client_to_server: queue.Queue[SessionMessage] = queue.Queue()
+ server_to_client: queue.Queue[SessionMessage] = queue.Queue()
+
+ received_client_info = None
+
+ def mock_server():
+ nonlocal received_client_info
+
+ session_message = client_to_server.get(timeout=5.0)
+ jsonrpc_request = session_message.message
+ assert isinstance(jsonrpc_request.root, JSONRPCRequest)
+ request = ClientRequest.model_validate(
+ jsonrpc_request.root.model_dump(by_alias=True, mode="json", exclude_none=True)
+ )
+ assert isinstance(request.root, InitializeRequest)
+ received_client_info = request.root.params.clientInfo
+
+ result = ServerResult(
+ InitializeResult(
+ protocolVersion=LATEST_PROTOCOL_VERSION,
+ capabilities=ServerCapabilities(),
+ serverInfo=Implementation(name="mock-server", version="0.1.0"),
+ )
+ )
+
+ server_to_client.put(
+ SessionMessage(
+ message=JSONRPCMessage(
+ JSONRPCResponse(
+ jsonrpc="2.0",
+ id=jsonrpc_request.root.id,
+ result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
+ )
+ )
+ )
+ )
+ # Receive initialized notification
+ client_to_server.get(timeout=5.0)
+
+ # Start mock server thread
+ server_thread = threading.Thread(target=mock_server, daemon=True)
+ server_thread.start()
+
+ with ClientSession(
+ server_to_client,
+ client_to_server,
+ ) as session:
+ session.initialize()
+
+ # Wait for server thread to complete
+ server_thread.join(timeout=10.0)
+
+ # Assert that default client info was used
+ assert received_client_info == DEFAULT_CLIENT_INFO
+
+
+def test_client_session_version_negotiation_success():
+ # Create synchronous queues to replace async streams
+ client_to_server: queue.Queue[SessionMessage] = queue.Queue()
+ server_to_client: queue.Queue[SessionMessage] = queue.Queue()
+
+ def mock_server():
+ session_message = client_to_server.get(timeout=5.0)
+ jsonrpc_request = session_message.message
+ assert isinstance(jsonrpc_request.root, JSONRPCRequest)
+ request = ClientRequest.model_validate(
+ jsonrpc_request.root.model_dump(by_alias=True, mode="json", exclude_none=True)
+ )
+ assert isinstance(request.root, InitializeRequest)
+
+ # Send supported protocol version
+ result = ServerResult(
+ InitializeResult(
+ protocolVersion=LATEST_PROTOCOL_VERSION,
+ capabilities=ServerCapabilities(),
+ serverInfo=Implementation(name="mock-server", version="0.1.0"),
+ )
+ )
+
+ server_to_client.put(
+ SessionMessage(
+ message=JSONRPCMessage(
+ JSONRPCResponse(
+ jsonrpc="2.0",
+ id=jsonrpc_request.root.id,
+ result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
+ )
+ )
+ )
+ )
+ # Receive initialized notification
+ client_to_server.get(timeout=5.0)
+
+ # Start mock server thread
+ server_thread = threading.Thread(target=mock_server, daemon=True)
+ server_thread.start()
+
+ with ClientSession(
+ server_to_client,
+ client_to_server,
+ ) as session:
+ result = session.initialize()
+
+ # Wait for server thread to complete
+ server_thread.join(timeout=10.0)
+
+ # Should successfully initialize
+ assert isinstance(result, InitializeResult)
+ assert result.protocolVersion == LATEST_PROTOCOL_VERSION
+
+
+def test_client_session_version_negotiation_failure():
+ # Create synchronous queues to replace async streams
+ client_to_server: queue.Queue[SessionMessage] = queue.Queue()
+ server_to_client: queue.Queue[SessionMessage] = queue.Queue()
+
+ def mock_server():
+ session_message = client_to_server.get(timeout=5.0)
+ jsonrpc_request = session_message.message
+ assert isinstance(jsonrpc_request.root, JSONRPCRequest)
+ request = ClientRequest.model_validate(
+ jsonrpc_request.root.model_dump(by_alias=True, mode="json", exclude_none=True)
+ )
+ assert isinstance(request.root, InitializeRequest)
+
+ # Send unsupported protocol version
+ result = ServerResult(
+ InitializeResult(
+ protocolVersion="99.99.99", # Unsupported version
+ capabilities=ServerCapabilities(),
+ serverInfo=Implementation(name="mock-server", version="0.1.0"),
+ )
+ )
+
+ server_to_client.put(
+ SessionMessage(
+ message=JSONRPCMessage(
+ JSONRPCResponse(
+ jsonrpc="2.0",
+ id=jsonrpc_request.root.id,
+ result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
+ )
+ )
+ )
+ )
+
+ # Start mock server thread
+ server_thread = threading.Thread(target=mock_server, daemon=True)
+ server_thread.start()
+
+ with ClientSession(
+ server_to_client,
+ client_to_server,
+ ) as session:
+ import pytest
+
+ with pytest.raises(RuntimeError, match="Unsupported protocol version"):
+ session.initialize()
+
+ # Wait for server thread to complete
+ server_thread.join(timeout=10.0)
+
+
+def test_client_capabilities_default():
+ # Create synchronous queues to replace async streams
+ client_to_server: queue.Queue[SessionMessage] = queue.Queue()
+ server_to_client: queue.Queue[SessionMessage] = queue.Queue()
+
+ received_capabilities = None
+
+ def mock_server():
+ nonlocal received_capabilities
+
+ session_message = client_to_server.get(timeout=5.0)
+ jsonrpc_request = session_message.message
+ assert isinstance(jsonrpc_request.root, JSONRPCRequest)
+ request = ClientRequest.model_validate(
+ jsonrpc_request.root.model_dump(by_alias=True, mode="json", exclude_none=True)
+ )
+ assert isinstance(request.root, InitializeRequest)
+ received_capabilities = request.root.params.capabilities
+
+ result = ServerResult(
+ InitializeResult(
+ protocolVersion=LATEST_PROTOCOL_VERSION,
+ capabilities=ServerCapabilities(),
+ serverInfo=Implementation(name="mock-server", version="0.1.0"),
+ )
+ )
+
+ server_to_client.put(
+ SessionMessage(
+ message=JSONRPCMessage(
+ JSONRPCResponse(
+ jsonrpc="2.0",
+ id=jsonrpc_request.root.id,
+ result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
+ )
+ )
+ )
+ )
+ # Receive initialized notification
+ client_to_server.get(timeout=5.0)
+
+ # Start mock server thread
+ server_thread = threading.Thread(target=mock_server, daemon=True)
+ server_thread.start()
+
+ with ClientSession(
+ server_to_client,
+ client_to_server,
+ ) as session:
+ session.initialize()
+
+ # Wait for server thread to complete
+ server_thread.join(timeout=10.0)
+
+ # Assert default capabilities
+ assert received_capabilities is not None
+ assert received_capabilities.sampling is not None
+ assert received_capabilities.roots is not None
+ assert received_capabilities.roots.listChanged is True
+
+
+def test_client_capabilities_with_custom_callbacks():
+ # Create synchronous queues to replace async streams
+ client_to_server: queue.Queue[SessionMessage] = queue.Queue()
+ server_to_client: queue.Queue[SessionMessage] = queue.Queue()
+
+ def custom_sampling_callback(
+ context: RequestContext["ClientSession", Any],
+ params: types.CreateMessageRequestParams,
+ ) -> types.CreateMessageResult | types.ErrorData:
+ return types.CreateMessageResult(
+ model="test-model",
+ role="assistant",
+ content=types.TextContent(type="text", text="Custom response"),
+ )
+
+ def custom_list_roots_callback(
+ context: RequestContext["ClientSession", Any],
+ ) -> types.ListRootsResult | types.ErrorData:
+ return types.ListRootsResult(roots=[])
+
+ def mock_server():
+ session_message = client_to_server.get(timeout=5.0)
+ jsonrpc_request = session_message.message
+ assert isinstance(jsonrpc_request.root, JSONRPCRequest)
+ request = ClientRequest.model_validate(
+ jsonrpc_request.root.model_dump(by_alias=True, mode="json", exclude_none=True)
+ )
+ assert isinstance(request.root, InitializeRequest)
+
+ result = ServerResult(
+ InitializeResult(
+ protocolVersion=LATEST_PROTOCOL_VERSION,
+ capabilities=ServerCapabilities(),
+ serverInfo=Implementation(name="mock-server", version="0.1.0"),
+ )
+ )
+
+ server_to_client.put(
+ SessionMessage(
+ message=JSONRPCMessage(
+ JSONRPCResponse(
+ jsonrpc="2.0",
+ id=jsonrpc_request.root.id,
+ result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
+ )
+ )
+ )
+ )
+ # Receive initialized notification
+ client_to_server.get(timeout=5.0)
+
+ # Start mock server thread
+ server_thread = threading.Thread(target=mock_server, daemon=True)
+ server_thread.start()
+
+ with ClientSession(
+ server_to_client,
+ client_to_server,
+ sampling_callback=custom_sampling_callback,
+ list_roots_callback=custom_list_roots_callback,
+ ) as session:
+ result = session.initialize()
+
+ # Wait for server thread to complete
+ server_thread.join(timeout=10.0)
+
+ # Verify initialization succeeded
+ assert isinstance(result, InitializeResult)
+ assert result.protocolVersion == LATEST_PROTOCOL_VERSION
diff --git a/api/tests/unit_tests/core/mcp/client/test_sse.py b/api/tests/unit_tests/core/mcp/client/test_sse.py
new file mode 100644
index 0000000000..8122cd08eb
--- /dev/null
+++ b/api/tests/unit_tests/core/mcp/client/test_sse.py
@@ -0,0 +1,349 @@
+import json
+import queue
+import threading
+import time
+from typing import Any
+from unittest.mock import Mock, patch
+
+import httpx
+import pytest
+
+from core.mcp import types
+from core.mcp.client.sse_client import sse_client
+from core.mcp.error import MCPAuthError, MCPConnectionError
+
+SERVER_NAME = "test_server_for_SSE"
+
+
+def test_sse_message_id_coercion():
+ """Test that string message IDs that look like integers are parsed as integers.
+
+ See for more details.
+ """
+ json_message = '{"jsonrpc": "2.0", "id": "123", "method": "ping", "params": null}'
+ msg = types.JSONRPCMessage.model_validate_json(json_message)
+ expected = types.JSONRPCMessage(root=types.JSONRPCRequest(method="ping", jsonrpc="2.0", id=123))
+
+ # Check if both are JSONRPCRequest instances
+ assert isinstance(msg.root, types.JSONRPCRequest)
+ assert isinstance(expected.root, types.JSONRPCRequest)
+
+ assert msg.root.id == expected.root.id
+ assert msg.root.method == expected.root.method
+ assert msg.root.jsonrpc == expected.root.jsonrpc
+
+
+class MockSSEClient:
+ """Mock SSE client for testing."""
+
+ def __init__(self, url: str, headers: dict[str, Any] | None = None):
+ self.url = url
+ self.headers = headers or {}
+ self.connected = False
+ self.read_queue: queue.Queue = queue.Queue()
+ self.write_queue: queue.Queue = queue.Queue()
+
+ def connect(self):
+ """Simulate connection establishment."""
+ self.connected = True
+
+ # Send endpoint event
+ endpoint_data = "/messages/?session_id=test-session-123"
+ self.read_queue.put(("endpoint", endpoint_data))
+
+ return self.read_queue, self.write_queue
+
+ def send_initialize_response(self):
+ """Send a mock initialize response."""
+ response = {
+ "jsonrpc": "2.0",
+ "id": 1,
+ "result": {
+ "protocolVersion": types.LATEST_PROTOCOL_VERSION,
+ "capabilities": {
+ "logging": None,
+ "resources": None,
+ "tools": None,
+ "experimental": None,
+ "prompts": None,
+ },
+ "serverInfo": {"name": SERVER_NAME, "version": "0.1.0"},
+ "instructions": "Test server instructions.",
+ },
+ }
+ self.read_queue.put(("message", json.dumps(response)))
+
+
+def test_sse_client_message_id_handling():
+ """Test SSE client properly handles message ID coercion."""
+ mock_client = MockSSEClient("http://test.example/sse")
+ read_queue, write_queue = mock_client.connect()
+
+ # Send a message with string ID that should be coerced to int
+ message_data = {
+ "jsonrpc": "2.0",
+ "id": "456", # String ID
+ "result": {"test": "data"},
+ }
+ read_queue.put(("message", json.dumps(message_data)))
+ read_queue.get(timeout=1.0)
+ # Get the message from queue
+ event_type, data = read_queue.get(timeout=1.0)
+ assert event_type == "message"
+
+ # Parse the message
+ parsed_message = types.JSONRPCMessage.model_validate_json(data)
+ # Check that it's a JSONRPCResponse and verify the ID
+ assert isinstance(parsed_message.root, types.JSONRPCResponse)
+ assert parsed_message.root.id == 456 # Should be converted to int
+
+
+def test_sse_client_connection_validation():
+ """Test SSE client validates endpoint URLs properly."""
+ test_url = "http://test.example/sse"
+
+ with patch("core.mcp.client.sse_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory:
+ with patch("core.mcp.client.sse_client.ssrf_proxy_sse_connect") as mock_sse_connect:
+ # Mock the HTTP client
+ mock_client = Mock()
+ mock_client_factory.return_value.__enter__.return_value = mock_client
+
+ # Mock the SSE connection
+ mock_event_source = Mock()
+ mock_event_source.response.raise_for_status.return_value = None
+ mock_sse_connect.return_value.__enter__.return_value = mock_event_source
+
+ # Mock SSE events
+ class MockSSEEvent:
+ def __init__(self, event_type: str, data: str):
+ self.event = event_type
+ self.data = data
+
+ # Simulate endpoint event
+ endpoint_event = MockSSEEvent("endpoint", "/messages/?session_id=test-123")
+ mock_event_source.iter_sse.return_value = [endpoint_event]
+
+ # Test connection
+ try:
+ with sse_client(test_url) as (read_queue, write_queue):
+ assert read_queue is not None
+ assert write_queue is not None
+ except Exception as e:
+ # Connection might fail due to mocking, but we're testing the validation logic
+ pass
+
+
+def test_sse_client_error_handling():
+ """Test SSE client properly handles various error conditions."""
+ test_url = "http://test.example/sse"
+
+ # Test 401 error handling
+ with patch("core.mcp.client.sse_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory:
+ with patch("core.mcp.client.sse_client.ssrf_proxy_sse_connect") as mock_sse_connect:
+ # Mock 401 HTTP error
+ mock_error = httpx.HTTPStatusError("Unauthorized", request=Mock(), response=Mock(status_code=401))
+ mock_sse_connect.side_effect = mock_error
+
+ with pytest.raises(MCPAuthError):
+ with sse_client(test_url):
+ pass
+
+ # Test other HTTP errors
+ with patch("core.mcp.client.sse_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory:
+ with patch("core.mcp.client.sse_client.ssrf_proxy_sse_connect") as mock_sse_connect:
+ # Mock other HTTP error
+ mock_error = httpx.HTTPStatusError("Server Error", request=Mock(), response=Mock(status_code=500))
+ mock_sse_connect.side_effect = mock_error
+
+ with pytest.raises(MCPConnectionError):
+ with sse_client(test_url):
+ pass
+
+
+def test_sse_client_timeout_configuration():
+ """Test SSE client timeout configuration."""
+ test_url = "http://test.example/sse"
+ custom_timeout = 10.0
+ custom_sse_timeout = 300.0
+ custom_headers = {"Authorization": "Bearer test-token"}
+
+ with patch("core.mcp.client.sse_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory:
+ with patch("core.mcp.client.sse_client.ssrf_proxy_sse_connect") as mock_sse_connect:
+ # Mock successful connection
+ mock_client = Mock()
+ mock_client_factory.return_value.__enter__.return_value = mock_client
+
+ mock_event_source = Mock()
+ mock_event_source.response.raise_for_status.return_value = None
+ mock_event_source.iter_sse.return_value = []
+ mock_sse_connect.return_value.__enter__.return_value = mock_event_source
+
+ try:
+ with sse_client(
+ test_url, headers=custom_headers, timeout=custom_timeout, sse_read_timeout=custom_sse_timeout
+ ) as (read_queue, write_queue):
+ # Verify the configuration was passed correctly
+ mock_client_factory.assert_called_with(headers=custom_headers)
+
+ # Check that timeout was configured
+ call_args = mock_sse_connect.call_args
+ assert call_args is not None
+ timeout_arg = call_args[1]["timeout"]
+ assert timeout_arg.read == custom_sse_timeout
+ except Exception:
+ # Connection might fail due to mocking, but we tested the configuration
+ pass
+
+
+def test_sse_transport_endpoint_validation():
+ """Test SSE transport validates endpoint URLs correctly."""
+ from core.mcp.client.sse_client import SSETransport
+
+ transport = SSETransport("http://example.com/sse")
+
+ # Valid endpoint (same origin)
+ valid_endpoint = "http://example.com/messages/session123"
+ assert transport._validate_endpoint_url(valid_endpoint) == True
+
+ # Invalid endpoint (different origin)
+ invalid_endpoint = "http://malicious.com/messages/session123"
+ assert transport._validate_endpoint_url(invalid_endpoint) == False
+
+ # Invalid endpoint (different scheme)
+ invalid_scheme = "https://example.com/messages/session123"
+ assert transport._validate_endpoint_url(invalid_scheme) == False
+
+
+def test_sse_transport_message_parsing():
+ """Test SSE transport properly parses different message types."""
+ from core.mcp.client.sse_client import SSETransport
+
+ transport = SSETransport("http://example.com/sse")
+ read_queue: queue.Queue = queue.Queue()
+
+ # Test valid JSON-RPC message
+ valid_message = '{"jsonrpc": "2.0", "id": 1, "method": "ping"}'
+ transport._handle_message_event(valid_message, read_queue)
+
+ # Should have a SessionMessage in the queue
+ message = read_queue.get(timeout=1.0)
+ assert message is not None
+ assert hasattr(message, "message")
+
+ # Test invalid JSON
+ invalid_json = '{"invalid": json}'
+ transport._handle_message_event(invalid_json, read_queue)
+
+ # Should have an exception in the queue
+ error = read_queue.get(timeout=1.0)
+ assert isinstance(error, Exception)
+
+
+def test_sse_client_queue_cleanup():
+ """Test that SSE client properly cleans up queues on exit."""
+ test_url = "http://test.example/sse"
+
+ read_queue = None
+ write_queue = None
+
+ with patch("core.mcp.client.sse_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory:
+ with patch("core.mcp.client.sse_client.ssrf_proxy_sse_connect") as mock_sse_connect:
+ # Mock connection that raises an exception
+ mock_sse_connect.side_effect = Exception("Connection failed")
+
+ try:
+ with sse_client(test_url) as (rq, wq):
+ read_queue = rq
+ write_queue = wq
+ except Exception:
+ pass # Expected to fail
+
+ # Queues should be cleaned up even on exception
+ # Note: In real implementation, cleanup should put None to signal shutdown
+
+
+def test_sse_client_url_processing():
+ """Test SSE client URL processing functions."""
+ from core.mcp.client.sse_client import remove_request_params
+
+ # Test URL with parameters
+ url_with_params = "http://example.com/sse?param1=value1¶m2=value2"
+ cleaned_url = remove_request_params(url_with_params)
+ assert cleaned_url == "http://example.com/sse"
+
+ # Test URL without parameters
+ url_without_params = "http://example.com/sse"
+ cleaned_url = remove_request_params(url_without_params)
+ assert cleaned_url == "http://example.com/sse"
+
+ # Test URL with path and parameters
+ complex_url = "http://example.com/path/to/sse?session=123&token=abc"
+ cleaned_url = remove_request_params(complex_url)
+ assert cleaned_url == "http://example.com/path/to/sse"
+
+
+def test_sse_client_headers_propagation():
+ """Test that custom headers are properly propagated in SSE client."""
+ test_url = "http://test.example/sse"
+ custom_headers = {
+ "Authorization": "Bearer test-token",
+ "X-Custom-Header": "test-value",
+ "User-Agent": "test-client/1.0",
+ }
+
+ with patch("core.mcp.client.sse_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory:
+ with patch("core.mcp.client.sse_client.ssrf_proxy_sse_connect") as mock_sse_connect:
+ # Mock the client factory to capture headers
+ mock_client = Mock()
+ mock_client_factory.return_value.__enter__.return_value = mock_client
+
+ # Mock the SSE connection
+ mock_event_source = Mock()
+ mock_event_source.response.raise_for_status.return_value = None
+ mock_event_source.iter_sse.return_value = []
+ mock_sse_connect.return_value.__enter__.return_value = mock_event_source
+
+ try:
+ with sse_client(test_url, headers=custom_headers):
+ pass
+ except Exception:
+ pass # Expected due to mocking
+
+ # Verify headers were passed to client factory
+ mock_client_factory.assert_called_with(headers=custom_headers)
+
+
+def test_sse_client_concurrent_access():
+ """Test SSE client behavior with concurrent queue access."""
+ test_read_queue: queue.Queue = queue.Queue()
+
+ # Simulate concurrent producers and consumers
+ def producer():
+ for i in range(10):
+ test_read_queue.put(f"message_{i}")
+ time.sleep(0.01) # Small delay to simulate real conditions
+
+ def consumer():
+ received = []
+ for _ in range(10):
+ try:
+ msg = test_read_queue.get(timeout=2.0)
+ received.append(msg)
+ except queue.Empty:
+ break
+ return received
+
+ # Start producer in separate thread
+ producer_thread = threading.Thread(target=producer, daemon=True)
+ producer_thread.start()
+
+ # Consume messages
+ received_messages = consumer()
+
+ # Wait for producer to finish
+ producer_thread.join(timeout=5.0)
+
+ # Verify all messages were received
+ assert len(received_messages) == 10
+ for i in range(10):
+ assert f"message_{i}" in received_messages
diff --git a/api/tests/unit_tests/core/mcp/client/test_streamable_http.py b/api/tests/unit_tests/core/mcp/client/test_streamable_http.py
new file mode 100644
index 0000000000..9a30a35a49
--- /dev/null
+++ b/api/tests/unit_tests/core/mcp/client/test_streamable_http.py
@@ -0,0 +1,450 @@
+"""
+Tests for the StreamableHTTP client transport.
+
+Contains tests for only the client side of the StreamableHTTP transport.
+"""
+
+import queue
+import threading
+import time
+from typing import Any
+from unittest.mock import Mock, patch
+
+from core.mcp import types
+from core.mcp.client.streamable_client import streamablehttp_client
+
+# Test constants
+SERVER_NAME = "test_streamable_http_server"
+TEST_SESSION_ID = "test-session-id-12345"
+INIT_REQUEST = {
+ "jsonrpc": "2.0",
+ "method": "initialize",
+ "params": {
+ "clientInfo": {"name": "test-client", "version": "1.0"},
+ "protocolVersion": "2025-03-26",
+ "capabilities": {},
+ },
+ "id": "init-1",
+}
+
+
+class MockStreamableHTTPClient:
+ """Mock StreamableHTTP client for testing."""
+
+ def __init__(self, url: str, headers: dict[str, Any] | None = None):
+ self.url = url
+ self.headers = headers or {}
+ self.connected = False
+ self.read_queue: queue.Queue = queue.Queue()
+ self.write_queue: queue.Queue = queue.Queue()
+ self.session_id = TEST_SESSION_ID
+
+ def connect(self):
+ """Simulate connection establishment."""
+ self.connected = True
+ return self.read_queue, self.write_queue, lambda: self.session_id
+
+ def send_initialize_response(self):
+ """Send a mock initialize response."""
+ session_message = types.SessionMessage(
+ message=types.JSONRPCMessage(
+ root=types.JSONRPCResponse(
+ jsonrpc="2.0",
+ id="init-1",
+ result={
+ "protocolVersion": types.LATEST_PROTOCOL_VERSION,
+ "capabilities": {
+ "logging": None,
+ "resources": None,
+ "tools": None,
+ "experimental": None,
+ "prompts": None,
+ },
+ "serverInfo": {"name": SERVER_NAME, "version": "0.1.0"},
+ "instructions": "Test server instructions.",
+ },
+ )
+ )
+ )
+ self.read_queue.put(session_message)
+
+ def send_tools_response(self):
+ """Send a mock tools list response."""
+ session_message = types.SessionMessage(
+ message=types.JSONRPCMessage(
+ root=types.JSONRPCResponse(
+ jsonrpc="2.0",
+ id="tools-1",
+ result={
+ "tools": [
+ {
+ "name": "test_tool",
+ "description": "A test tool",
+ "inputSchema": {"type": "object", "properties": {}},
+ }
+ ],
+ },
+ )
+ )
+ )
+ self.read_queue.put(session_message)
+
+
+def test_streamablehttp_client_message_id_handling():
+ """Test StreamableHTTP client properly handles message ID coercion."""
+ mock_client = MockStreamableHTTPClient("http://test.example/mcp")
+ read_queue, write_queue, get_session_id = mock_client.connect()
+
+ # Send a message with string ID that should be coerced to int
+ response_message = types.SessionMessage(
+ message=types.JSONRPCMessage(root=types.JSONRPCResponse(jsonrpc="2.0", id="789", result={"test": "data"}))
+ )
+ read_queue.put(response_message)
+
+ # Get the message from queue
+ message = read_queue.get(timeout=1.0)
+ assert message is not None
+ assert isinstance(message, types.SessionMessage)
+
+ # Check that the ID was properly handled
+ assert isinstance(message.message.root, types.JSONRPCResponse)
+ assert message.message.root.id == 789 # ID should be coerced to int due to union_mode="left_to_right"
+
+
+def test_streamablehttp_client_connection_validation():
+ """Test StreamableHTTP client validates connections properly."""
+ test_url = "http://test.example/mcp"
+
+ with patch("core.mcp.client.streamable_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory:
+ # Mock the HTTP client
+ mock_client = Mock()
+ mock_client_factory.return_value.__enter__.return_value = mock_client
+
+ # Mock successful response
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.headers = {"content-type": "application/json"}
+ mock_response.raise_for_status.return_value = None
+ mock_client.post.return_value = mock_response
+
+ # Test connection
+ try:
+ with streamablehttp_client(test_url) as (read_queue, write_queue, get_session_id):
+ assert read_queue is not None
+ assert write_queue is not None
+ assert get_session_id is not None
+ except Exception:
+ # Connection might fail due to mocking, but we're testing the validation logic
+ pass
+
+
+def test_streamablehttp_client_timeout_configuration():
+ """Test StreamableHTTP client timeout configuration."""
+ test_url = "http://test.example/mcp"
+ custom_headers = {"Authorization": "Bearer test-token"}
+
+ with patch("core.mcp.client.streamable_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory:
+ # Mock successful connection
+ mock_client = Mock()
+ mock_client_factory.return_value.__enter__.return_value = mock_client
+
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.headers = {"content-type": "application/json"}
+ mock_response.raise_for_status.return_value = None
+ mock_client.post.return_value = mock_response
+
+ try:
+ with streamablehttp_client(test_url, headers=custom_headers) as (read_queue, write_queue, get_session_id):
+ # Verify the configuration was passed correctly
+ mock_client_factory.assert_called_with(headers=custom_headers)
+ except Exception:
+ # Connection might fail due to mocking, but we tested the configuration
+ pass
+
+
+def test_streamablehttp_client_session_id_handling():
+ """Test StreamableHTTP client properly handles session IDs."""
+ mock_client = MockStreamableHTTPClient("http://test.example/mcp")
+ read_queue, write_queue, get_session_id = mock_client.connect()
+
+ # Test that session ID is available
+ session_id = get_session_id()
+ assert session_id == TEST_SESSION_ID
+
+ # Test that we can use the session ID in subsequent requests
+ assert session_id is not None
+ assert len(session_id) > 0
+
+
+def test_streamablehttp_client_message_parsing():
+ """Test StreamableHTTP client properly parses different message types."""
+ mock_client = MockStreamableHTTPClient("http://test.example/mcp")
+ read_queue, write_queue, get_session_id = mock_client.connect()
+
+ # Test valid initialization response
+ mock_client.send_initialize_response()
+
+ # Should have a SessionMessage in the queue
+ message = read_queue.get(timeout=1.0)
+ assert message is not None
+ assert isinstance(message, types.SessionMessage)
+ assert isinstance(message.message.root, types.JSONRPCResponse)
+
+ # Test tools response
+ mock_client.send_tools_response()
+
+ tools_message = read_queue.get(timeout=1.0)
+ assert tools_message is not None
+ assert isinstance(tools_message, types.SessionMessage)
+
+
+def test_streamablehttp_client_queue_cleanup():
+ """Test that StreamableHTTP client properly cleans up queues on exit."""
+ test_url = "http://test.example/mcp"
+
+ read_queue = None
+ write_queue = None
+
+ with patch("core.mcp.client.streamable_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory:
+ # Mock connection that raises an exception
+ mock_client_factory.side_effect = Exception("Connection failed")
+
+ try:
+ with streamablehttp_client(test_url) as (rq, wq, get_session_id):
+ read_queue = rq
+ write_queue = wq
+ except Exception:
+ pass # Expected to fail
+
+ # Queues should be cleaned up even on exception
+ # Note: In real implementation, cleanup should put None to signal shutdown
+
+
+def test_streamablehttp_client_headers_propagation():
+ """Test that custom headers are properly propagated in StreamableHTTP client."""
+ test_url = "http://test.example/mcp"
+ custom_headers = {
+ "Authorization": "Bearer test-token",
+ "X-Custom-Header": "test-value",
+ "User-Agent": "test-client/1.0",
+ }
+
+ with patch("core.mcp.client.streamable_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory:
+ # Mock the client factory to capture headers
+ mock_client = Mock()
+ mock_client_factory.return_value.__enter__.return_value = mock_client
+
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.headers = {"content-type": "application/json"}
+ mock_response.raise_for_status.return_value = None
+ mock_client.post.return_value = mock_response
+
+ try:
+ with streamablehttp_client(test_url, headers=custom_headers):
+ pass
+ except Exception:
+ pass # Expected due to mocking
+
+ # Verify headers were passed to client factory
+ # Check that the call was made with headers that include our custom headers
+ mock_client_factory.assert_called_once()
+ call_args = mock_client_factory.call_args
+ assert "headers" in call_args.kwargs
+ passed_headers = call_args.kwargs["headers"]
+
+ # Verify all custom headers are present
+ for key, value in custom_headers.items():
+ assert key in passed_headers
+ assert passed_headers[key] == value
+
+
+def test_streamablehttp_client_concurrent_access():
+ """Test StreamableHTTP client behavior with concurrent queue access."""
+ test_read_queue: queue.Queue = queue.Queue()
+ test_write_queue: queue.Queue = queue.Queue()
+
+ # Simulate concurrent producers and consumers
+ def producer():
+ for i in range(10):
+ test_read_queue.put(f"message_{i}")
+ time.sleep(0.01) # Small delay to simulate real conditions
+
+ def consumer():
+ received = []
+ for _ in range(10):
+ try:
+ msg = test_read_queue.get(timeout=2.0)
+ received.append(msg)
+ except queue.Empty:
+ break
+ return received
+
+ # Start producer in separate thread
+ producer_thread = threading.Thread(target=producer, daemon=True)
+ producer_thread.start()
+
+ # Consume messages
+ received_messages = consumer()
+
+ # Wait for producer to finish
+ producer_thread.join(timeout=5.0)
+
+ # Verify all messages were received
+ assert len(received_messages) == 10
+ for i in range(10):
+ assert f"message_{i}" in received_messages
+
+
+def test_streamablehttp_client_json_vs_sse_mode():
+ """Test StreamableHTTP client handling of JSON vs SSE response modes."""
+ test_url = "http://test.example/mcp"
+
+ with patch("core.mcp.client.streamable_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory:
+ mock_client = Mock()
+ mock_client_factory.return_value.__enter__.return_value = mock_client
+
+ # Mock JSON response
+ mock_json_response = Mock()
+ mock_json_response.status_code = 200
+ mock_json_response.headers = {"content-type": "application/json"}
+ mock_json_response.json.return_value = {"result": "json_mode"}
+ mock_json_response.raise_for_status.return_value = None
+
+ # Mock SSE response
+ mock_sse_response = Mock()
+ mock_sse_response.status_code = 200
+ mock_sse_response.headers = {"content-type": "text/event-stream"}
+ mock_sse_response.raise_for_status.return_value = None
+
+ # Test JSON mode
+ mock_client.post.return_value = mock_json_response
+
+ try:
+ with streamablehttp_client(test_url) as (read_queue, write_queue, get_session_id):
+ # Should handle JSON responses
+ assert read_queue is not None
+ assert write_queue is not None
+ except Exception:
+ pass # Expected due to mocking
+
+ # Test SSE mode
+ mock_client.post.return_value = mock_sse_response
+
+ try:
+ with streamablehttp_client(test_url) as (read_queue, write_queue, get_session_id):
+ # Should handle SSE responses
+ assert read_queue is not None
+ assert write_queue is not None
+ except Exception:
+ pass # Expected due to mocking
+
+
+def test_streamablehttp_client_terminate_on_close():
+ """Test StreamableHTTP client terminate_on_close parameter."""
+ test_url = "http://test.example/mcp"
+
+ with patch("core.mcp.client.streamable_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory:
+ mock_client = Mock()
+ mock_client_factory.return_value.__enter__.return_value = mock_client
+
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.headers = {"content-type": "application/json"}
+ mock_response.raise_for_status.return_value = None
+ mock_client.post.return_value = mock_response
+ mock_client.delete.return_value = mock_response
+
+ # Test with terminate_on_close=True (default)
+ try:
+ with streamablehttp_client(test_url, terminate_on_close=True) as (read_queue, write_queue, get_session_id):
+ pass
+ except Exception:
+ pass # Expected due to mocking
+
+ # Test with terminate_on_close=False
+ try:
+ with streamablehttp_client(test_url, terminate_on_close=False) as (read_queue, write_queue, get_session_id):
+ pass
+ except Exception:
+ pass # Expected due to mocking
+
+
+def test_streamablehttp_client_protocol_version_handling():
+ """Test StreamableHTTP client protocol version handling."""
+ mock_client = MockStreamableHTTPClient("http://test.example/mcp")
+ read_queue, write_queue, get_session_id = mock_client.connect()
+
+ # Send initialize response with specific protocol version
+
+ session_message = types.SessionMessage(
+ message=types.JSONRPCMessage(
+ root=types.JSONRPCResponse(
+ jsonrpc="2.0",
+ id="init-1",
+ result={
+ "protocolVersion": "2024-11-05",
+ "capabilities": {},
+ "serverInfo": {"name": SERVER_NAME, "version": "0.1.0"},
+ },
+ )
+ )
+ )
+ read_queue.put(session_message)
+
+ # Get the message and verify protocol version
+ message = read_queue.get(timeout=1.0)
+ assert message is not None
+ assert isinstance(message.message.root, types.JSONRPCResponse)
+ result = message.message.root.result
+ assert result["protocolVersion"] == "2024-11-05"
+
+
+def test_streamablehttp_client_error_response_handling():
+ """Test StreamableHTTP client handling of error responses."""
+ mock_client = MockStreamableHTTPClient("http://test.example/mcp")
+ read_queue, write_queue, get_session_id = mock_client.connect()
+
+ # Send an error response
+ session_message = types.SessionMessage(
+ message=types.JSONRPCMessage(
+ root=types.JSONRPCError(
+ jsonrpc="2.0",
+ id="test-1",
+ error=types.ErrorData(code=-32601, message="Method not found", data=None),
+ )
+ )
+ )
+ read_queue.put(session_message)
+
+ # Get the error message
+ message = read_queue.get(timeout=1.0)
+ assert message is not None
+ assert isinstance(message.message.root, types.JSONRPCError)
+ assert message.message.root.error.code == -32601
+ assert message.message.root.error.message == "Method not found"
+
+
+def test_streamablehttp_client_resumption_token_handling():
+ """Test StreamableHTTP client resumption token functionality."""
+ test_url = "http://test.example/mcp"
+ test_resumption_token = "resume-token-123"
+
+ with patch("core.mcp.client.streamable_client.create_ssrf_proxy_mcp_http_client") as mock_client_factory:
+ mock_client = Mock()
+ mock_client_factory.return_value.__enter__.return_value = mock_client
+
+ mock_response = Mock()
+ mock_response.status_code = 200
+ mock_response.headers = {"content-type": "application/json", "last-event-id": test_resumption_token}
+ mock_response.raise_for_status.return_value = None
+ mock_client.post.return_value = mock_response
+
+ try:
+ with streamablehttp_client(test_url) as (read_queue, write_queue, get_session_id):
+ # Test that resumption token can be captured from headers
+ assert read_queue is not None
+ assert write_queue is not None
+ except Exception:
+ pass # Expected due to mocking
diff --git a/api/tests/unit_tests/core/ops/__init__.py b/api/tests/unit_tests/core/ops/__init__.py
new file mode 100644
index 0000000000..bb92ccdec7
--- /dev/null
+++ b/api/tests/unit_tests/core/ops/__init__.py
@@ -0,0 +1 @@
+# Unit tests for core ops module
diff --git a/api/tests/unit_tests/core/ops/test_config_entity.py b/api/tests/unit_tests/core/ops/test_config_entity.py
new file mode 100644
index 0000000000..81cb04548d
--- /dev/null
+++ b/api/tests/unit_tests/core/ops/test_config_entity.py
@@ -0,0 +1,385 @@
+import pytest
+from pydantic import ValidationError
+
+from core.ops.entities.config_entity import (
+ AliyunConfig,
+ ArizeConfig,
+ LangfuseConfig,
+ LangSmithConfig,
+ OpikConfig,
+ PhoenixConfig,
+ TracingProviderEnum,
+ WeaveConfig,
+)
+
+
+class TestTracingProviderEnum:
+ """Test cases for TracingProviderEnum"""
+
+ def test_enum_values(self):
+ """Test that all expected enum values are present"""
+ assert TracingProviderEnum.ARIZE == "arize"
+ assert TracingProviderEnum.PHOENIX == "phoenix"
+ assert TracingProviderEnum.LANGFUSE == "langfuse"
+ assert TracingProviderEnum.LANGSMITH == "langsmith"
+ assert TracingProviderEnum.OPIK == "opik"
+ assert TracingProviderEnum.WEAVE == "weave"
+ assert TracingProviderEnum.ALIYUN == "aliyun"
+
+
+class TestArizeConfig:
+ """Test cases for ArizeConfig"""
+
+ def test_valid_config(self):
+ """Test valid Arize configuration"""
+ config = ArizeConfig(
+ api_key="test_key", space_id="test_space", project="test_project", endpoint="https://custom.arize.com"
+ )
+ assert config.api_key == "test_key"
+ assert config.space_id == "test_space"
+ assert config.project == "test_project"
+ assert config.endpoint == "https://custom.arize.com"
+
+ def test_default_values(self):
+ """Test default values are set correctly"""
+ config = ArizeConfig()
+ assert config.api_key is None
+ assert config.space_id is None
+ assert config.project is None
+ assert config.endpoint == "https://otlp.arize.com"
+
+ def test_project_validation_empty(self):
+ """Test project validation with empty value"""
+ config = ArizeConfig(project="")
+ assert config.project == "default"
+
+ def test_project_validation_none(self):
+ """Test project validation with None value"""
+ config = ArizeConfig(project=None)
+ assert config.project == "default"
+
+ def test_endpoint_validation_empty(self):
+ """Test endpoint validation with empty value"""
+ config = ArizeConfig(endpoint="")
+ assert config.endpoint == "https://otlp.arize.com"
+
+ def test_endpoint_validation_with_path(self):
+ """Test endpoint validation normalizes URL by removing path"""
+ config = ArizeConfig(endpoint="https://custom.arize.com/api/v1")
+ assert config.endpoint == "https://custom.arize.com"
+
+ def test_endpoint_validation_invalid_scheme(self):
+ """Test endpoint validation rejects invalid schemes"""
+ with pytest.raises(ValidationError, match="URL scheme must be one of"):
+ ArizeConfig(endpoint="ftp://invalid.com")
+
+ def test_endpoint_validation_no_scheme(self):
+ """Test endpoint validation rejects URLs without scheme"""
+ with pytest.raises(ValidationError, match="URL scheme must be one of"):
+ ArizeConfig(endpoint="invalid.com")
+
+
+class TestPhoenixConfig:
+ """Test cases for PhoenixConfig"""
+
+ def test_valid_config(self):
+ """Test valid Phoenix configuration"""
+ config = PhoenixConfig(api_key="test_key", project="test_project", endpoint="https://custom.phoenix.com")
+ assert config.api_key == "test_key"
+ assert config.project == "test_project"
+ assert config.endpoint == "https://custom.phoenix.com"
+
+ def test_default_values(self):
+ """Test default values are set correctly"""
+ config = PhoenixConfig()
+ assert config.api_key is None
+ assert config.project is None
+ assert config.endpoint == "https://app.phoenix.arize.com"
+
+ def test_project_validation_empty(self):
+ """Test project validation with empty value"""
+ config = PhoenixConfig(project="")
+ assert config.project == "default"
+
+ def test_endpoint_validation_with_path(self):
+ """Test endpoint validation normalizes URL by removing path"""
+ config = PhoenixConfig(endpoint="https://custom.phoenix.com/api/v1")
+ assert config.endpoint == "https://custom.phoenix.com"
+
+
+class TestLangfuseConfig:
+ """Test cases for LangfuseConfig"""
+
+ def test_valid_config(self):
+ """Test valid Langfuse configuration"""
+ config = LangfuseConfig(public_key="public_key", secret_key="secret_key", host="https://custom.langfuse.com")
+ assert config.public_key == "public_key"
+ assert config.secret_key == "secret_key"
+ assert config.host == "https://custom.langfuse.com"
+
+ def test_default_values(self):
+ """Test default values are set correctly"""
+ config = LangfuseConfig(public_key="public", secret_key="secret")
+ assert config.host == "https://api.langfuse.com"
+
+ def test_missing_required_fields(self):
+ """Test that required fields are enforced"""
+ with pytest.raises(ValidationError):
+ LangfuseConfig()
+
+ with pytest.raises(ValidationError):
+ LangfuseConfig(public_key="public")
+
+ with pytest.raises(ValidationError):
+ LangfuseConfig(secret_key="secret")
+
+ def test_host_validation_empty(self):
+ """Test host validation with empty value"""
+ config = LangfuseConfig(public_key="public", secret_key="secret", host="")
+ assert config.host == "https://api.langfuse.com"
+
+
+class TestLangSmithConfig:
+ """Test cases for LangSmithConfig"""
+
+ def test_valid_config(self):
+ """Test valid LangSmith configuration"""
+ config = LangSmithConfig(api_key="test_key", project="test_project", endpoint="https://custom.smith.com")
+ assert config.api_key == "test_key"
+ assert config.project == "test_project"
+ assert config.endpoint == "https://custom.smith.com"
+
+ def test_default_values(self):
+ """Test default values are set correctly"""
+ config = LangSmithConfig(api_key="key", project="project")
+ assert config.endpoint == "https://api.smith.langchain.com"
+
+ def test_missing_required_fields(self):
+ """Test that required fields are enforced"""
+ with pytest.raises(ValidationError):
+ LangSmithConfig()
+
+ with pytest.raises(ValidationError):
+ LangSmithConfig(api_key="key")
+
+ with pytest.raises(ValidationError):
+ LangSmithConfig(project="project")
+
+ def test_endpoint_validation_https_only(self):
+ """Test endpoint validation only allows HTTPS"""
+ with pytest.raises(ValidationError, match="URL scheme must be one of"):
+ LangSmithConfig(api_key="key", project="project", endpoint="http://insecure.com")
+
+
+class TestOpikConfig:
+ """Test cases for OpikConfig"""
+
+ def test_valid_config(self):
+ """Test valid Opik configuration"""
+ config = OpikConfig(
+ api_key="test_key",
+ project="test_project",
+ workspace="test_workspace",
+ url="https://custom.comet.com/opik/api/",
+ )
+ assert config.api_key == "test_key"
+ assert config.project == "test_project"
+ assert config.workspace == "test_workspace"
+ assert config.url == "https://custom.comet.com/opik/api/"
+
+ def test_default_values(self):
+ """Test default values are set correctly"""
+ config = OpikConfig()
+ assert config.api_key is None
+ assert config.project is None
+ assert config.workspace is None
+ assert config.url == "https://www.comet.com/opik/api/"
+
+ def test_project_validation_empty(self):
+ """Test project validation with empty value"""
+ config = OpikConfig(project="")
+ assert config.project == "Default Project"
+
+ def test_url_validation_empty(self):
+ """Test URL validation with empty value"""
+ config = OpikConfig(url="")
+ assert config.url == "https://www.comet.com/opik/api/"
+
+ def test_url_validation_missing_suffix(self):
+ """Test URL validation requires /api/ suffix"""
+ with pytest.raises(ValidationError, match="URL should end with /api/"):
+ OpikConfig(url="https://custom.comet.com/opik/")
+
+ def test_url_validation_invalid_scheme(self):
+ """Test URL validation rejects invalid schemes"""
+ with pytest.raises(ValidationError, match="URL must start with https:// or http://"):
+ OpikConfig(url="ftp://custom.comet.com/opik/api/")
+
+
+class TestWeaveConfig:
+ """Test cases for WeaveConfig"""
+
+ def test_valid_config(self):
+ """Test valid Weave configuration"""
+ config = WeaveConfig(
+ api_key="test_key",
+ entity="test_entity",
+ project="test_project",
+ endpoint="https://custom.wandb.ai",
+ host="https://custom.host.com",
+ )
+ assert config.api_key == "test_key"
+ assert config.entity == "test_entity"
+ assert config.project == "test_project"
+ assert config.endpoint == "https://custom.wandb.ai"
+ assert config.host == "https://custom.host.com"
+
+ def test_default_values(self):
+ """Test default values are set correctly"""
+ config = WeaveConfig(api_key="key", project="project")
+ assert config.entity is None
+ assert config.endpoint == "https://trace.wandb.ai"
+ assert config.host is None
+
+ def test_missing_required_fields(self):
+ """Test that required fields are enforced"""
+ with pytest.raises(ValidationError):
+ WeaveConfig()
+
+ with pytest.raises(ValidationError):
+ WeaveConfig(api_key="key")
+
+ with pytest.raises(ValidationError):
+ WeaveConfig(project="project")
+
+ def test_endpoint_validation_https_only(self):
+ """Test endpoint validation only allows HTTPS"""
+ with pytest.raises(ValidationError, match="URL scheme must be one of"):
+ WeaveConfig(api_key="key", project="project", endpoint="http://insecure.wandb.ai")
+
+ def test_host_validation_optional(self):
+ """Test host validation is optional but validates when provided"""
+ config = WeaveConfig(api_key="key", project="project", host=None)
+ assert config.host is None
+
+ config = WeaveConfig(api_key="key", project="project", host="")
+ assert config.host == ""
+
+ config = WeaveConfig(api_key="key", project="project", host="https://valid.host.com")
+ assert config.host == "https://valid.host.com"
+
+ def test_host_validation_invalid_scheme(self):
+ """Test host validation rejects invalid schemes when provided"""
+ with pytest.raises(ValidationError, match="URL scheme must be one of"):
+ WeaveConfig(api_key="key", project="project", host="ftp://invalid.host.com")
+
+
+class TestAliyunConfig:
+ """Test cases for AliyunConfig"""
+
+ def test_valid_config(self):
+ """Test valid Aliyun configuration"""
+ config = AliyunConfig(
+ app_name="test_app",
+ license_key="test_license_key",
+ endpoint="https://custom.tracing-analysis-dc-hz.aliyuncs.com",
+ )
+ assert config.app_name == "test_app"
+ assert config.license_key == "test_license_key"
+ assert config.endpoint == "https://custom.tracing-analysis-dc-hz.aliyuncs.com"
+
+ def test_default_values(self):
+ """Test default values are set correctly"""
+ config = AliyunConfig(license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com")
+ assert config.app_name == "dify_app"
+
+ def test_missing_required_fields(self):
+ """Test that required fields are enforced"""
+ with pytest.raises(ValidationError):
+ AliyunConfig()
+
+ with pytest.raises(ValidationError):
+ AliyunConfig(license_key="test_license")
+
+ with pytest.raises(ValidationError):
+ AliyunConfig(endpoint="https://tracing-analysis-dc-hz.aliyuncs.com")
+
+ def test_app_name_validation_empty(self):
+ """Test app_name validation with empty value"""
+ config = AliyunConfig(
+ license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com", app_name=""
+ )
+ assert config.app_name == "dify_app"
+
+ def test_endpoint_validation_empty(self):
+ """Test endpoint validation with empty value"""
+ config = AliyunConfig(license_key="test_license", endpoint="")
+ assert config.endpoint == "https://tracing-analysis-dc-hz.aliyuncs.com"
+
+ def test_endpoint_validation_with_path(self):
+ """Test endpoint validation normalizes URL by removing path"""
+ config = AliyunConfig(
+ license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com/api/v1/traces"
+ )
+ assert config.endpoint == "https://tracing-analysis-dc-hz.aliyuncs.com"
+
+ def test_endpoint_validation_invalid_scheme(self):
+ """Test endpoint validation rejects invalid schemes"""
+ with pytest.raises(ValidationError, match="URL scheme must be one of"):
+ AliyunConfig(license_key="test_license", endpoint="ftp://invalid.tracing-analysis-dc-hz.aliyuncs.com")
+
+ def test_endpoint_validation_no_scheme(self):
+ """Test endpoint validation rejects URLs without scheme"""
+ with pytest.raises(ValidationError, match="URL scheme must be one of"):
+ AliyunConfig(license_key="test_license", endpoint="invalid.tracing-analysis-dc-hz.aliyuncs.com")
+
+ def test_license_key_required(self):
+ """Test that license_key is required and cannot be empty"""
+ with pytest.raises(ValidationError):
+ AliyunConfig(license_key="", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com")
+
+
+class TestConfigIntegration:
+ """Integration tests for configuration classes"""
+
+ def test_all_configs_can_be_instantiated(self):
+ """Test that all config classes can be instantiated with valid data"""
+ configs = [
+ ArizeConfig(api_key="key"),
+ PhoenixConfig(api_key="key"),
+ LangfuseConfig(public_key="public", secret_key="secret"),
+ LangSmithConfig(api_key="key", project="project"),
+ OpikConfig(api_key="key"),
+ WeaveConfig(api_key="key", project="project"),
+ AliyunConfig(license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com"),
+ ]
+
+ for config in configs:
+ assert config is not None
+
+ def test_url_normalization_consistency(self):
+ """Test that URL normalization works consistently across configs"""
+ # Test that paths are removed from endpoints
+ arize_config = ArizeConfig(endpoint="https://arize.com/api/v1/test")
+ phoenix_config = PhoenixConfig(endpoint="https://phoenix.com/api/v2/")
+ aliyun_config = AliyunConfig(
+ license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com/api/v1/traces"
+ )
+
+ assert arize_config.endpoint == "https://arize.com"
+ assert phoenix_config.endpoint == "https://phoenix.com"
+ assert aliyun_config.endpoint == "https://tracing-analysis-dc-hz.aliyuncs.com"
+
+ def test_project_default_values(self):
+ """Test that project default values are set correctly"""
+ arize_config = ArizeConfig(project="")
+ phoenix_config = PhoenixConfig(project="")
+ opik_config = OpikConfig(project="")
+ aliyun_config = AliyunConfig(
+ license_key="test_license", endpoint="https://tracing-analysis-dc-hz.aliyuncs.com", app_name=""
+ )
+
+ assert arize_config.project == "default"
+ assert phoenix_config.project == "default"
+ assert opik_config.project == "Default Project"
+ assert aliyun_config.app_name == "dify_app"
diff --git a/api/tests/unit_tests/core/ops/test_utils.py b/api/tests/unit_tests/core/ops/test_utils.py
new file mode 100644
index 0000000000..7cc2772acf
--- /dev/null
+++ b/api/tests/unit_tests/core/ops/test_utils.py
@@ -0,0 +1,138 @@
+import pytest
+
+from core.ops.utils import validate_project_name, validate_url, validate_url_with_path
+
+
+class TestValidateUrl:
+ """Test cases for validate_url function"""
+
+ def test_valid_https_url(self):
+ """Test valid HTTPS URL"""
+ result = validate_url("https://example.com", "https://default.com")
+ assert result == "https://example.com"
+
+ def test_valid_http_url(self):
+ """Test valid HTTP URL"""
+ result = validate_url("http://example.com", "https://default.com")
+ assert result == "http://example.com"
+
+ def test_url_with_path_removed(self):
+ """Test that URL path is removed during normalization"""
+ result = validate_url("https://example.com/api/v1/test", "https://default.com")
+ assert result == "https://example.com"
+
+ def test_url_with_query_removed(self):
+ """Test that URL query parameters are removed"""
+ result = validate_url("https://example.com?param=value", "https://default.com")
+ assert result == "https://example.com"
+
+ def test_url_with_fragment_removed(self):
+ """Test that URL fragments are removed"""
+ result = validate_url("https://example.com#section", "https://default.com")
+ assert result == "https://example.com"
+
+ def test_empty_url_returns_default(self):
+ """Test empty URL returns default"""
+ result = validate_url("", "https://default.com")
+ assert result == "https://default.com"
+
+ def test_none_url_returns_default(self):
+ """Test None URL returns default"""
+ result = validate_url(None, "https://default.com")
+ assert result == "https://default.com"
+
+ def test_whitespace_url_returns_default(self):
+ """Test whitespace URL returns default"""
+ result = validate_url(" ", "https://default.com")
+ assert result == "https://default.com"
+
+ def test_invalid_scheme_raises_error(self):
+ """Test invalid scheme raises ValueError"""
+ with pytest.raises(ValueError, match="URL scheme must be one of"):
+ validate_url("ftp://example.com", "https://default.com")
+
+ def test_no_scheme_raises_error(self):
+ """Test URL without scheme raises ValueError"""
+ with pytest.raises(ValueError, match="URL scheme must be one of"):
+ validate_url("example.com", "https://default.com")
+
+ def test_custom_allowed_schemes(self):
+ """Test custom allowed schemes"""
+ result = validate_url("https://example.com", "https://default.com", allowed_schemes=("https",))
+ assert result == "https://example.com"
+
+ with pytest.raises(ValueError, match="URL scheme must be one of"):
+ validate_url("http://example.com", "https://default.com", allowed_schemes=("https",))
+
+
+class TestValidateUrlWithPath:
+ """Test cases for validate_url_with_path function"""
+
+ def test_valid_url_with_path(self):
+ """Test valid URL with path"""
+ result = validate_url_with_path("https://example.com/api/v1", "https://default.com")
+ assert result == "https://example.com/api/v1"
+
+ def test_valid_url_with_required_suffix(self):
+ """Test valid URL with required suffix"""
+ result = validate_url_with_path("https://example.com/api/", "https://default.com", required_suffix="/api/")
+ assert result == "https://example.com/api/"
+
+ def test_url_without_required_suffix_raises_error(self):
+ """Test URL without required suffix raises error"""
+ with pytest.raises(ValueError, match="URL should end with /api/"):
+ validate_url_with_path("https://example.com/api", "https://default.com", required_suffix="/api/")
+
+ def test_empty_url_returns_default(self):
+ """Test empty URL returns default"""
+ result = validate_url_with_path("", "https://default.com")
+ assert result == "https://default.com"
+
+ def test_none_url_returns_default(self):
+ """Test None URL returns default"""
+ result = validate_url_with_path(None, "https://default.com")
+ assert result == "https://default.com"
+
+ def test_invalid_scheme_raises_error(self):
+ """Test invalid scheme raises ValueError"""
+ with pytest.raises(ValueError, match="URL must start with https:// or http://"):
+ validate_url_with_path("ftp://example.com", "https://default.com")
+
+ def test_no_scheme_raises_error(self):
+ """Test URL without scheme raises ValueError"""
+ with pytest.raises(ValueError, match="URL must start with https:// or http://"):
+ validate_url_with_path("example.com", "https://default.com")
+
+
+class TestValidateProjectName:
+ """Test cases for validate_project_name function"""
+
+ def test_valid_project_name(self):
+ """Test valid project name"""
+ result = validate_project_name("my-project", "default")
+ assert result == "my-project"
+
+ def test_empty_project_name_returns_default(self):
+ """Test empty project name returns default"""
+ result = validate_project_name("", "default")
+ assert result == "default"
+
+ def test_none_project_name_returns_default(self):
+ """Test None project name returns default"""
+ result = validate_project_name(None, "default")
+ assert result == "default"
+
+ def test_whitespace_project_name_returns_default(self):
+ """Test whitespace project name returns default"""
+ result = validate_project_name(" ", "default")
+ assert result == "default"
+
+ def test_project_name_with_whitespace_trimmed(self):
+ """Test project name with whitespace is trimmed"""
+ result = validate_project_name(" my-project ", "default")
+ assert result == "my-project"
+
+ def test_custom_default_name(self):
+ """Test custom default name"""
+ result = validate_project_name("", "Custom Default")
+ assert result == "Custom Default"
diff --git a/api/tests/unit_tests/core/rag/extractor/test_markdown_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_markdown_extractor.py
new file mode 100644
index 0000000000..d4cf534c56
--- /dev/null
+++ b/api/tests/unit_tests/core/rag/extractor/test_markdown_extractor.py
@@ -0,0 +1,22 @@
+from core.rag.extractor.markdown_extractor import MarkdownExtractor
+
+
+def test_markdown_to_tups():
+ markdown = """
+this is some text without header
+
+# title 1
+this is balabala text
+
+## title 2
+this is more specific text.
+ """
+ extractor = MarkdownExtractor(file_path="dummy_path")
+ updated_output = extractor.markdown_to_tups(markdown)
+ assert len(updated_output) == 3
+ key, header_value = updated_output[0]
+ assert key == None
+ assert header_value.strip() == "this is some text without header"
+ title_1, value = updated_output[1]
+ assert title_1.strip() == "title 1"
+ assert value.strip() == "this is balabala text"
diff --git a/api/tests/unit_tests/core/repositories/__init__.py b/api/tests/unit_tests/core/repositories/__init__.py
new file mode 100644
index 0000000000..c65d7da61d
--- /dev/null
+++ b/api/tests/unit_tests/core/repositories/__init__.py
@@ -0,0 +1 @@
+# Unit tests for core repositories module
diff --git a/api/tests/unit_tests/core/repositories/test_factory.py b/api/tests/unit_tests/core/repositories/test_factory.py
new file mode 100644
index 0000000000..fce4a6fb6b
--- /dev/null
+++ b/api/tests/unit_tests/core/repositories/test_factory.py
@@ -0,0 +1,455 @@
+"""
+Unit tests for the RepositoryFactory.
+
+This module tests the factory pattern implementation for creating repository instances
+based on configuration, including error handling and validation.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import pytest
+from pytest_mock import MockerFixture
+from sqlalchemy.engine import Engine
+from sqlalchemy.orm import sessionmaker
+
+from core.repositories.factory import DifyCoreRepositoryFactory, RepositoryImportError
+from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository
+from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository
+from models import Account, EndUser
+from models.enums import WorkflowRunTriggeredFrom
+from models.workflow import WorkflowNodeExecutionTriggeredFrom
+
+
+class TestRepositoryFactory:
+ """Test cases for RepositoryFactory."""
+
+ def test_import_class_success(self):
+ """Test successful class import."""
+ # Test importing a real class
+ class_path = "unittest.mock.MagicMock"
+ result = DifyCoreRepositoryFactory._import_class(class_path)
+ assert result is MagicMock
+
+ def test_import_class_invalid_path(self):
+ """Test import with invalid module path."""
+ with pytest.raises(RepositoryImportError) as exc_info:
+ DifyCoreRepositoryFactory._import_class("invalid.module.path")
+ assert "Cannot import repository class" in str(exc_info.value)
+
+ def test_import_class_invalid_class_name(self):
+ """Test import with invalid class name."""
+ with pytest.raises(RepositoryImportError) as exc_info:
+ DifyCoreRepositoryFactory._import_class("unittest.mock.NonExistentClass")
+ assert "Cannot import repository class" in str(exc_info.value)
+
+ def test_import_class_malformed_path(self):
+ """Test import with malformed path (no dots)."""
+ with pytest.raises(RepositoryImportError) as exc_info:
+ DifyCoreRepositoryFactory._import_class("invalidpath")
+ assert "Cannot import repository class" in str(exc_info.value)
+
+ def test_validate_repository_interface_success(self):
+ """Test successful interface validation."""
+
+ # Create a mock class that implements the required methods
+ class MockRepository:
+ def save(self):
+ pass
+
+ def get_by_id(self):
+ pass
+
+ # Create a mock interface with the same methods
+ class MockInterface:
+ def save(self):
+ pass
+
+ def get_by_id(self):
+ pass
+
+ # Should not raise an exception
+ DifyCoreRepositoryFactory._validate_repository_interface(MockRepository, MockInterface)
+
+ def test_validate_repository_interface_missing_methods(self):
+ """Test interface validation with missing methods."""
+
+ # Create a mock class that doesn't implement all required methods
+ class IncompleteRepository:
+ def save(self):
+ pass
+
+ # Missing get_by_id method
+
+ # Create a mock interface with required methods
+ class MockInterface:
+ def save(self):
+ pass
+
+ def get_by_id(self):
+ pass
+
+ with pytest.raises(RepositoryImportError) as exc_info:
+ DifyCoreRepositoryFactory._validate_repository_interface(IncompleteRepository, MockInterface)
+ assert "does not implement required methods" in str(exc_info.value)
+ assert "get_by_id" in str(exc_info.value)
+
+ def test_validate_constructor_signature_success(self):
+ """Test successful constructor signature validation."""
+
+ class MockRepository:
+ def __init__(self, session_factory, user, app_id, triggered_from):
+ pass
+
+ # Should not raise an exception
+ DifyCoreRepositoryFactory._validate_constructor_signature(
+ MockRepository, ["session_factory", "user", "app_id", "triggered_from"]
+ )
+
+ def test_validate_constructor_signature_missing_params(self):
+ """Test constructor validation with missing parameters."""
+
+ class IncompleteRepository:
+ def __init__(self, session_factory, user):
+ # Missing app_id and triggered_from parameters
+ pass
+
+ with pytest.raises(RepositoryImportError) as exc_info:
+ DifyCoreRepositoryFactory._validate_constructor_signature(
+ IncompleteRepository, ["session_factory", "user", "app_id", "triggered_from"]
+ )
+ assert "does not accept required parameters" in str(exc_info.value)
+ assert "app_id" in str(exc_info.value)
+ assert "triggered_from" in str(exc_info.value)
+
+ def test_validate_constructor_signature_inspection_error(self, mocker: MockerFixture):
+ """Test constructor validation when inspection fails."""
+ # Mock inspect.signature to raise an exception
+ mocker.patch("inspect.signature", side_effect=Exception("Inspection failed"))
+
+ class MockRepository:
+ def __init__(self, session_factory):
+ pass
+
+ with pytest.raises(RepositoryImportError) as exc_info:
+ DifyCoreRepositoryFactory._validate_constructor_signature(MockRepository, ["session_factory"])
+ assert "Failed to validate constructor signature" in str(exc_info.value)
+
+ @patch("core.repositories.factory.dify_config")
+ def test_create_workflow_execution_repository_success(self, mock_config, mocker: MockerFixture):
+ """Test successful creation of WorkflowExecutionRepository."""
+ # Setup mock configuration
+ mock_config.WORKFLOW_EXECUTION_REPOSITORY = "unittest.mock.MagicMock"
+
+ # Create mock dependencies
+ mock_session_factory = MagicMock(spec=sessionmaker)
+ mock_user = MagicMock(spec=Account)
+ app_id = "test-app-id"
+ triggered_from = WorkflowRunTriggeredFrom.APP_RUN
+
+ # Mock the imported class to be a valid repository
+ mock_repository_class = MagicMock()
+ mock_repository_instance = MagicMock(spec=WorkflowExecutionRepository)
+ mock_repository_class.return_value = mock_repository_instance
+
+ # Mock the validation methods
+ with (
+ patch.object(DifyCoreRepositoryFactory, "_import_class", return_value=mock_repository_class),
+ patch.object(DifyCoreRepositoryFactory, "_validate_repository_interface"),
+ patch.object(DifyCoreRepositoryFactory, "_validate_constructor_signature"),
+ ):
+ result = DifyCoreRepositoryFactory.create_workflow_execution_repository(
+ session_factory=mock_session_factory,
+ user=mock_user,
+ app_id=app_id,
+ triggered_from=triggered_from,
+ )
+
+ # Verify the repository was created with correct parameters
+ mock_repository_class.assert_called_once_with(
+ session_factory=mock_session_factory,
+ user=mock_user,
+ app_id=app_id,
+ triggered_from=triggered_from,
+ )
+ assert result is mock_repository_instance
+
+ @patch("core.repositories.factory.dify_config")
+ def test_create_workflow_execution_repository_import_error(self, mock_config):
+ """Test WorkflowExecutionRepository creation with import error."""
+ # Setup mock configuration with invalid class path
+ mock_config.WORKFLOW_EXECUTION_REPOSITORY = "invalid.module.InvalidClass"
+
+ mock_session_factory = MagicMock(spec=sessionmaker)
+ mock_user = MagicMock(spec=Account)
+
+ with pytest.raises(RepositoryImportError) as exc_info:
+ DifyCoreRepositoryFactory.create_workflow_execution_repository(
+ session_factory=mock_session_factory,
+ user=mock_user,
+ app_id="test-app-id",
+ triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
+ )
+ assert "Cannot import repository class" in str(exc_info.value)
+
+ @patch("core.repositories.factory.dify_config")
+ def test_create_workflow_execution_repository_validation_error(self, mock_config, mocker: MockerFixture):
+ """Test WorkflowExecutionRepository creation with validation error."""
+ # Setup mock configuration
+ mock_config.WORKFLOW_EXECUTION_REPOSITORY = "unittest.mock.MagicMock"
+
+ mock_session_factory = MagicMock(spec=sessionmaker)
+ mock_user = MagicMock(spec=Account)
+
+ # Mock import to succeed but validation to fail
+ mock_repository_class = MagicMock()
+ with (
+ patch.object(DifyCoreRepositoryFactory, "_import_class", return_value=mock_repository_class),
+ patch.object(
+ DifyCoreRepositoryFactory,
+ "_validate_repository_interface",
+ side_effect=RepositoryImportError("Interface validation failed"),
+ ),
+ ):
+ with pytest.raises(RepositoryImportError) as exc_info:
+ DifyCoreRepositoryFactory.create_workflow_execution_repository(
+ session_factory=mock_session_factory,
+ user=mock_user,
+ app_id="test-app-id",
+ triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
+ )
+ assert "Interface validation failed" in str(exc_info.value)
+
+ @patch("core.repositories.factory.dify_config")
+ def test_create_workflow_execution_repository_instantiation_error(self, mock_config, mocker: MockerFixture):
+ """Test WorkflowExecutionRepository creation with instantiation error."""
+ # Setup mock configuration
+ mock_config.WORKFLOW_EXECUTION_REPOSITORY = "unittest.mock.MagicMock"
+
+ mock_session_factory = MagicMock(spec=sessionmaker)
+ mock_user = MagicMock(spec=Account)
+
+ # Mock import and validation to succeed but instantiation to fail
+ mock_repository_class = MagicMock(side_effect=Exception("Instantiation failed"))
+ with (
+ patch.object(DifyCoreRepositoryFactory, "_import_class", return_value=mock_repository_class),
+ patch.object(DifyCoreRepositoryFactory, "_validate_repository_interface"),
+ patch.object(DifyCoreRepositoryFactory, "_validate_constructor_signature"),
+ ):
+ with pytest.raises(RepositoryImportError) as exc_info:
+ DifyCoreRepositoryFactory.create_workflow_execution_repository(
+ session_factory=mock_session_factory,
+ user=mock_user,
+ app_id="test-app-id",
+ triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
+ )
+ assert "Failed to create WorkflowExecutionRepository" in str(exc_info.value)
+
+ @patch("core.repositories.factory.dify_config")
+ def test_create_workflow_node_execution_repository_success(self, mock_config, mocker: MockerFixture):
+ """Test successful creation of WorkflowNodeExecutionRepository."""
+ # Setup mock configuration
+ mock_config.WORKFLOW_NODE_EXECUTION_REPOSITORY = "unittest.mock.MagicMock"
+
+ # Create mock dependencies
+ mock_session_factory = MagicMock(spec=sessionmaker)
+ mock_user = MagicMock(spec=EndUser)
+ app_id = "test-app-id"
+ triggered_from = WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN
+
+ # Mock the imported class to be a valid repository
+ mock_repository_class = MagicMock()
+ mock_repository_instance = MagicMock(spec=WorkflowNodeExecutionRepository)
+ mock_repository_class.return_value = mock_repository_instance
+
+ # Mock the validation methods
+ with (
+ patch.object(DifyCoreRepositoryFactory, "_import_class", return_value=mock_repository_class),
+ patch.object(DifyCoreRepositoryFactory, "_validate_repository_interface"),
+ patch.object(DifyCoreRepositoryFactory, "_validate_constructor_signature"),
+ ):
+ result = DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
+ session_factory=mock_session_factory,
+ user=mock_user,
+ app_id=app_id,
+ triggered_from=triggered_from,
+ )
+
+ # Verify the repository was created with correct parameters
+ mock_repository_class.assert_called_once_with(
+ session_factory=mock_session_factory,
+ user=mock_user,
+ app_id=app_id,
+ triggered_from=triggered_from,
+ )
+ assert result is mock_repository_instance
+
+ @patch("core.repositories.factory.dify_config")
+ def test_create_workflow_node_execution_repository_import_error(self, mock_config):
+ """Test WorkflowNodeExecutionRepository creation with import error."""
+ # Setup mock configuration with invalid class path
+ mock_config.WORKFLOW_NODE_EXECUTION_REPOSITORY = "invalid.module.InvalidClass"
+
+ mock_session_factory = MagicMock(spec=sessionmaker)
+ mock_user = MagicMock(spec=EndUser)
+
+ with pytest.raises(RepositoryImportError) as exc_info:
+ DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
+ session_factory=mock_session_factory,
+ user=mock_user,
+ app_id="test-app-id",
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
+ )
+ assert "Cannot import repository class" in str(exc_info.value)
+
+ def test_repository_import_error_exception(self):
+ """Test RepositoryImportError exception."""
+ error_message = "Test error message"
+ exception = RepositoryImportError(error_message)
+ assert str(exception) == error_message
+ assert isinstance(exception, Exception)
+
+ @patch("core.repositories.factory.dify_config")
+ def test_create_with_engine_instead_of_sessionmaker(self, mock_config, mocker: MockerFixture):
+ """Test repository creation with Engine instead of sessionmaker."""
+ # Setup mock configuration
+ mock_config.WORKFLOW_EXECUTION_REPOSITORY = "unittest.mock.MagicMock"
+
+ # Create mock dependencies with Engine instead of sessionmaker
+ mock_engine = MagicMock(spec=Engine)
+ mock_user = MagicMock(spec=Account)
+
+ # Mock the imported class to be a valid repository
+ mock_repository_class = MagicMock()
+ mock_repository_instance = MagicMock(spec=WorkflowExecutionRepository)
+ mock_repository_class.return_value = mock_repository_instance
+
+ # Mock the validation methods
+ with (
+ patch.object(DifyCoreRepositoryFactory, "_import_class", return_value=mock_repository_class),
+ patch.object(DifyCoreRepositoryFactory, "_validate_repository_interface"),
+ patch.object(DifyCoreRepositoryFactory, "_validate_constructor_signature"),
+ ):
+ result = DifyCoreRepositoryFactory.create_workflow_execution_repository(
+ session_factory=mock_engine, # Using Engine instead of sessionmaker
+ user=mock_user,
+ app_id="test-app-id",
+ triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
+ )
+
+ # Verify the repository was created with the Engine
+ mock_repository_class.assert_called_once_with(
+ session_factory=mock_engine,
+ user=mock_user,
+ app_id="test-app-id",
+ triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
+ )
+ assert result is mock_repository_instance
+
+ @patch("core.repositories.factory.dify_config")
+ def test_create_workflow_node_execution_repository_validation_error(self, mock_config):
+ """Test WorkflowNodeExecutionRepository creation with validation error."""
+ # Setup mock configuration
+ mock_config.WORKFLOW_NODE_EXECUTION_REPOSITORY = "unittest.mock.MagicMock"
+
+ mock_session_factory = MagicMock(spec=sessionmaker)
+ mock_user = MagicMock(spec=EndUser)
+
+ # Mock import to succeed but validation to fail
+ mock_repository_class = MagicMock()
+ with (
+ patch.object(DifyCoreRepositoryFactory, "_import_class", return_value=mock_repository_class),
+ patch.object(
+ DifyCoreRepositoryFactory,
+ "_validate_repository_interface",
+ side_effect=RepositoryImportError("Interface validation failed"),
+ ),
+ ):
+ with pytest.raises(RepositoryImportError) as exc_info:
+ DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
+ session_factory=mock_session_factory,
+ user=mock_user,
+ app_id="test-app-id",
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
+ )
+ assert "Interface validation failed" in str(exc_info.value)
+
+ @patch("core.repositories.factory.dify_config")
+ def test_create_workflow_node_execution_repository_instantiation_error(self, mock_config):
+ """Test WorkflowNodeExecutionRepository creation with instantiation error."""
+ # Setup mock configuration
+ mock_config.WORKFLOW_NODE_EXECUTION_REPOSITORY = "unittest.mock.MagicMock"
+
+ mock_session_factory = MagicMock(spec=sessionmaker)
+ mock_user = MagicMock(spec=EndUser)
+
+ # Mock import and validation to succeed but instantiation to fail
+ mock_repository_class = MagicMock(side_effect=Exception("Instantiation failed"))
+ with (
+ patch.object(DifyCoreRepositoryFactory, "_import_class", return_value=mock_repository_class),
+ patch.object(DifyCoreRepositoryFactory, "_validate_repository_interface"),
+ patch.object(DifyCoreRepositoryFactory, "_validate_constructor_signature"),
+ ):
+ with pytest.raises(RepositoryImportError) as exc_info:
+ DifyCoreRepositoryFactory.create_workflow_node_execution_repository(
+ session_factory=mock_session_factory,
+ user=mock_user,
+ app_id="test-app-id",
+ triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
+ )
+ assert "Failed to create WorkflowNodeExecutionRepository" in str(exc_info.value)
+
+ def test_validate_repository_interface_with_private_methods(self):
+ """Test interface validation ignores private methods."""
+
+ # Create a mock class with private methods
+ class MockRepository:
+ def save(self):
+ pass
+
+ def get_by_id(self):
+ pass
+
+ def _private_method(self):
+ pass
+
+ # Create a mock interface with private methods
+ class MockInterface:
+ def save(self):
+ pass
+
+ def get_by_id(self):
+ pass
+
+ def _private_method(self):
+ pass
+
+ # Should not raise an exception (private methods are ignored)
+ DifyCoreRepositoryFactory._validate_repository_interface(MockRepository, MockInterface)
+
+ def test_validate_constructor_signature_with_extra_params(self):
+ """Test constructor validation with extra parameters (should pass)."""
+
+ class MockRepository:
+ def __init__(self, session_factory, user, app_id, triggered_from, extra_param=None):
+ pass
+
+ # Should not raise an exception (extra parameters are allowed)
+ DifyCoreRepositoryFactory._validate_constructor_signature(
+ MockRepository, ["session_factory", "user", "app_id", "triggered_from"]
+ )
+
+ def test_validate_constructor_signature_with_kwargs(self):
+ """Test constructor validation with **kwargs (current implementation doesn't support this)."""
+
+ class MockRepository:
+ def __init__(self, session_factory, user, **kwargs):
+ pass
+
+ # Current implementation doesn't handle **kwargs, so this should raise an exception
+ with pytest.raises(RepositoryImportError) as exc_info:
+ DifyCoreRepositoryFactory._validate_constructor_signature(
+ MockRepository, ["session_factory", "user", "app_id", "triggered_from"]
+ )
+ assert "does not accept required parameters" in str(exc_info.value)
+ assert "app_id" in str(exc_info.value)
+ assert "triggered_from" in str(exc_info.value)
diff --git a/api/tests/unit_tests/core/tools/utils/__init__.py b/api/tests/unit_tests/core/tools/utils/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/tests/unit_tests/core/tools/utils/test_parser.py b/api/tests/unit_tests/core/tools/utils/test_parser.py
new file mode 100644
index 0000000000..8e07293ce0
--- /dev/null
+++ b/api/tests/unit_tests/core/tools/utils/test_parser.py
@@ -0,0 +1,56 @@
+import pytest
+from flask import Flask
+
+from core.tools.utils.parser import ApiBasedToolSchemaParser
+
+
+@pytest.fixture
+def app():
+ app = Flask(__name__)
+ return app
+
+
+def test_parse_openapi_to_tool_bundle_operation_id(app):
+ openapi = {
+ "openapi": "3.0.0",
+ "info": {"title": "Simple API", "version": "1.0.0"},
+ "servers": [{"url": "http://localhost:3000"}],
+ "paths": {
+ "/": {
+ "get": {
+ "summary": "Root endpoint",
+ "responses": {
+ "200": {
+ "description": "Successful response",
+ }
+ },
+ }
+ },
+ "/api/resources": {
+ "get": {
+ "summary": "Non-root endpoint without an operationId",
+ "responses": {
+ "200": {
+ "description": "Successful response",
+ }
+ },
+ },
+ "post": {
+ "summary": "Non-root endpoint with an operationId",
+ "operationId": "createResource",
+ "responses": {
+ "201": {
+ "description": "Resource created",
+ }
+ },
+ },
+ },
+ },
+ }
+ with app.test_request_context():
+ tool_bundles = ApiBasedToolSchemaParser.parse_openapi_to_tool_bundle(openapi)
+
+ assert len(tool_bundles) == 3
+ assert tool_bundles[0].operation_id == "_get"
+ assert tool_bundles[1].operation_id == "apiresources_get"
+ assert tool_bundles[2].operation_id == "createResource"
diff --git a/api/tests/unit_tests/core/app/segments/test_segment.py b/api/tests/unit_tests/core/variables/test_segment.py
similarity index 100%
rename from api/tests/unit_tests/core/app/segments/test_segment.py
rename to api/tests/unit_tests/core/variables/test_segment.py
diff --git a/api/tests/unit_tests/core/app/segments/test_variables.py b/api/tests/unit_tests/core/variables/test_variables.py
similarity index 100%
rename from api/tests/unit_tests/core/app/segments/test_variables.py
rename to api/tests/unit_tests/core/variables/test_variables.py
diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_graph_engine.py b/api/tests/unit_tests/core/workflow/graph_engine/test_graph_engine.py
index 714ef1160e..102edf9381 100644
--- a/api/tests/unit_tests/core/workflow/graph_engine/test_graph_engine.py
+++ b/api/tests/unit_tests/core/workflow/graph_engine/test_graph_engine.py
@@ -1,3 +1,4 @@
+import time
from unittest.mock import patch
import pytest
@@ -19,6 +20,7 @@ from core.workflow.graph_engine.entities.event import (
NodeRunSucceededEvent,
)
from core.workflow.graph_engine.entities.graph import Graph
+from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState
from core.workflow.graph_engine.entities.runtime_route_state import RouteNodeState
from core.workflow.graph_engine.graph_engine import GraphEngine
from core.workflow.nodes.code.code_node import CodeNode
@@ -172,6 +174,7 @@ def test_run_parallel_in_workflow(mock_close, mock_remove):
system_variables={SystemVariableKey.FILES: [], SystemVariableKey.USER_ID: "aaa"}, user_inputs={"query": "hi"}
)
+ graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
graph_engine = GraphEngine(
tenant_id="111",
app_id="222",
@@ -183,7 +186,7 @@ def test_run_parallel_in_workflow(mock_close, mock_remove):
invoke_from=InvokeFrom.WEB_APP,
call_depth=0,
graph=graph,
- variable_pool=variable_pool,
+ graph_runtime_state=graph_runtime_state,
max_execution_steps=500,
max_execution_time=1200,
)
@@ -299,6 +302,7 @@ def test_run_parallel_in_chatflow(mock_close, mock_remove):
user_inputs={},
)
+ graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
graph_engine = GraphEngine(
tenant_id="111",
app_id="222",
@@ -310,7 +314,7 @@ def test_run_parallel_in_chatflow(mock_close, mock_remove):
invoke_from=InvokeFrom.WEB_APP,
call_depth=0,
graph=graph,
- variable_pool=variable_pool,
+ graph_runtime_state=graph_runtime_state,
max_execution_steps=500,
max_execution_time=1200,
)
@@ -487,6 +491,7 @@ def test_run_branch(mock_close, mock_remove):
user_inputs={"uid": "takato"},
)
+ graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
graph_engine = GraphEngine(
tenant_id="111",
app_id="222",
@@ -498,7 +503,7 @@ def test_run_branch(mock_close, mock_remove):
invoke_from=InvokeFrom.WEB_APP,
call_depth=0,
graph=graph,
- variable_pool=variable_pool,
+ graph_runtime_state=graph_runtime_state,
max_execution_steps=500,
max_execution_time=1200,
)
@@ -825,6 +830,7 @@ def test_condition_parallel_correct_output(mock_close, mock_remove, app):
system_variables={SystemVariableKey.FILES: [], SystemVariableKey.USER_ID: "aaa"}, user_inputs={"query": "hi"}
)
+ graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
graph_engine = GraphEngine(
tenant_id="111",
app_id="222",
@@ -836,7 +842,7 @@ def test_condition_parallel_correct_output(mock_close, mock_remove, app):
invoke_from=InvokeFrom.WEB_APP,
call_depth=0,
graph=graph,
- variable_pool=variable_pool,
+ graph_runtime_state=graph_runtime_state,
max_execution_steps=500,
max_execution_time=1200,
)
diff --git a/api/tests/unit_tests/core/workflow/nodes/base/test_base_node.py b/api/tests/unit_tests/core/workflow/nodes/base/test_base_node.py
new file mode 100644
index 0000000000..8712b61a23
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/nodes/base/test_base_node.py
@@ -0,0 +1,36 @@
+from core.workflow.nodes.base.node import BaseNode
+from core.workflow.nodes.enums import NodeType
+
+# Ensures that all node classes are imported.
+from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING
+
+_ = NODE_TYPE_CLASSES_MAPPING
+
+
+def _get_all_subclasses(root: type[BaseNode]) -> list[type[BaseNode]]:
+ subclasses = []
+ queue = [root]
+ while queue:
+ cls = queue.pop()
+
+ subclasses.extend(cls.__subclasses__())
+ queue.extend(cls.__subclasses__())
+
+ return subclasses
+
+
+def test_ensure_subclasses_of_base_node_has_node_type_and_version_method_defined():
+ classes = _get_all_subclasses(BaseNode) # type: ignore
+ type_version_set: set[tuple[NodeType, str]] = set()
+
+ for cls in classes:
+ # Validate that 'version' is directly defined in the class (not inherited) by checking the class's __dict__
+ assert "version" in cls.__dict__, f"class {cls} should have version method defined (NOT INHERITED.)"
+ node_type = cls._node_type
+ node_version = cls.version()
+
+ assert isinstance(cls._node_type, NodeType)
+ assert isinstance(node_version, str)
+ node_type_and_version = (node_type, node_version)
+ assert node_type_and_version not in type_version_set
+ type_version_set.add(node_type_and_version)
diff --git a/api/tests/unit_tests/core/workflow/nodes/iteration/test_iteration.py b/api/tests/unit_tests/core/workflow/nodes/iteration/test_iteration.py
index 00a38d9802..6d6e6bde0c 100644
--- a/api/tests/unit_tests/core/workflow/nodes/iteration/test_iteration.py
+++ b/api/tests/unit_tests/core/workflow/nodes/iteration/test_iteration.py
@@ -3,6 +3,7 @@ import uuid
from unittest.mock import patch
from core.app.entities.app_invoke_entities import InvokeFrom
+from core.variables.segments import ArrayAnySegment, ArrayStringSegment
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.entities.variable_pool import VariablePool
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
@@ -209,7 +210,7 @@ def test_run():
count += 1
if isinstance(item, RunCompletedEvent):
assert item.run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED
- assert item.run_result.outputs == {"output": ["dify 123", "dify 123"]}
+ assert item.run_result.outputs == {"output": ArrayStringSegment(value=["dify 123", "dify 123"])}
assert count == 20
@@ -437,7 +438,7 @@ def test_run_parallel():
count += 1
if isinstance(item, RunCompletedEvent):
assert item.run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED
- assert item.run_result.outputs == {"output": ["dify 123", "dify 123"]}
+ assert item.run_result.outputs == {"output": ArrayStringSegment(value=["dify 123", "dify 123"])}
assert count == 32
@@ -690,7 +691,7 @@ def test_iteration_run_in_parallel_mode():
parallel_arr.append(item)
if isinstance(item, RunCompletedEvent):
assert item.run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED
- assert item.run_result.outputs == {"output": ["dify 123", "dify 123"]}
+ assert item.run_result.outputs == {"output": ArrayStringSegment(value=["dify 123", "dify 123"])}
assert count == 32
for item in sequential_result:
@@ -698,7 +699,7 @@ def test_iteration_run_in_parallel_mode():
count += 1
if isinstance(item, RunCompletedEvent):
assert item.run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED
- assert item.run_result.outputs == {"output": ["dify 123", "dify 123"]}
+ assert item.run_result.outputs == {"output": ArrayStringSegment(value=["dify 123", "dify 123"])}
assert count == 64
@@ -887,7 +888,7 @@ def test_iteration_run_error_handle():
count += 1
if isinstance(item, RunCompletedEvent):
assert item.run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED
- assert item.run_result.outputs == {"output": [None, None]}
+ assert item.run_result.outputs == {"output": ArrayAnySegment(value=[None, None])}
assert count == 14
# execute remove abnormal output
@@ -898,5 +899,5 @@ def test_iteration_run_error_handle():
count += 1
if isinstance(item, RunCompletedEvent):
assert item.run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED
- assert item.run_result.outputs == {"output": []}
+ assert item.run_result.outputs == {"output": ArrayAnySegment(value=[])}
assert count == 14
diff --git a/api/tests/unit_tests/core/workflow/nodes/test_continue_on_error.py b/api/tests/unit_tests/core/workflow/nodes/test_continue_on_error.py
index efe4be204b..94b17f5a3c 100644
--- a/api/tests/unit_tests/core/workflow/nodes/test_continue_on_error.py
+++ b/api/tests/unit_tests/core/workflow/nodes/test_continue_on_error.py
@@ -1,7 +1,9 @@
+import time
from unittest.mock import patch
from core.app.entities.app_invoke_entities import InvokeFrom
from core.workflow.entities.node_entities import NodeRunResult, WorkflowNodeExecutionMetadataKey
+from core.workflow.entities.variable_pool import VariablePool
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
from core.workflow.enums import SystemVariableKey
from core.workflow.graph_engine.entities.event import (
@@ -11,6 +13,7 @@ from core.workflow.graph_engine.entities.event import (
NodeRunStreamChunkEvent,
)
from core.workflow.graph_engine.entities.graph import Graph
+from core.workflow.graph_engine.entities.graph_runtime_state import GraphRuntimeState
from core.workflow.graph_engine.graph_engine import GraphEngine
from core.workflow.nodes.event.event import RunCompletedEvent, RunStreamChunkEvent
from core.workflow.nodes.llm.node import LLMNode
@@ -163,15 +166,16 @@ class ContinueOnErrorTestHelper:
def create_test_graph_engine(graph_config: dict, user_inputs: dict | None = None):
"""Helper method to create a graph engine instance for testing"""
graph = Graph.init(graph_config=graph_config)
- variable_pool = {
- "system_variables": {
+ variable_pool = VariablePool(
+ system_variables={
SystemVariableKey.QUERY: "clear",
SystemVariableKey.FILES: [],
SystemVariableKey.CONVERSATION_ID: "abababa",
SystemVariableKey.USER_ID: "aaa",
},
- "user_inputs": user_inputs or {"uid": "takato"},
- }
+ user_inputs=user_inputs or {"uid": "takato"},
+ )
+ graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
return GraphEngine(
tenant_id="111",
@@ -184,7 +188,7 @@ class ContinueOnErrorTestHelper:
invoke_from=InvokeFrom.WEB_APP,
call_depth=0,
graph=graph,
- variable_pool=variable_pool,
+ graph_runtime_state=graph_runtime_state,
max_execution_steps=500,
max_execution_time=1200,
)
diff --git a/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py b/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py
index 4cb1aa93f9..66c7818adf 100644
--- a/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py
+++ b/api/tests/unit_tests/core/workflow/nodes/test_document_extractor_node.py
@@ -7,6 +7,7 @@ from docx.oxml.text.paragraph import CT_P
from core.file import File, FileTransferMethod
from core.variables import ArrayFileSegment
+from core.variables.segments import ArrayStringSegment
from core.variables.variables import StringVariable
from core.workflow.entities.node_entities import NodeRunResult
from core.workflow.entities.workflow_node_execution import WorkflowNodeExecutionStatus
@@ -69,7 +70,13 @@ def test_run_invalid_variable_type(document_extractor_node, mock_graph_runtime_s
@pytest.mark.parametrize(
("mime_type", "file_content", "expected_text", "transfer_method", "extension"),
[
- ("text/plain", b"Hello, world!", ["Hello, world!"], FileTransferMethod.LOCAL_FILE, ".txt"),
+ (
+ "text/plain",
+ b"Hello, world!",
+ ["Hello, world!"],
+ FileTransferMethod.LOCAL_FILE,
+ ".txt",
+ ),
(
"application/pdf",
b"%PDF-1.5\n%Test PDF content",
@@ -84,7 +91,13 @@ def test_run_invalid_variable_type(document_extractor_node, mock_graph_runtime_s
FileTransferMethod.REMOTE_URL,
"",
),
- ("text/plain", b"Remote content", ["Remote content"], FileTransferMethod.REMOTE_URL, None),
+ (
+ "text/plain",
+ b"Remote content",
+ ["Remote content"],
+ FileTransferMethod.REMOTE_URL,
+ None,
+ ),
],
)
def test_run_extract_text(
@@ -131,7 +144,7 @@ def test_run_extract_text(
assert isinstance(result, NodeRunResult)
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED, result.error
assert result.outputs is not None
- assert result.outputs["text"] == expected_text
+ assert result.outputs["text"] == ArrayStringSegment(value=expected_text)
if transfer_method == FileTransferMethod.REMOTE_URL:
mock_ssrf_proxy_get.assert_called_once_with("https://example.com/file.txt")
@@ -329,3 +342,26 @@ def test_extract_text_from_excel_all_sheets_fail(mock_excel_file):
assert result == ""
assert mock_excel_instance.parse.call_count == 2
+
+
+@patch("pandas.ExcelFile")
+def test_extract_text_from_excel_numeric_type_column(mock_excel_file):
+ """Test extracting text from Excel file with numeric column names."""
+
+ # Test numeric type column
+ data = {1: ["Test"], 1.1: ["Test"]}
+
+ df = pd.DataFrame(data)
+
+ # Mock ExcelFile
+ mock_excel_instance = Mock()
+ mock_excel_instance.sheet_names = ["Sheet1"]
+ mock_excel_instance.parse.return_value = df
+ mock_excel_file.return_value = mock_excel_instance
+
+ file_content = b"fake_excel_content"
+ result = _extract_text_from_excel(file_content)
+
+ expected_manual = "| 1.0 | 1.1 |\n| --- | --- |\n| Test | Test |\n\n"
+
+ assert expected_manual == result
diff --git a/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py b/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py
index 77d42e2692..7d3a1d6a2d 100644
--- a/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py
+++ b/api/tests/unit_tests/core/workflow/nodes/test_list_operator.py
@@ -115,7 +115,7 @@ def test_filter_files_by_type(list_operator_node):
},
]
assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED
- for expected_file, result_file in zip(expected_files, result.outputs["result"]):
+ for expected_file, result_file in zip(expected_files, result.outputs["result"].value):
assert expected_file["filename"] == result_file.filename
assert expected_file["type"] == result_file.type
assert expected_file["tenant_id"] == result_file.tenant_id
diff --git a/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v1/test_variable_assigner_v1.py b/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v1/test_variable_assigner_v1.py
index 9793da129d..deb3e29b86 100644
--- a/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v1/test_variable_assigner_v1.py
+++ b/api/tests/unit_tests/core/workflow/nodes/variable_assigner/v1/test_variable_assigner_v1.py
@@ -5,6 +5,7 @@ from uuid import uuid4
from core.app.entities.app_invoke_entities import InvokeFrom
from core.variables import ArrayStringVariable, StringVariable
+from core.workflow.conversation_variable_updater import ConversationVariableUpdater
from core.workflow.entities.variable_pool import VariablePool
from core.workflow.enums import SystemVariableKey
from core.workflow.graph_engine.entities.graph import Graph
@@ -63,10 +64,11 @@ def test_overwrite_string_variable():
name="test_string_variable",
value="the second value",
)
+ conversation_id = str(uuid.uuid4())
# construct variable pool
variable_pool = VariablePool(
- system_variables={SystemVariableKey.CONVERSATION_ID: "conversation_id"},
+ system_variables={SystemVariableKey.CONVERSATION_ID: conversation_id},
user_inputs={},
environment_variables=[],
conversation_variables=[conversation_variable],
@@ -77,6 +79,9 @@ def test_overwrite_string_variable():
input_variable,
)
+ mock_conv_var_updater = mock.Mock(spec=ConversationVariableUpdater)
+ mock_conv_var_updater_factory = mock.Mock(return_value=mock_conv_var_updater)
+
node = VariableAssignerNode(
id=str(uuid.uuid4()),
graph_init_params=init_params,
@@ -91,11 +96,20 @@ def test_overwrite_string_variable():
"input_variable_selector": [DEFAULT_NODE_ID, input_variable.name],
},
},
+ conv_var_updater_factory=mock_conv_var_updater_factory,
)
- with mock.patch("core.workflow.nodes.variable_assigner.common.helpers.update_conversation_variable") as mock_run:
- list(node.run())
- mock_run.assert_called_once()
+ list(node.run())
+ expected_var = StringVariable(
+ id=conversation_variable.id,
+ name=conversation_variable.name,
+ description=conversation_variable.description,
+ selector=conversation_variable.selector,
+ value_type=conversation_variable.value_type,
+ value=input_variable.value,
+ )
+ mock_conv_var_updater.update.assert_called_once_with(conversation_id=conversation_id, variable=expected_var)
+ mock_conv_var_updater.flush.assert_called_once()
got = variable_pool.get(["conversation", conversation_variable.name])
assert got is not None
@@ -148,9 +162,10 @@ def test_append_variable_to_array():
name="test_string_variable",
value="the second value",
)
+ conversation_id = str(uuid.uuid4())
variable_pool = VariablePool(
- system_variables={SystemVariableKey.CONVERSATION_ID: "conversation_id"},
+ system_variables={SystemVariableKey.CONVERSATION_ID: conversation_id},
user_inputs={},
environment_variables=[],
conversation_variables=[conversation_variable],
@@ -160,6 +175,9 @@ def test_append_variable_to_array():
input_variable,
)
+ mock_conv_var_updater = mock.Mock(spec=ConversationVariableUpdater)
+ mock_conv_var_updater_factory = mock.Mock(return_value=mock_conv_var_updater)
+
node = VariableAssignerNode(
id=str(uuid.uuid4()),
graph_init_params=init_params,
@@ -174,11 +192,22 @@ def test_append_variable_to_array():
"input_variable_selector": [DEFAULT_NODE_ID, input_variable.name],
},
},
+ conv_var_updater_factory=mock_conv_var_updater_factory,
)
- with mock.patch("core.workflow.nodes.variable_assigner.common.helpers.update_conversation_variable") as mock_run:
- list(node.run())
- mock_run.assert_called_once()
+ list(node.run())
+ expected_value = list(conversation_variable.value)
+ expected_value.append(input_variable.value)
+ expected_var = ArrayStringVariable(
+ id=conversation_variable.id,
+ name=conversation_variable.name,
+ description=conversation_variable.description,
+ selector=conversation_variable.selector,
+ value_type=conversation_variable.value_type,
+ value=expected_value,
+ )
+ mock_conv_var_updater.update.assert_called_once_with(conversation_id=conversation_id, variable=expected_var)
+ mock_conv_var_updater.flush.assert_called_once()
got = variable_pool.get(["conversation", conversation_variable.name])
assert got is not None
@@ -225,13 +254,17 @@ def test_clear_array():
value=["the first value"],
)
+ conversation_id = str(uuid.uuid4())
variable_pool = VariablePool(
- system_variables={SystemVariableKey.CONVERSATION_ID: "conversation_id"},
+ system_variables={SystemVariableKey.CONVERSATION_ID: conversation_id},
user_inputs={},
environment_variables=[],
conversation_variables=[conversation_variable],
)
+ mock_conv_var_updater = mock.Mock(spec=ConversationVariableUpdater)
+ mock_conv_var_updater_factory = mock.Mock(return_value=mock_conv_var_updater)
+
node = VariableAssignerNode(
id=str(uuid.uuid4()),
graph_init_params=init_params,
@@ -246,11 +279,20 @@ def test_clear_array():
"input_variable_selector": [],
},
},
+ conv_var_updater_factory=mock_conv_var_updater_factory,
)
- with mock.patch("core.workflow.nodes.variable_assigner.common.helpers.update_conversation_variable") as mock_run:
- list(node.run())
- mock_run.assert_called_once()
+ list(node.run())
+ expected_var = ArrayStringVariable(
+ id=conversation_variable.id,
+ name=conversation_variable.name,
+ description=conversation_variable.description,
+ selector=conversation_variable.selector,
+ value_type=conversation_variable.value_type,
+ value=[],
+ )
+ mock_conv_var_updater.update.assert_called_once_with(conversation_id=conversation_id, variable=expected_var)
+ mock_conv_var_updater.flush.assert_called_once()
got = variable_pool.get(["conversation", conversation_variable.name])
assert got is not None
diff --git a/api/tests/unit_tests/core/workflow/test_variable_pool.py b/api/tests/unit_tests/core/workflow/test_variable_pool.py
index efbcdc760c..bb8d34fad5 100644
--- a/api/tests/unit_tests/core/workflow/test_variable_pool.py
+++ b/api/tests/unit_tests/core/workflow/test_variable_pool.py
@@ -1,8 +1,12 @@
import pytest
+from pydantic import ValidationError
from core.file import File, FileTransferMethod, FileType
from core.variables import FileSegment, StringSegment
+from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID
from core.workflow.entities.variable_pool import VariablePool
+from core.workflow.enums import SystemVariableKey
+from factories.variable_factory import build_segment, segment_to_variable
@pytest.fixture
@@ -44,3 +48,38 @@ def test_use_long_selector(pool):
result = pool.get(("node_1", "part_1", "part_2"))
assert result is not None
assert result.value == "test_value"
+
+
+class TestVariablePool:
+ def test_constructor(self):
+ pool = VariablePool()
+ pool = VariablePool(
+ variable_dictionary={},
+ user_inputs={},
+ system_variables={},
+ environment_variables=[],
+ conversation_variables=[],
+ )
+
+ pool = VariablePool(
+ user_inputs={"key": "value"},
+ system_variables={SystemVariableKey.WORKFLOW_ID: "test_workflow_id"},
+ environment_variables=[
+ segment_to_variable(
+ segment=build_segment(1),
+ selector=[ENVIRONMENT_VARIABLE_NODE_ID, "env_var_1"],
+ name="env_var_1",
+ )
+ ],
+ conversation_variables=[
+ segment_to_variable(
+ segment=build_segment("1"),
+ selector=[CONVERSATION_VARIABLE_NODE_ID, "conv_var_1"],
+ name="conv_var_1",
+ )
+ ],
+ )
+
+ def test_constructor_with_invalid_system_variable_key(self):
+ with pytest.raises(ValidationError):
+ VariablePool(system_variables={"invalid_key": "value"}) # type: ignore
diff --git a/api/tests/unit_tests/core/workflow/test_workflow_cycle_manager.py b/api/tests/unit_tests/core/workflow/test_workflow_cycle_manager.py
index fddc182594..646de8bf3a 100644
--- a/api/tests/unit_tests/core/workflow/test_workflow_cycle_manager.py
+++ b/api/tests/unit_tests/core/workflow/test_workflow_cycle_manager.py
@@ -163,7 +163,6 @@ def real_workflow_run():
workflow_run.tenant_id = "test-tenant-id"
workflow_run.app_id = "test-app-id"
workflow_run.workflow_id = "test-workflow-id"
- workflow_run.sequence_number = 1
workflow_run.type = "chat"
workflow_run.triggered_from = "app-run"
workflow_run.version = "1.0"
diff --git a/api/tests/unit_tests/core/workflow/utils/test_variable_template_parser.py b/api/tests/unit_tests/core/workflow/utils/test_variable_template_parser.py
index 2f90afcf89..28ef05edde 100644
--- a/api/tests/unit_tests/core/workflow/utils/test_variable_template_parser.py
+++ b/api/tests/unit_tests/core/workflow/utils/test_variable_template_parser.py
@@ -1,22 +1,10 @@
-from core.variables import SecretVariable
+import dataclasses
+
from core.workflow.entities.variable_entities import VariableSelector
-from core.workflow.entities.variable_pool import VariablePool
-from core.workflow.enums import SystemVariableKey
from core.workflow.utils import variable_template_parser
def test_extract_selectors_from_template():
- variable_pool = VariablePool(
- system_variables={
- SystemVariableKey("user_id"): "fake-user-id",
- },
- user_inputs={},
- environment_variables=[
- SecretVariable(name="secret_key", value="fake-secret-key"),
- ],
- conversation_variables=[],
- )
- variable_pool.add(("node_id", "custom_query"), "fake-user-query")
template = (
"Hello, {{#sys.user_id#}}! Your query is {{#node_id.custom_query#}}. And your key is {{#env.secret_key#}}."
)
@@ -26,3 +14,35 @@ def test_extract_selectors_from_template():
VariableSelector(variable="#node_id.custom_query#", value_selector=["node_id", "custom_query"]),
VariableSelector(variable="#env.secret_key#", value_selector=["env", "secret_key"]),
]
+
+
+def test_invalid_references():
+ @dataclasses.dataclass
+ class TestCase:
+ name: str
+ template: str
+
+ cases = [
+ TestCase(
+ name="lack of closing brace",
+ template="Hello, {{#sys.user_id#",
+ ),
+ TestCase(
+ name="lack of opening brace",
+ template="Hello, #sys.user_id#}}",
+ ),
+ TestCase(
+ name="lack selector name",
+ template="Hello, {{#sys#}}",
+ ),
+ TestCase(
+ name="empty node name part",
+ template="Hello, {{#.user_id#}}",
+ ),
+ ]
+ for idx, c in enumerate(cases, 1):
+ fail_msg = f"Test case {c.name} failed, index={idx}"
+ selectors = variable_template_parser.extract_selectors_from_template(c.template)
+ assert selectors == [], fail_msg
+ parser = variable_template_parser.VariableTemplateParser(c.template)
+ assert parser.extract_variable_selectors() == [], fail_msg
diff --git a/api/tests/unit_tests/core/workflow/utils/test_variable_utils.py b/api/tests/unit_tests/core/workflow/utils/test_variable_utils.py
new file mode 100644
index 0000000000..f1cb937bb3
--- /dev/null
+++ b/api/tests/unit_tests/core/workflow/utils/test_variable_utils.py
@@ -0,0 +1,148 @@
+from typing import Any
+
+from core.variables.segments import ObjectSegment, StringSegment
+from core.workflow.entities.variable_pool import VariablePool
+from core.workflow.utils.variable_utils import append_variables_recursively
+
+
+class TestAppendVariablesRecursively:
+ """Test cases for append_variables_recursively function"""
+
+ def test_append_simple_dict_value(self):
+ """Test appending a simple dictionary value"""
+ pool = VariablePool()
+ node_id = "test_node"
+ variable_key_list = ["output"]
+ variable_value = {"name": "John", "age": 30}
+
+ append_variables_recursively(pool, node_id, variable_key_list, variable_value)
+
+ # Check that the main variable is added
+ main_var = pool.get([node_id] + variable_key_list)
+ assert main_var is not None
+ assert main_var.value == variable_value
+
+ # Check that nested variables are added recursively
+ name_var = pool.get([node_id] + variable_key_list + ["name"])
+ assert name_var is not None
+ assert name_var.value == "John"
+
+ age_var = pool.get([node_id] + variable_key_list + ["age"])
+ assert age_var is not None
+ assert age_var.value == 30
+
+ def test_append_object_segment_value(self):
+ """Test appending an ObjectSegment value"""
+ pool = VariablePool()
+ node_id = "test_node"
+ variable_key_list = ["result"]
+
+ # Create an ObjectSegment
+ obj_data = {"status": "success", "code": 200}
+ variable_value = ObjectSegment(value=obj_data)
+
+ append_variables_recursively(pool, node_id, variable_key_list, variable_value)
+
+ # Check that the main variable is added
+ main_var = pool.get([node_id] + variable_key_list)
+ assert main_var is not None
+ assert isinstance(main_var, ObjectSegment)
+ assert main_var.value == obj_data
+
+ # Check that nested variables are added recursively
+ status_var = pool.get([node_id] + variable_key_list + ["status"])
+ assert status_var is not None
+ assert status_var.value == "success"
+
+ code_var = pool.get([node_id] + variable_key_list + ["code"])
+ assert code_var is not None
+ assert code_var.value == 200
+
+ def test_append_nested_dict_value(self):
+ """Test appending a nested dictionary value"""
+ pool = VariablePool()
+ node_id = "test_node"
+ variable_key_list = ["data"]
+
+ variable_value = {
+ "user": {
+ "profile": {"name": "Alice", "email": "alice@example.com"},
+ "settings": {"theme": "dark", "notifications": True},
+ },
+ "metadata": {"version": "1.0", "timestamp": 1234567890},
+ }
+
+ append_variables_recursively(pool, node_id, variable_key_list, variable_value)
+
+ # Check deeply nested variables
+ name_var = pool.get([node_id] + variable_key_list + ["user", "profile", "name"])
+ assert name_var is not None
+ assert name_var.value == "Alice"
+
+ email_var = pool.get([node_id] + variable_key_list + ["user", "profile", "email"])
+ assert email_var is not None
+ assert email_var.value == "alice@example.com"
+
+ theme_var = pool.get([node_id] + variable_key_list + ["user", "settings", "theme"])
+ assert theme_var is not None
+ assert theme_var.value == "dark"
+
+ notifications_var = pool.get([node_id] + variable_key_list + ["user", "settings", "notifications"])
+ assert notifications_var is not None
+ assert notifications_var.value == 1 # Boolean True is converted to integer 1
+
+ version_var = pool.get([node_id] + variable_key_list + ["metadata", "version"])
+ assert version_var is not None
+ assert version_var.value == "1.0"
+
+ def test_append_non_dict_value(self):
+ """Test appending a non-dictionary value (should not recurse)"""
+ pool = VariablePool()
+ node_id = "test_node"
+ variable_key_list = ["simple"]
+ variable_value = "simple_string"
+
+ append_variables_recursively(pool, node_id, variable_key_list, variable_value)
+
+ # Check that only the main variable is added
+ main_var = pool.get([node_id] + variable_key_list)
+ assert main_var is not None
+ assert main_var.value == variable_value
+
+ # Ensure no additional variables are created
+ assert len(pool.variable_dictionary[node_id]) == 1
+
+ def test_append_segment_non_object_value(self):
+ """Test appending a Segment that is not ObjectSegment (should not recurse)"""
+ pool = VariablePool()
+ node_id = "test_node"
+ variable_key_list = ["text"]
+ variable_value = StringSegment(value="Hello World")
+
+ append_variables_recursively(pool, node_id, variable_key_list, variable_value)
+
+ # Check that only the main variable is added
+ main_var = pool.get([node_id] + variable_key_list)
+ assert main_var is not None
+ assert isinstance(main_var, StringSegment)
+ assert main_var.value == "Hello World"
+
+ # Ensure no additional variables are created
+ assert len(pool.variable_dictionary[node_id]) == 1
+
+ def test_append_empty_dict_value(self):
+ """Test appending an empty dictionary value"""
+ pool = VariablePool()
+ node_id = "test_node"
+ variable_key_list = ["empty"]
+ variable_value: dict[str, Any] = {}
+
+ append_variables_recursively(pool, node_id, variable_key_list, variable_value)
+
+ # Check that the main variable is added
+ main_var = pool.get([node_id] + variable_key_list)
+ assert main_var is not None
+ assert main_var.value == {}
+
+ # Ensure only the main variable is created (no recursion for empty dict)
+ assert len(pool.variable_dictionary[node_id]) == 1
diff --git a/api/tests/unit_tests/extensions/test_redis.py b/api/tests/unit_tests/extensions/test_redis.py
new file mode 100644
index 0000000000..933fa32894
--- /dev/null
+++ b/api/tests/unit_tests/extensions/test_redis.py
@@ -0,0 +1,53 @@
+from redis import RedisError
+
+from extensions.ext_redis import redis_fallback
+
+
+def test_redis_fallback_success():
+ @redis_fallback(default_return=None)
+ def test_func():
+ return "success"
+
+ assert test_func() == "success"
+
+
+def test_redis_fallback_error():
+ @redis_fallback(default_return="fallback")
+ def test_func():
+ raise RedisError("Redis error")
+
+ assert test_func() == "fallback"
+
+
+def test_redis_fallback_none_default():
+ @redis_fallback()
+ def test_func():
+ raise RedisError("Redis error")
+
+ assert test_func() is None
+
+
+def test_redis_fallback_with_args():
+ @redis_fallback(default_return=0)
+ def test_func(x, y):
+ raise RedisError("Redis error")
+
+ assert test_func(1, 2) == 0
+
+
+def test_redis_fallback_with_kwargs():
+ @redis_fallback(default_return={})
+ def test_func(x=None, y=None):
+ raise RedisError("Redis error")
+
+ assert test_func(x=1, y=2) == {}
+
+
+def test_redis_fallback_preserves_function_metadata():
+ @redis_fallback(default_return=None)
+ def test_func():
+ """Test function docstring"""
+ pass
+
+ assert test_func.__name__ == "test_func"
+ assert test_func.__doc__ == "Test function docstring"
diff --git a/api/tests/unit_tests/factories/test_variable_factory.py b/api/tests/unit_tests/factories/test_variable_factory.py
new file mode 100644
index 0000000000..edd4c5e93e
--- /dev/null
+++ b/api/tests/unit_tests/factories/test_variable_factory.py
@@ -0,0 +1,861 @@
+import math
+from dataclasses import dataclass
+from typing import Any
+from uuid import uuid4
+
+import pytest
+from hypothesis import given
+from hypothesis import strategies as st
+
+from core.file import File, FileTransferMethod, FileType
+from core.variables import (
+ ArrayNumberVariable,
+ ArrayObjectVariable,
+ ArrayStringVariable,
+ FloatVariable,
+ IntegerVariable,
+ SecretVariable,
+ StringVariable,
+)
+from core.variables.exc import VariableError
+from core.variables.segments import (
+ ArrayAnySegment,
+ ArrayFileSegment,
+ ArrayNumberSegment,
+ ArrayObjectSegment,
+ ArrayStringSegment,
+ FileSegment,
+ FloatSegment,
+ IntegerSegment,
+ NoneSegment,
+ ObjectSegment,
+ StringSegment,
+)
+from core.variables.types import SegmentType
+from factories import variable_factory
+from factories.variable_factory import TypeMismatchError, build_segment_with_type
+
+
+def test_string_variable():
+ test_data = {"value_type": "string", "name": "test_text", "value": "Hello, World!"}
+ result = variable_factory.build_conversation_variable_from_mapping(test_data)
+ assert isinstance(result, StringVariable)
+
+
+def test_integer_variable():
+ test_data = {"value_type": "number", "name": "test_int", "value": 42}
+ result = variable_factory.build_conversation_variable_from_mapping(test_data)
+ assert isinstance(result, IntegerVariable)
+
+
+def test_float_variable():
+ test_data = {"value_type": "number", "name": "test_float", "value": 3.14}
+ result = variable_factory.build_conversation_variable_from_mapping(test_data)
+ assert isinstance(result, FloatVariable)
+
+
+def test_secret_variable():
+ test_data = {"value_type": "secret", "name": "test_secret", "value": "secret_value"}
+ result = variable_factory.build_conversation_variable_from_mapping(test_data)
+ assert isinstance(result, SecretVariable)
+
+
+def test_invalid_value_type():
+ test_data = {"value_type": "unknown", "name": "test_invalid", "value": "value"}
+ with pytest.raises(VariableError):
+ variable_factory.build_conversation_variable_from_mapping(test_data)
+
+
+def test_build_a_blank_string():
+ result = variable_factory.build_conversation_variable_from_mapping(
+ {
+ "value_type": "string",
+ "name": "blank",
+ "value": "",
+ }
+ )
+ assert isinstance(result, StringVariable)
+ assert result.value == ""
+
+
+def test_build_a_object_variable_with_none_value():
+ var = variable_factory.build_segment(
+ {
+ "key1": None,
+ }
+ )
+ assert isinstance(var, ObjectSegment)
+ assert var.value["key1"] is None
+
+
+def test_object_variable():
+ mapping = {
+ "id": str(uuid4()),
+ "value_type": "object",
+ "name": "test_object",
+ "description": "Description of the variable.",
+ "value": {
+ "key1": "text",
+ "key2": 2,
+ },
+ }
+ variable = variable_factory.build_conversation_variable_from_mapping(mapping)
+ assert isinstance(variable, ObjectSegment)
+ assert isinstance(variable.value["key1"], str)
+ assert isinstance(variable.value["key2"], int)
+
+
+def test_array_string_variable():
+ mapping = {
+ "id": str(uuid4()),
+ "value_type": "array[string]",
+ "name": "test_array",
+ "description": "Description of the variable.",
+ "value": [
+ "text",
+ "text",
+ ],
+ }
+ variable = variable_factory.build_conversation_variable_from_mapping(mapping)
+ assert isinstance(variable, ArrayStringVariable)
+ assert isinstance(variable.value[0], str)
+ assert isinstance(variable.value[1], str)
+
+
+def test_array_number_variable():
+ mapping = {
+ "id": str(uuid4()),
+ "value_type": "array[number]",
+ "name": "test_array",
+ "description": "Description of the variable.",
+ "value": [
+ 1,
+ 2.0,
+ ],
+ }
+ variable = variable_factory.build_conversation_variable_from_mapping(mapping)
+ assert isinstance(variable, ArrayNumberVariable)
+ assert isinstance(variable.value[0], int)
+ assert isinstance(variable.value[1], float)
+
+
+def test_array_object_variable():
+ mapping = {
+ "id": str(uuid4()),
+ "value_type": "array[object]",
+ "name": "test_array",
+ "description": "Description of the variable.",
+ "value": [
+ {
+ "key1": "text",
+ "key2": 1,
+ },
+ {
+ "key1": "text",
+ "key2": 1,
+ },
+ ],
+ }
+ variable = variable_factory.build_conversation_variable_from_mapping(mapping)
+ assert isinstance(variable, ArrayObjectVariable)
+ assert isinstance(variable.value[0], dict)
+ assert isinstance(variable.value[1], dict)
+ assert isinstance(variable.value[0]["key1"], str)
+ assert isinstance(variable.value[0]["key2"], int)
+ assert isinstance(variable.value[1]["key1"], str)
+ assert isinstance(variable.value[1]["key2"], int)
+
+
+def test_variable_cannot_large_than_200_kb():
+ with pytest.raises(VariableError):
+ variable_factory.build_conversation_variable_from_mapping(
+ {
+ "id": str(uuid4()),
+ "value_type": "string",
+ "name": "test_text",
+ "value": "a" * 1024 * 201,
+ }
+ )
+
+
+def test_array_none_variable():
+ var = variable_factory.build_segment([None, None, None, None])
+ assert isinstance(var, ArrayAnySegment)
+ assert var.value == [None, None, None, None]
+
+
+def test_build_segment_none_type():
+ """Test building NoneSegment from None value."""
+ segment = variable_factory.build_segment(None)
+ assert isinstance(segment, NoneSegment)
+ assert segment.value is None
+ assert segment.value_type == SegmentType.NONE
+
+
+def test_build_segment_none_type_properties():
+ """Test NoneSegment properties and methods."""
+ segment = variable_factory.build_segment(None)
+ assert segment.text == ""
+ assert segment.log == ""
+ assert segment.markdown == ""
+ assert segment.to_object() is None
+
+
+def test_build_segment_array_file_single_file():
+ """Test building ArrayFileSegment from list with single file."""
+ file = File(
+ id="test_file_id",
+ tenant_id="test_tenant_id",
+ type=FileType.IMAGE,
+ transfer_method=FileTransferMethod.REMOTE_URL,
+ remote_url="https://test.example.com/test-file.png",
+ filename="test-file",
+ extension=".png",
+ mime_type="image/png",
+ size=1000,
+ )
+ segment = variable_factory.build_segment([file])
+ assert isinstance(segment, ArrayFileSegment)
+ assert len(segment.value) == 1
+ assert segment.value[0] == file
+ assert segment.value_type == SegmentType.ARRAY_FILE
+
+
+def test_build_segment_array_file_multiple_files():
+ """Test building ArrayFileSegment from list with multiple files."""
+ file1 = File(
+ id="test_file_id_1",
+ tenant_id="test_tenant_id",
+ type=FileType.IMAGE,
+ transfer_method=FileTransferMethod.REMOTE_URL,
+ remote_url="https://test.example.com/test-file1.png",
+ filename="test-file1",
+ extension=".png",
+ mime_type="image/png",
+ size=1000,
+ )
+ file2 = File(
+ id="test_file_id_2",
+ tenant_id="test_tenant_id",
+ type=FileType.DOCUMENT,
+ transfer_method=FileTransferMethod.LOCAL_FILE,
+ related_id="test_relation_id",
+ filename="test-file2",
+ extension=".txt",
+ mime_type="text/plain",
+ size=500,
+ )
+ segment = variable_factory.build_segment([file1, file2])
+ assert isinstance(segment, ArrayFileSegment)
+ assert len(segment.value) == 2
+ assert segment.value[0] == file1
+ assert segment.value[1] == file2
+ assert segment.value_type == SegmentType.ARRAY_FILE
+
+
+def test_build_segment_array_file_empty_list():
+ """Test building ArrayFileSegment from empty list should create ArrayAnySegment."""
+ segment = variable_factory.build_segment([])
+ assert isinstance(segment, ArrayAnySegment)
+ assert segment.value == []
+ assert segment.value_type == SegmentType.ARRAY_ANY
+
+
+def test_build_segment_array_any_mixed_types():
+ """Test building ArrayAnySegment from list with mixed types."""
+ mixed_values = ["string", 42, 3.14, {"key": "value"}, None]
+ segment = variable_factory.build_segment(mixed_values)
+ assert isinstance(segment, ArrayAnySegment)
+ assert segment.value == mixed_values
+ assert segment.value_type == SegmentType.ARRAY_ANY
+
+
+def test_build_segment_array_any_with_nested_arrays():
+ """Test building ArrayAnySegment from list containing arrays."""
+ nested_values = [["nested", "array"], [1, 2, 3], "string"]
+ segment = variable_factory.build_segment(nested_values)
+ assert isinstance(segment, ArrayAnySegment)
+ assert segment.value == nested_values
+ assert segment.value_type == SegmentType.ARRAY_ANY
+
+
+def test_build_segment_array_any_mixed_with_files():
+ """Test building ArrayAnySegment from list with files and other types."""
+ file = File(
+ id="test_file_id",
+ tenant_id="test_tenant_id",
+ type=FileType.IMAGE,
+ transfer_method=FileTransferMethod.REMOTE_URL,
+ remote_url="https://test.example.com/test-file.png",
+ filename="test-file",
+ extension=".png",
+ mime_type="image/png",
+ size=1000,
+ )
+ mixed_values = [file, "string", 42]
+ segment = variable_factory.build_segment(mixed_values)
+ assert isinstance(segment, ArrayAnySegment)
+ assert segment.value == mixed_values
+ assert segment.value_type == SegmentType.ARRAY_ANY
+
+
+def test_build_segment_array_any_all_none_values():
+ """Test building ArrayAnySegment from list with all None values."""
+ none_values = [None, None, None]
+ segment = variable_factory.build_segment(none_values)
+ assert isinstance(segment, ArrayAnySegment)
+ assert segment.value == none_values
+ assert segment.value_type == SegmentType.ARRAY_ANY
+
+
+def test_build_segment_array_file_properties():
+ """Test ArrayFileSegment properties and methods."""
+ file1 = File(
+ id="test_file_id_1",
+ tenant_id="test_tenant_id",
+ type=FileType.IMAGE,
+ transfer_method=FileTransferMethod.REMOTE_URL,
+ remote_url="https://test.example.com/test-file1.png",
+ filename="test-file1",
+ extension=".png",
+ mime_type="image/png",
+ size=1000,
+ )
+ file2 = File(
+ id="test_file_id_2",
+ tenant_id="test_tenant_id",
+ type=FileType.DOCUMENT,
+ transfer_method=FileTransferMethod.REMOTE_URL,
+ remote_url="https://test.example.com/test-file2.txt",
+ filename="test-file2",
+ extension=".txt",
+ mime_type="text/plain",
+ size=500,
+ )
+ segment = variable_factory.build_segment([file1, file2])
+
+ # Test properties
+ assert segment.text == "" # ArrayFileSegment text property returns empty string
+ assert segment.log == "" # ArrayFileSegment log property returns empty string
+ assert segment.markdown == f"{file1.markdown}\n{file2.markdown}"
+ assert segment.to_object() == [file1, file2]
+
+
+def test_build_segment_array_any_properties():
+ """Test ArrayAnySegment properties and methods."""
+ mixed_values = ["string", 42, None]
+ segment = variable_factory.build_segment(mixed_values)
+
+ # Test properties
+ assert segment.text == str(mixed_values)
+ assert segment.log == str(mixed_values)
+ assert segment.markdown == "string\n42\nNone"
+ assert segment.to_object() == mixed_values
+
+
+def test_build_segment_edge_cases():
+ """Test edge cases for build_segment function."""
+ # Test with complex nested structures
+ complex_structure = [{"nested": {"deep": [1, 2, 3]}}, [{"inner": "value"}], "mixed"]
+ segment = variable_factory.build_segment(complex_structure)
+ assert isinstance(segment, ArrayAnySegment)
+ assert segment.value == complex_structure
+
+ # Test with single None in list
+ single_none = [None]
+ segment = variable_factory.build_segment(single_none)
+ assert isinstance(segment, ArrayAnySegment)
+ assert segment.value == single_none
+
+
+def test_build_segment_file_array_with_different_file_types():
+ """Test ArrayFileSegment with different file types."""
+ image_file = File(
+ id="image_id",
+ tenant_id="test_tenant_id",
+ type=FileType.IMAGE,
+ transfer_method=FileTransferMethod.REMOTE_URL,
+ remote_url="https://test.example.com/image.png",
+ filename="image",
+ extension=".png",
+ mime_type="image/png",
+ size=1000,
+ )
+
+ video_file = File(
+ id="video_id",
+ tenant_id="test_tenant_id",
+ type=FileType.VIDEO,
+ transfer_method=FileTransferMethod.LOCAL_FILE,
+ related_id="video_relation_id",
+ filename="video",
+ extension=".mp4",
+ mime_type="video/mp4",
+ size=5000,
+ )
+
+ audio_file = File(
+ id="audio_id",
+ tenant_id="test_tenant_id",
+ type=FileType.AUDIO,
+ transfer_method=FileTransferMethod.LOCAL_FILE,
+ related_id="audio_relation_id",
+ filename="audio",
+ extension=".mp3",
+ mime_type="audio/mpeg",
+ size=3000,
+ )
+
+ segment = variable_factory.build_segment([image_file, video_file, audio_file])
+ assert isinstance(segment, ArrayFileSegment)
+ assert len(segment.value) == 3
+ assert segment.value[0].type == FileType.IMAGE
+ assert segment.value[1].type == FileType.VIDEO
+ assert segment.value[2].type == FileType.AUDIO
+
+
+@st.composite
+def _generate_file(draw) -> File:
+ file_type, mime_type, extension = draw(
+ st.sampled_from(
+ [
+ (FileType.IMAGE, "image/png", ".png"),
+ (FileType.VIDEO, "video/mp4", ".mp4"),
+ (FileType.DOCUMENT, "text/plain", ".txt"),
+ (FileType.AUDIO, "audio/mpeg", ".mp3"),
+ ]
+ )
+ )
+ filename = "test-file"
+ size = draw(st.integers(min_value=0, max_value=1024 * 1024))
+
+ transfer_method = draw(st.sampled_from(list(FileTransferMethod)))
+ if transfer_method == FileTransferMethod.REMOTE_URL:
+ url = "https://test.example.com/test-file"
+ file = File(
+ id="test_file_id",
+ tenant_id="test_tenant_id",
+ type=file_type,
+ transfer_method=transfer_method,
+ remote_url=url,
+ related_id=None,
+ filename=filename,
+ extension=extension,
+ mime_type=mime_type,
+ size=size,
+ )
+ else:
+ relation_id = draw(st.uuids(version=4))
+
+ file = File(
+ id="test_file_id",
+ tenant_id="test_tenant_id",
+ type=file_type,
+ transfer_method=transfer_method,
+ related_id=str(relation_id),
+ filename=filename,
+ extension=extension,
+ mime_type=mime_type,
+ size=size,
+ )
+ return file
+
+
+def _scalar_value() -> st.SearchStrategy[int | float | str | File | None]:
+ return st.one_of(
+ st.none(),
+ st.integers(),
+ st.floats(),
+ st.text(),
+ _generate_file(),
+ )
+
+
+@given(_scalar_value())
+def test_build_segment_and_extract_values_for_scalar_types(value):
+ seg = variable_factory.build_segment(value)
+ # nan == nan yields false, so we need to use `math.isnan` to check `seg.value` here.
+ if isinstance(value, float) and math.isnan(value):
+ assert math.isnan(seg.value)
+ else:
+ assert seg.value == value
+
+
+@given(st.lists(_scalar_value()))
+def test_build_segment_and_extract_values_for_array_types(values):
+ seg = variable_factory.build_segment(values)
+ assert seg.value == values
+
+
+def test_build_segment_type_for_scalar():
+ @dataclass(frozen=True)
+ class TestCase:
+ value: int | float | str | File
+ expected_type: SegmentType
+
+ file = File(
+ id="test_file_id",
+ tenant_id="test_tenant_id",
+ type=FileType.IMAGE,
+ transfer_method=FileTransferMethod.REMOTE_URL,
+ remote_url="https://test.example.com/test-file.png",
+ filename="test-file",
+ extension=".png",
+ mime_type="image/png",
+ size=1000,
+ )
+ cases = [
+ TestCase(0, SegmentType.NUMBER),
+ TestCase(0.0, SegmentType.NUMBER),
+ TestCase("", SegmentType.STRING),
+ TestCase(file, SegmentType.FILE),
+ ]
+
+ for idx, c in enumerate(cases, 1):
+ segment = variable_factory.build_segment(c.value)
+ assert segment.value_type == c.expected_type, f"test case {idx} failed."
+
+
+class TestBuildSegmentWithType:
+ """Test cases for build_segment_with_type function."""
+
+ def test_string_type(self):
+ """Test building a string segment with correct type."""
+ result = build_segment_with_type(SegmentType.STRING, "hello")
+ assert isinstance(result, StringSegment)
+ assert result.value == "hello"
+ assert result.value_type == SegmentType.STRING
+
+ def test_number_type_integer(self):
+ """Test building a number segment with integer value."""
+ result = build_segment_with_type(SegmentType.NUMBER, 42)
+ assert isinstance(result, IntegerSegment)
+ assert result.value == 42
+ assert result.value_type == SegmentType.NUMBER
+
+ def test_number_type_float(self):
+ """Test building a number segment with float value."""
+ result = build_segment_with_type(SegmentType.NUMBER, 3.14)
+ assert isinstance(result, FloatSegment)
+ assert result.value == 3.14
+ assert result.value_type == SegmentType.NUMBER
+
+ def test_object_type(self):
+ """Test building an object segment with correct type."""
+ test_obj = {"key": "value", "nested": {"inner": 123}}
+ result = build_segment_with_type(SegmentType.OBJECT, test_obj)
+ assert isinstance(result, ObjectSegment)
+ assert result.value == test_obj
+ assert result.value_type == SegmentType.OBJECT
+
+ def test_file_type(self):
+ """Test building a file segment with correct type."""
+ test_file = File(
+ id="test_file_id",
+ tenant_id="test_tenant_id",
+ type=FileType.IMAGE,
+ transfer_method=FileTransferMethod.REMOTE_URL,
+ remote_url="https://test.example.com/test-file.png",
+ filename="test-file",
+ extension=".png",
+ mime_type="image/png",
+ size=1000,
+ storage_key="test_storage_key",
+ )
+ result = build_segment_with_type(SegmentType.FILE, test_file)
+ assert isinstance(result, FileSegment)
+ assert result.value == test_file
+ assert result.value_type == SegmentType.FILE
+
+ def test_none_type(self):
+ """Test building a none segment with None value."""
+ result = build_segment_with_type(SegmentType.NONE, None)
+ assert isinstance(result, NoneSegment)
+ assert result.value is None
+ assert result.value_type == SegmentType.NONE
+
+ def test_empty_array_string(self):
+ """Test building an empty array[string] segment."""
+ result = build_segment_with_type(SegmentType.ARRAY_STRING, [])
+ assert isinstance(result, ArrayStringSegment)
+ assert result.value == []
+ assert result.value_type == SegmentType.ARRAY_STRING
+
+ def test_empty_array_number(self):
+ """Test building an empty array[number] segment."""
+ result = build_segment_with_type(SegmentType.ARRAY_NUMBER, [])
+ assert isinstance(result, ArrayNumberSegment)
+ assert result.value == []
+ assert result.value_type == SegmentType.ARRAY_NUMBER
+
+ def test_empty_array_object(self):
+ """Test building an empty array[object] segment."""
+ result = build_segment_with_type(SegmentType.ARRAY_OBJECT, [])
+ assert isinstance(result, ArrayObjectSegment)
+ assert result.value == []
+ assert result.value_type == SegmentType.ARRAY_OBJECT
+
+ def test_empty_array_file(self):
+ """Test building an empty array[file] segment."""
+ result = build_segment_with_type(SegmentType.ARRAY_FILE, [])
+ assert isinstance(result, ArrayFileSegment)
+ assert result.value == []
+ assert result.value_type == SegmentType.ARRAY_FILE
+
+ def test_empty_array_any(self):
+ """Test building an empty array[any] segment."""
+ result = build_segment_with_type(SegmentType.ARRAY_ANY, [])
+ assert isinstance(result, ArrayAnySegment)
+ assert result.value == []
+ assert result.value_type == SegmentType.ARRAY_ANY
+
+ def test_array_with_values(self):
+ """Test building array segments with actual values."""
+ # Array of strings
+ result = build_segment_with_type(SegmentType.ARRAY_STRING, ["hello", "world"])
+ assert isinstance(result, ArrayStringSegment)
+ assert result.value == ["hello", "world"]
+ assert result.value_type == SegmentType.ARRAY_STRING
+
+ # Array of numbers
+ result = build_segment_with_type(SegmentType.ARRAY_NUMBER, [1, 2, 3.14])
+ assert isinstance(result, ArrayNumberSegment)
+ assert result.value == [1, 2, 3.14]
+ assert result.value_type == SegmentType.ARRAY_NUMBER
+
+ # Array of objects
+ result = build_segment_with_type(SegmentType.ARRAY_OBJECT, [{"a": 1}, {"b": 2}])
+ assert isinstance(result, ArrayObjectSegment)
+ assert result.value == [{"a": 1}, {"b": 2}]
+ assert result.value_type == SegmentType.ARRAY_OBJECT
+
+ def test_type_mismatch_string_to_number(self):
+ """Test type mismatch when expecting number but getting string."""
+ with pytest.raises(TypeMismatchError) as exc_info:
+ build_segment_with_type(SegmentType.NUMBER, "not_a_number")
+
+ assert "Type mismatch" in str(exc_info.value)
+ assert "expected number" in str(exc_info.value)
+ assert "str" in str(exc_info.value)
+
+ def test_type_mismatch_number_to_string(self):
+ """Test type mismatch when expecting string but getting number."""
+ with pytest.raises(TypeMismatchError) as exc_info:
+ build_segment_with_type(SegmentType.STRING, 123)
+
+ assert "Type mismatch" in str(exc_info.value)
+ assert "expected string" in str(exc_info.value)
+ assert "int" in str(exc_info.value)
+
+ def test_type_mismatch_none_to_string(self):
+ """Test type mismatch when expecting string but getting None."""
+ with pytest.raises(TypeMismatchError) as exc_info:
+ build_segment_with_type(SegmentType.STRING, None)
+
+ assert "Expected string, but got None" in str(exc_info.value)
+
+ def test_type_mismatch_empty_list_to_non_array(self):
+ """Test type mismatch when expecting non-array type but getting empty list."""
+ with pytest.raises(TypeMismatchError) as exc_info:
+ build_segment_with_type(SegmentType.STRING, [])
+
+ assert "Expected string, but got empty list" in str(exc_info.value)
+
+ def test_type_mismatch_object_to_array(self):
+ """Test type mismatch when expecting array but getting object."""
+ with pytest.raises(TypeMismatchError) as exc_info:
+ build_segment_with_type(SegmentType.ARRAY_STRING, {"key": "value"})
+
+ assert "Type mismatch" in str(exc_info.value)
+ assert "expected array[string]" in str(exc_info.value)
+
+ def test_compatible_number_types(self):
+ """Test that int and float are both compatible with NUMBER type."""
+ # Integer should work
+ result_int = build_segment_with_type(SegmentType.NUMBER, 42)
+ assert isinstance(result_int, IntegerSegment)
+ assert result_int.value_type == SegmentType.NUMBER
+
+ # Float should work
+ result_float = build_segment_with_type(SegmentType.NUMBER, 3.14)
+ assert isinstance(result_float, FloatSegment)
+ assert result_float.value_type == SegmentType.NUMBER
+
+ @pytest.mark.parametrize(
+ ("segment_type", "value", "expected_class"),
+ [
+ (SegmentType.STRING, "test", StringSegment),
+ (SegmentType.NUMBER, 42, IntegerSegment),
+ (SegmentType.NUMBER, 3.14, FloatSegment),
+ (SegmentType.OBJECT, {}, ObjectSegment),
+ (SegmentType.NONE, None, NoneSegment),
+ (SegmentType.ARRAY_STRING, [], ArrayStringSegment),
+ (SegmentType.ARRAY_NUMBER, [], ArrayNumberSegment),
+ (SegmentType.ARRAY_OBJECT, [], ArrayObjectSegment),
+ (SegmentType.ARRAY_ANY, [], ArrayAnySegment),
+ ],
+ )
+ def test_parametrized_valid_types(self, segment_type, value, expected_class):
+ """Parametrized test for valid type combinations."""
+ result = build_segment_with_type(segment_type, value)
+ assert isinstance(result, expected_class)
+ assert result.value == value
+ assert result.value_type == segment_type
+
+ @pytest.mark.parametrize(
+ ("segment_type", "value"),
+ [
+ (SegmentType.STRING, 123),
+ (SegmentType.NUMBER, "not_a_number"),
+ (SegmentType.OBJECT, "not_an_object"),
+ (SegmentType.ARRAY_STRING, "not_an_array"),
+ (SegmentType.STRING, None),
+ (SegmentType.NUMBER, None),
+ ],
+ )
+ def test_parametrized_type_mismatches(self, segment_type, value):
+ """Parametrized test for type mismatches that should raise TypeMismatchError."""
+ with pytest.raises(TypeMismatchError):
+ build_segment_with_type(segment_type, value)
+
+
+# Test cases for ValueError scenarios in build_segment function
+class TestBuildSegmentValueErrors:
+ """Test cases for ValueError scenarios in the build_segment function."""
+
+ @dataclass(frozen=True)
+ class ValueErrorTestCase:
+ """Test case data for ValueError scenarios."""
+
+ name: str
+ description: str
+ test_value: Any
+
+ def _get_test_cases(self):
+ """Get all test cases for ValueError scenarios."""
+
+ # Define inline classes for complex test cases
+ class CustomType:
+ pass
+
+ def unsupported_function():
+ return "test"
+
+ def gen():
+ yield 1
+ yield 2
+
+ return [
+ self.ValueErrorTestCase(
+ name="unsupported_custom_type",
+ description="custom class that doesn't match any supported type",
+ test_value=CustomType(),
+ ),
+ self.ValueErrorTestCase(
+ name="unsupported_set_type",
+ description="set (unsupported collection type)",
+ test_value={1, 2, 3},
+ ),
+ self.ValueErrorTestCase(
+ name="unsupported_tuple_type", description="tuple (unsupported type)", test_value=(1, 2, 3)
+ ),
+ self.ValueErrorTestCase(
+ name="unsupported_bytes_type",
+ description="bytes (unsupported type)",
+ test_value=b"hello world",
+ ),
+ self.ValueErrorTestCase(
+ name="unsupported_function_type",
+ description="function (unsupported type)",
+ test_value=unsupported_function,
+ ),
+ self.ValueErrorTestCase(
+ name="unsupported_module_type", description="module (unsupported type)", test_value=math
+ ),
+ self.ValueErrorTestCase(
+ name="array_with_unsupported_element_types",
+ description="array with unsupported element types",
+ test_value=[CustomType()],
+ ),
+ self.ValueErrorTestCase(
+ name="mixed_array_with_unsupported_types",
+ description="array with mix of supported and unsupported types",
+ test_value=["valid_string", 42, CustomType()],
+ ),
+ self.ValueErrorTestCase(
+ name="nested_unsupported_types",
+ description="nested structures containing unsupported types",
+ test_value=[{"valid": "data"}, CustomType()],
+ ),
+ self.ValueErrorTestCase(
+ name="complex_number_type",
+ description="complex number (unsupported type)",
+ test_value=3 + 4j,
+ ),
+ self.ValueErrorTestCase(
+ name="range_type", description="range object (unsupported type)", test_value=range(10)
+ ),
+ self.ValueErrorTestCase(
+ name="generator_type",
+ description="generator (unsupported type)",
+ test_value=gen(),
+ ),
+ self.ValueErrorTestCase(
+ name="exception_message_contains_value",
+ description="set to verify error message contains the actual unsupported value",
+ test_value={1, 2, 3},
+ ),
+ self.ValueErrorTestCase(
+ name="array_with_mixed_unsupported_segment_types",
+ description="array processing with unsupported segment types in match",
+ test_value=[CustomType()],
+ ),
+ self.ValueErrorTestCase(
+ name="frozenset_type",
+ description="frozenset (unsupported type)",
+ test_value=frozenset([1, 2, 3]),
+ ),
+ self.ValueErrorTestCase(
+ name="memoryview_type",
+ description="memoryview (unsupported type)",
+ test_value=memoryview(b"hello"),
+ ),
+ self.ValueErrorTestCase(
+ name="slice_type", description="slice object (unsupported type)", test_value=slice(1, 10, 2)
+ ),
+ self.ValueErrorTestCase(name="type_object", description="type object (unsupported type)", test_value=type),
+ self.ValueErrorTestCase(
+ name="generic_object", description="generic object (unsupported type)", test_value=object()
+ ),
+ ]
+
+ def test_build_segment_unsupported_types(self):
+ """Table-driven test for all ValueError scenarios in build_segment function."""
+ test_cases = self._get_test_cases()
+
+ for index, test_case in enumerate(test_cases, 1):
+ # Use test value directly
+ test_value = test_case.test_value
+
+ with pytest.raises(ValueError) as exc_info: # noqa: PT012
+ segment = variable_factory.build_segment(test_value)
+ pytest.fail(f"Test case {index} ({test_case.name}) should raise ValueError but not, result={segment}")
+
+ error_message = str(exc_info.value)
+ assert "not supported value" in error_message, (
+ f"Test case {index} ({test_case.name}): Expected 'not supported value' in error message, "
+ f"but got: {error_message}"
+ )
+
+ def test_build_segment_boolean_type_note(self):
+ """Note: Boolean values are actually handled as integers in Python, so they don't raise ValueError."""
+ # Boolean values in Python are subclasses of int, so they get processed as integers
+ # True becomes IntegerSegment(value=1) and False becomes IntegerSegment(value=0)
+ true_segment = variable_factory.build_segment(True)
+ false_segment = variable_factory.build_segment(False)
+
+ # Verify they are processed as integers, not as errors
+ assert true_segment.value == 1, "Test case 1 (boolean_true): Expected True to be processed as integer 1"
+ assert false_segment.value == 0, "Test case 2 (boolean_false): Expected False to be processed as integer 0"
+ assert true_segment.value_type == SegmentType.NUMBER
+ assert false_segment.value_type == SegmentType.NUMBER
diff --git a/api/tests/unit_tests/libs/test_datetime_utils.py b/api/tests/unit_tests/libs/test_datetime_utils.py
new file mode 100644
index 0000000000..e7781a5821
--- /dev/null
+++ b/api/tests/unit_tests/libs/test_datetime_utils.py
@@ -0,0 +1,20 @@
+import datetime
+
+from libs.datetime_utils import naive_utc_now
+
+
+def test_naive_utc_now(monkeypatch):
+ tz_aware_utc_now = datetime.datetime.now(tz=datetime.UTC)
+
+ def _now_func(tz: datetime.timezone | None) -> datetime.datetime:
+ return tz_aware_utc_now.astimezone(tz)
+
+ monkeypatch.setattr("libs.datetime_utils._now_func", _now_func)
+
+ naive_datetime = naive_utc_now()
+
+ assert naive_datetime.tzinfo is None
+ assert naive_datetime.date() == tz_aware_utc_now.date()
+ naive_time = naive_datetime.time()
+ utc_time = tz_aware_utc_now.time()
+ assert naive_time == utc_time
diff --git a/api/tests/unit_tests/libs/test_flask_utils.py b/api/tests/unit_tests/libs/test_flask_utils.py
new file mode 100644
index 0000000000..fb46ba50f3
--- /dev/null
+++ b/api/tests/unit_tests/libs/test_flask_utils.py
@@ -0,0 +1,124 @@
+import contextvars
+import threading
+from typing import Optional
+
+import pytest
+from flask import Flask
+from flask_login import LoginManager, UserMixin, current_user, login_user
+
+from libs.flask_utils import preserve_flask_contexts
+
+
+class User(UserMixin):
+ """Simple User class for testing."""
+
+ def __init__(self, id: str):
+ self.id = id
+
+ def get_id(self) -> str:
+ return self.id
+
+
+@pytest.fixture
+def login_app(app: Flask) -> Flask:
+ """Set up a Flask app with flask-login."""
+ # Set a secret key for the app
+ app.config["SECRET_KEY"] = "test-secret-key"
+
+ login_manager = LoginManager()
+ login_manager.init_app(app)
+
+ @login_manager.user_loader
+ def load_user(user_id: str) -> Optional[User]:
+ if user_id == "test_user":
+ return User("test_user")
+ return None
+
+ return app
+
+
+@pytest.fixture
+def test_user() -> User:
+ """Create a test user."""
+ return User("test_user")
+
+
+def test_current_user_not_accessible_across_threads(login_app: Flask, test_user: User):
+ """
+ Test that current_user is not accessible in a different thread without preserve_flask_contexts.
+
+ This test demonstrates that without the preserve_flask_contexts, we cannot access
+ current_user in a different thread, even with app_context.
+ """
+ # Log in the user in the main thread
+ with login_app.test_request_context():
+ login_user(test_user)
+ assert current_user.is_authenticated
+ assert current_user.id == "test_user"
+
+ # Store the result of the thread execution
+ result = {"user_accessible": True, "error": None}
+
+ # Define a function to run in a separate thread
+ def check_user_in_thread():
+ try:
+ # Try to access current_user in a different thread with app_context
+ with login_app.app_context():
+ # This should fail because current_user is not accessible across threads
+ # without preserve_flask_contexts
+ result["user_accessible"] = current_user.is_authenticated
+ except Exception as e:
+ result["error"] = str(e) # type: ignore
+
+ # Run the function in a separate thread
+ thread = threading.Thread(target=check_user_in_thread)
+ thread.start()
+ thread.join()
+
+ # Verify that we got an error or current_user is not authenticated
+ assert result["error"] is not None or (result["user_accessible"] is not None and not result["user_accessible"])
+
+
+def test_current_user_accessible_with_preserve_flask_contexts(login_app: Flask, test_user: User):
+ """
+ Test that current_user is accessible in a different thread with preserve_flask_contexts.
+
+ This test demonstrates that with the preserve_flask_contexts, we can access
+ current_user in a different thread.
+ """
+ # Log in the user in the main thread
+ with login_app.test_request_context():
+ login_user(test_user)
+ assert current_user.is_authenticated
+ assert current_user.id == "test_user"
+
+ # Save the context variables
+ context_vars = contextvars.copy_context()
+
+ # Store the result of the thread execution
+ result = {"user_accessible": False, "user_id": None, "error": None}
+
+ # Define a function to run in a separate thread
+ def check_user_in_thread_with_manager():
+ try:
+ # Use preserve_flask_contexts to access current_user in a different thread
+ with preserve_flask_contexts(login_app, context_vars):
+ from flask_login import current_user
+
+ if current_user:
+ result["user_accessible"] = True
+ result["user_id"] = current_user.id
+ else:
+ result["user_accessible"] = False
+ except Exception as e:
+ result["error"] = str(e) # type: ignore
+
+ # Run the function in a separate thread
+ thread = threading.Thread(target=check_user_in_thread_with_manager)
+ thread.start()
+ thread.join()
+
+ # Verify that current_user is accessible and has the correct ID
+ assert result["error"] is None
+ assert result["user_accessible"] is True
+ assert result["user_id"] == "test_user"
diff --git a/api/tests/unit_tests/libs/test_helper.py b/api/tests/unit_tests/libs/test_helper.py
new file mode 100644
index 0000000000..b7701055f5
--- /dev/null
+++ b/api/tests/unit_tests/libs/test_helper.py
@@ -0,0 +1,65 @@
+import pytest
+
+from libs.helper import extract_tenant_id
+from models.account import Account
+from models.model import EndUser
+
+
+class TestExtractTenantId:
+ """Test cases for the extract_tenant_id utility function."""
+
+ def test_extract_tenant_id_from_account_with_tenant(self):
+ """Test extracting tenant_id from Account with current_tenant_id."""
+ # Create a mock Account object
+ account = Account()
+ # Mock the current_tenant_id property
+ account._current_tenant = type("MockTenant", (), {"id": "account-tenant-123"})()
+
+ tenant_id = extract_tenant_id(account)
+ assert tenant_id == "account-tenant-123"
+
+ def test_extract_tenant_id_from_account_without_tenant(self):
+ """Test extracting tenant_id from Account without current_tenant_id."""
+ # Create a mock Account object
+ account = Account()
+ account._current_tenant = None
+
+ tenant_id = extract_tenant_id(account)
+ assert tenant_id is None
+
+ def test_extract_tenant_id_from_enduser_with_tenant(self):
+ """Test extracting tenant_id from EndUser with tenant_id."""
+ # Create a mock EndUser object
+ end_user = EndUser()
+ end_user.tenant_id = "enduser-tenant-456"
+
+ tenant_id = extract_tenant_id(end_user)
+ assert tenant_id == "enduser-tenant-456"
+
+ def test_extract_tenant_id_from_enduser_without_tenant(self):
+ """Test extracting tenant_id from EndUser without tenant_id."""
+ # Create a mock EndUser object
+ end_user = EndUser()
+ end_user.tenant_id = None
+
+ tenant_id = extract_tenant_id(end_user)
+ assert tenant_id is None
+
+ def test_extract_tenant_id_with_invalid_user_type(self):
+ """Test extracting tenant_id with invalid user type raises ValueError."""
+ invalid_user = "not_a_user_object"
+
+ with pytest.raises(ValueError, match="Invalid user type.*Expected Account or EndUser"):
+ extract_tenant_id(invalid_user)
+
+ def test_extract_tenant_id_with_none_user(self):
+ """Test extracting tenant_id with None user raises ValueError."""
+ with pytest.raises(ValueError, match="Invalid user type.*Expected Account or EndUser"):
+ extract_tenant_id(None)
+
+ def test_extract_tenant_id_with_dict_user(self):
+ """Test extracting tenant_id with dict user raises ValueError."""
+ dict_user = {"id": "123", "tenant_id": "456"}
+
+ with pytest.raises(ValueError, match="Invalid user type.*Expected Account or EndUser"):
+ extract_tenant_id(dict_user)
diff --git a/api/tests/unit_tests/libs/test_login.py b/api/tests/unit_tests/libs/test_login.py
new file mode 100644
index 0000000000..39671077d4
--- /dev/null
+++ b/api/tests/unit_tests/libs/test_login.py
@@ -0,0 +1,232 @@
+from unittest.mock import MagicMock, patch
+
+import pytest
+from flask import Flask, g
+from flask_login import LoginManager, UserMixin
+
+from libs.login import _get_user, current_user, login_required
+
+
+class MockUser(UserMixin):
+ """Mock user class for testing."""
+
+ def __init__(self, id: str, is_authenticated: bool = True):
+ self.id = id
+ self._is_authenticated = is_authenticated
+
+ @property
+ def is_authenticated(self):
+ return self._is_authenticated
+
+
+class TestLoginRequired:
+ """Test cases for login_required decorator."""
+
+ @pytest.fixture
+ def setup_app(self, app: Flask):
+ """Set up Flask app with login manager."""
+ # Initialize login manager
+ login_manager = LoginManager()
+ login_manager.init_app(app)
+
+ # Mock unauthorized handler
+ login_manager.unauthorized = MagicMock(return_value="Unauthorized")
+
+ # Add a dummy user loader to prevent exceptions
+ @login_manager.user_loader
+ def load_user(user_id):
+ return None
+
+ return app
+
+ def test_authenticated_user_can_access_protected_view(self, setup_app: Flask):
+ """Test that authenticated users can access protected views."""
+
+ @login_required
+ def protected_view():
+ return "Protected content"
+
+ with setup_app.test_request_context():
+ # Mock authenticated user
+ mock_user = MockUser("test_user", is_authenticated=True)
+ with patch("libs.login._get_user", return_value=mock_user):
+ result = protected_view()
+ assert result == "Protected content"
+
+ def test_unauthenticated_user_cannot_access_protected_view(self, setup_app: Flask):
+ """Test that unauthenticated users are redirected."""
+
+ @login_required
+ def protected_view():
+ return "Protected content"
+
+ with setup_app.test_request_context():
+ # Mock unauthenticated user
+ mock_user = MockUser("test_user", is_authenticated=False)
+ with patch("libs.login._get_user", return_value=mock_user):
+ result = protected_view()
+ assert result == "Unauthorized"
+ setup_app.login_manager.unauthorized.assert_called_once()
+
+ def test_login_disabled_allows_unauthenticated_access(self, setup_app: Flask):
+ """Test that LOGIN_DISABLED config bypasses authentication."""
+
+ @login_required
+ def protected_view():
+ return "Protected content"
+
+ with setup_app.test_request_context():
+ # Mock unauthenticated user and LOGIN_DISABLED
+ mock_user = MockUser("test_user", is_authenticated=False)
+ with patch("libs.login._get_user", return_value=mock_user):
+ with patch("libs.login.dify_config") as mock_config:
+ mock_config.LOGIN_DISABLED = True
+
+ result = protected_view()
+ assert result == "Protected content"
+ # Ensure unauthorized was not called
+ setup_app.login_manager.unauthorized.assert_not_called()
+
+ def test_options_request_bypasses_authentication(self, setup_app: Flask):
+ """Test that OPTIONS requests are exempt from authentication."""
+
+ @login_required
+ def protected_view():
+ return "Protected content"
+
+ with setup_app.test_request_context(method="OPTIONS"):
+ # Mock unauthenticated user
+ mock_user = MockUser("test_user", is_authenticated=False)
+ with patch("libs.login._get_user", return_value=mock_user):
+ result = protected_view()
+ assert result == "Protected content"
+ # Ensure unauthorized was not called
+ setup_app.login_manager.unauthorized.assert_not_called()
+
+ def test_flask_2_compatibility(self, setup_app: Flask):
+ """Test Flask 2.x compatibility with ensure_sync."""
+
+ @login_required
+ def protected_view():
+ return "Protected content"
+
+ # Mock Flask 2.x ensure_sync
+ setup_app.ensure_sync = MagicMock(return_value=lambda: "Synced content")
+
+ with setup_app.test_request_context():
+ mock_user = MockUser("test_user", is_authenticated=True)
+ with patch("libs.login._get_user", return_value=mock_user):
+ result = protected_view()
+ assert result == "Synced content"
+ setup_app.ensure_sync.assert_called_once()
+
+ def test_flask_1_compatibility(self, setup_app: Flask):
+ """Test Flask 1.x compatibility without ensure_sync."""
+
+ @login_required
+ def protected_view():
+ return "Protected content"
+
+ # Remove ensure_sync to simulate Flask 1.x
+ if hasattr(setup_app, "ensure_sync"):
+ delattr(setup_app, "ensure_sync")
+
+ with setup_app.test_request_context():
+ mock_user = MockUser("test_user", is_authenticated=True)
+ with patch("libs.login._get_user", return_value=mock_user):
+ result = protected_view()
+ assert result == "Protected content"
+
+
+class TestGetUser:
+ """Test cases for _get_user function."""
+
+ def test_get_user_returns_user_from_g(self, app: Flask):
+ """Test that _get_user returns user from g._login_user."""
+ mock_user = MockUser("test_user")
+
+ with app.test_request_context():
+ g._login_user = mock_user
+ user = _get_user()
+ assert user == mock_user
+ assert user.id == "test_user"
+
+ def test_get_user_loads_user_if_not_in_g(self, app: Flask):
+ """Test that _get_user loads user if not already in g."""
+ mock_user = MockUser("test_user")
+
+ # Mock login manager
+ login_manager = MagicMock()
+ login_manager._load_user = MagicMock()
+ app.login_manager = login_manager
+
+ with app.test_request_context():
+ # Simulate _load_user setting g._login_user
+ def side_effect():
+ g._login_user = mock_user
+
+ login_manager._load_user.side_effect = side_effect
+
+ user = _get_user()
+ assert user == mock_user
+ login_manager._load_user.assert_called_once()
+
+ def test_get_user_returns_none_without_request_context(self, app: Flask):
+ """Test that _get_user returns None outside request context."""
+ # Outside of request context
+ user = _get_user()
+ assert user is None
+
+
+class TestCurrentUser:
+ """Test cases for current_user proxy."""
+
+ def test_current_user_proxy_returns_authenticated_user(self, app: Flask):
+ """Test that current_user proxy returns authenticated user."""
+ mock_user = MockUser("test_user", is_authenticated=True)
+
+ with app.test_request_context():
+ with patch("libs.login._get_user", return_value=mock_user):
+ assert current_user.id == "test_user"
+ assert current_user.is_authenticated is True
+
+ def test_current_user_proxy_returns_none_when_no_user(self, app: Flask):
+ """Test that current_user proxy handles None user."""
+ with app.test_request_context():
+ with patch("libs.login._get_user", return_value=None):
+ # When _get_user returns None, accessing attributes should fail
+ # or current_user should evaluate to falsy
+ try:
+ # Try to access an attribute that would exist on a real user
+ _ = current_user.id
+ pytest.fail("Should have raised AttributeError")
+ except AttributeError:
+ # This is expected when current_user is None
+ pass
+
+ def test_current_user_proxy_thread_safety(self, app: Flask):
+ """Test that current_user proxy is thread-safe."""
+ import threading
+
+ results = {}
+
+ def check_user_in_thread(user_id: str, index: int):
+ with app.test_request_context():
+ mock_user = MockUser(user_id)
+ with patch("libs.login._get_user", return_value=mock_user):
+ results[index] = current_user.id
+
+ # Create multiple threads with different users
+ threads = []
+ for i in range(5):
+ thread = threading.Thread(target=check_user_in_thread, args=(f"user_{i}", i))
+ threads.append(thread)
+ thread.start()
+
+ # Wait for all threads to complete
+ for thread in threads:
+ thread.join()
+
+ # Verify each thread got its own user
+ for i in range(5):
+ assert results[i] == f"user_{i}"
diff --git a/api/tests/unit_tests/libs/test_passport.py b/api/tests/unit_tests/libs/test_passport.py
new file mode 100644
index 0000000000..f33484c18d
--- /dev/null
+++ b/api/tests/unit_tests/libs/test_passport.py
@@ -0,0 +1,205 @@
+from datetime import UTC, datetime, timedelta
+from unittest.mock import patch
+
+import jwt
+import pytest
+from werkzeug.exceptions import Unauthorized
+
+from libs.passport import PassportService
+
+
+class TestPassportService:
+ """Test PassportService JWT operations"""
+
+ @pytest.fixture
+ def passport_service(self):
+ """Create PassportService instance with test secret key"""
+ with patch("libs.passport.dify_config") as mock_config:
+ mock_config.SECRET_KEY = "test-secret-key-for-testing"
+ return PassportService()
+
+ @pytest.fixture
+ def another_passport_service(self):
+ """Create another PassportService instance with different secret key"""
+ with patch("libs.passport.dify_config") as mock_config:
+ mock_config.SECRET_KEY = "another-secret-key-for-testing"
+ return PassportService()
+
+ # Core functionality tests
+ def test_should_issue_and_verify_token(self, passport_service):
+ """Test complete JWT lifecycle: issue and verify"""
+ payload = {"user_id": "123", "app_code": "test-app"}
+ token = passport_service.issue(payload)
+
+ # Verify token format
+ assert isinstance(token, str)
+ assert len(token.split(".")) == 3 # JWT format: header.payload.signature
+
+ # Verify token content
+ decoded = passport_service.verify(token)
+ assert decoded == payload
+
+ def test_should_handle_different_payload_types(self, passport_service):
+ """Test issuing and verifying tokens with different payload types"""
+ test_cases = [
+ {"string": "value"},
+ {"number": 42},
+ {"float": 3.14},
+ {"boolean": True},
+ {"null": None},
+ {"array": [1, 2, 3]},
+ {"nested": {"key": "value"}},
+ {"unicode": "中文测试"},
+ {"emoji": "🔐"},
+ {}, # Empty payload
+ ]
+
+ for payload in test_cases:
+ token = passport_service.issue(payload)
+ decoded = passport_service.verify(token)
+ assert decoded == payload
+
+ # Security tests
+ def test_should_reject_modified_token(self, passport_service):
+ """Test that any modification to token invalidates it"""
+ token = passport_service.issue({"user": "test"})
+
+ # Test multiple modification points
+ test_positions = [0, len(token) // 3, len(token) // 2, len(token) - 1]
+
+ for pos in test_positions:
+ if pos < len(token) and token[pos] != ".":
+ # Change one character
+ tampered = token[:pos] + ("X" if token[pos] != "X" else "Y") + token[pos + 1 :]
+ with pytest.raises(Unauthorized):
+ passport_service.verify(tampered)
+
+ def test_should_reject_token_with_different_secret_key(self, passport_service, another_passport_service):
+ """Test key isolation - token from one service should not work with another"""
+ payload = {"user_id": "123", "app_code": "test-app"}
+ token = passport_service.issue(payload)
+
+ with pytest.raises(Unauthorized) as exc_info:
+ another_passport_service.verify(token)
+ assert str(exc_info.value) == "401 Unauthorized: Invalid token signature."
+
+ def test_should_use_hs256_algorithm(self, passport_service):
+ """Test that HS256 algorithm is used for signing"""
+ payload = {"test": "data"}
+ token = passport_service.issue(payload)
+
+ # Decode header without relying on JWT internals
+ # Use jwt.get_unverified_header which is a public API
+ header = jwt.get_unverified_header(token)
+ assert header["alg"] == "HS256"
+
+ def test_should_reject_token_with_wrong_algorithm(self, passport_service):
+ """Test rejection of token signed with different algorithm"""
+ payload = {"user_id": "123"}
+
+ # Create token with different algorithm
+ with patch("libs.passport.dify_config") as mock_config:
+ mock_config.SECRET_KEY = "test-secret-key-for-testing"
+ # Create token with HS512 instead of HS256
+ wrong_alg_token = jwt.encode(payload, mock_config.SECRET_KEY, algorithm="HS512")
+
+ # Should fail because service expects HS256
+ # InvalidAlgorithmError is now caught by PyJWTError handler
+ with pytest.raises(Unauthorized) as exc_info:
+ passport_service.verify(wrong_alg_token)
+ assert str(exc_info.value) == "401 Unauthorized: Invalid token."
+
+ # Exception handling tests
+ def test_should_handle_invalid_tokens(self, passport_service):
+ """Test handling of various invalid token formats"""
+ invalid_tokens = [
+ ("not.a.token", "Invalid token."),
+ ("invalid-jwt-format", "Invalid token."),
+ ("xxx.yyy.zzz", "Invalid token."),
+ ("a.b", "Invalid token."), # Missing signature
+ ("", "Invalid token."), # Empty string
+ (" ", "Invalid token."), # Whitespace
+ (None, "Invalid token."), # None value
+ # Malformed base64
+ ("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.INVALID_BASE64!@#$.signature", "Invalid token."),
+ ]
+
+ for invalid_token, expected_message in invalid_tokens:
+ with pytest.raises(Unauthorized) as exc_info:
+ passport_service.verify(invalid_token)
+ assert expected_message in str(exc_info.value)
+
+ def test_should_reject_expired_token(self, passport_service):
+ """Test rejection of expired token"""
+ past_time = datetime.now(UTC) - timedelta(hours=1)
+ payload = {"user_id": "123", "exp": past_time.timestamp()}
+
+ with patch("libs.passport.dify_config") as mock_config:
+ mock_config.SECRET_KEY = "test-secret-key-for-testing"
+ token = jwt.encode(payload, mock_config.SECRET_KEY, algorithm="HS256")
+
+ with pytest.raises(Unauthorized) as exc_info:
+ passport_service.verify(token)
+ assert str(exc_info.value) == "401 Unauthorized: Token has expired."
+
+ # Configuration tests
+ def test_should_handle_empty_secret_key(self):
+ """Test behavior when SECRET_KEY is empty"""
+ with patch("libs.passport.dify_config") as mock_config:
+ mock_config.SECRET_KEY = ""
+ service = PassportService()
+
+ # Empty secret key should still work but is insecure
+ payload = {"test": "data"}
+ token = service.issue(payload)
+ decoded = service.verify(token)
+ assert decoded == payload
+
+ def test_should_handle_none_secret_key(self):
+ """Test behavior when SECRET_KEY is None"""
+ with patch("libs.passport.dify_config") as mock_config:
+ mock_config.SECRET_KEY = None
+ service = PassportService()
+
+ payload = {"test": "data"}
+ # JWT library will raise TypeError when secret is None
+ with pytest.raises((TypeError, jwt.exceptions.InvalidKeyError)):
+ service.issue(payload)
+
+ # Boundary condition tests
+ def test_should_handle_large_payload(self, passport_service):
+ """Test handling of large payload"""
+ # Test with 100KB instead of 1MB for faster tests
+ large_data = "x" * (100 * 1024)
+ payload = {"data": large_data}
+
+ token = passport_service.issue(payload)
+ decoded = passport_service.verify(token)
+
+ assert decoded["data"] == large_data
+
+ def test_should_handle_special_characters_in_payload(self, passport_service):
+ """Test handling of special characters in payload"""
+ special_payloads = [
+ {"special": "!@#$%^&*()"},
+ {"quotes": 'He said "Hello"'},
+ {"backslash": "path\\to\\file"},
+ {"newline": "line1\nline2"},
+ {"unicode": "🔐🔑🛡️"},
+ {"mixed": "Test123!@#中文🔐"},
+ ]
+
+ for payload in special_payloads:
+ token = passport_service.issue(payload)
+ decoded = passport_service.verify(token)
+ assert decoded == payload
+
+ def test_should_catch_generic_pyjwt_errors(self, passport_service):
+ """Test that generic PyJWTError exceptions are caught and converted to Unauthorized"""
+ # Mock jwt.decode to raise a generic PyJWTError
+ with patch("libs.passport.jwt.decode") as mock_decode:
+ mock_decode.side_effect = jwt.exceptions.PyJWTError("Generic JWT error")
+
+ with pytest.raises(Unauthorized) as exc_info:
+ passport_service.verify("some-token")
+ assert str(exc_info.value) == "401 Unauthorized: Invalid token."
diff --git a/api/tests/unit_tests/libs/test_password.py b/api/tests/unit_tests/libs/test_password.py
new file mode 100644
index 0000000000..79fc792cc5
--- /dev/null
+++ b/api/tests/unit_tests/libs/test_password.py
@@ -0,0 +1,74 @@
+import base64
+import binascii
+import os
+
+import pytest
+
+from libs.password import compare_password, hash_password, valid_password
+
+
+class TestValidPassword:
+ """Test password format validation"""
+
+ def test_should_accept_valid_passwords(self):
+ """Test accepting valid password formats"""
+ assert valid_password("password123") == "password123"
+ assert valid_password("test1234") == "test1234"
+ assert valid_password("Test123456") == "Test123456"
+
+ def test_should_reject_invalid_passwords(self):
+ """Test rejecting invalid password formats"""
+ # Too short
+ with pytest.raises(ValueError) as exc_info:
+ valid_password("abc123")
+ assert "Password must contain letters and numbers" in str(exc_info.value)
+
+ # No numbers
+ with pytest.raises(ValueError):
+ valid_password("abcdefgh")
+
+ # No letters
+ with pytest.raises(ValueError):
+ valid_password("12345678")
+
+ # Empty
+ with pytest.raises(ValueError):
+ valid_password("")
+
+
+class TestPasswordHashing:
+ """Test password hashing and comparison"""
+
+ def setup_method(self):
+ """Setup test data"""
+ self.password = "test123password"
+ self.salt = os.urandom(16)
+ self.salt_base64 = base64.b64encode(self.salt).decode()
+
+ password_hash = hash_password(self.password, self.salt)
+ self.password_hash_base64 = base64.b64encode(password_hash).decode()
+
+ def test_should_verify_correct_password(self):
+ """Test correct password verification"""
+ result = compare_password(self.password, self.password_hash_base64, self.salt_base64)
+ assert result is True
+
+ def test_should_reject_wrong_password(self):
+ """Test rejection of incorrect passwords"""
+ result = compare_password("wrongpassword", self.password_hash_base64, self.salt_base64)
+ assert result is False
+
+ def test_should_handle_invalid_base64(self):
+ """Test handling of invalid base64 data"""
+ # Invalid base64 hash
+ with pytest.raises(binascii.Error):
+ compare_password(self.password, "invalid_base64!", self.salt_base64)
+
+ # Invalid base64 salt
+ with pytest.raises(binascii.Error):
+ compare_password(self.password, self.password_hash_base64, "invalid_base64!")
+
+ def test_should_be_case_sensitive(self):
+ """Test password case sensitivity"""
+ result = compare_password(self.password.upper(), self.password_hash_base64, self.salt_base64)
+ assert result is False
diff --git a/api/tests/unit_tests/models/__init__.py b/api/tests/unit_tests/models/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/tests/unit_tests/models/test_workflow.py b/api/tests/unit_tests/models/test_workflow.py
index b79e95c7ed..5bc77ad0ef 100644
--- a/api/tests/unit_tests/models/test_workflow.py
+++ b/api/tests/unit_tests/models/test_workflow.py
@@ -1,10 +1,16 @@
+import dataclasses
import json
from unittest import mock
from uuid import uuid4
from constants import HIDDEN_VALUE
+from core.file.enums import FileTransferMethod, FileType
+from core.file.models import File
from core.variables import FloatVariable, IntegerVariable, SecretVariable, StringVariable
-from models.workflow import Workflow, WorkflowNodeExecutionModel
+from core.variables.segments import IntegerSegment, Segment
+from factories.variable_factory import build_segment
+from models.model import EndUser
+from models.workflow import Workflow, WorkflowDraftVariable, WorkflowNodeExecutionModel, is_system_variable_editable
def test_environment_variables():
@@ -38,7 +44,7 @@ def test_environment_variables():
)
# Mock current_user as an EndUser
- mock_user = mock.Mock()
+ mock_user = mock.Mock(spec=EndUser)
mock_user.tenant_id = "tenant_id"
with (
@@ -85,7 +91,7 @@ def test_update_environment_variables():
)
# Mock current_user as an EndUser
- mock_user = mock.Mock()
+ mock_user = mock.Mock(spec=EndUser)
mock_user.tenant_id = "tenant_id"
with (
@@ -131,7 +137,7 @@ def test_to_dict():
# Create some EnvironmentVariable instances
# Mock current_user as an EndUser
- mock_user = mock.Mock()
+ mock_user = mock.Mock(spec=EndUser)
mock_user.tenant_id = "tenant_id"
with (
@@ -163,3 +169,147 @@ class TestWorkflowNodeExecution:
original = {"a": 1, "b": ["2"]}
node_exec.execution_metadata = json.dumps(original)
assert node_exec.execution_metadata_dict == original
+
+
+class TestIsSystemVariableEditable:
+ def test_is_system_variable(self):
+ cases = [
+ ("query", True),
+ ("files", True),
+ ("dialogue_count", False),
+ ("conversation_id", False),
+ ("user_id", False),
+ ("app_id", False),
+ ("workflow_id", False),
+ ("workflow_run_id", False),
+ ]
+ for name, editable in cases:
+ assert editable == is_system_variable_editable(name)
+
+ assert is_system_variable_editable("invalid_or_new_system_variable") == False
+
+
+class TestWorkflowDraftVariableGetValue:
+ def test_get_value_by_case(self):
+ @dataclasses.dataclass
+ class TestCase:
+ name: str
+ value: Segment
+
+ tenant_id = "test_tenant_id"
+
+ test_file = File(
+ tenant_id=tenant_id,
+ type=FileType.IMAGE,
+ transfer_method=FileTransferMethod.REMOTE_URL,
+ remote_url="https://example.com/example.jpg",
+ filename="example.jpg",
+ extension=".jpg",
+ mime_type="image/jpeg",
+ size=100,
+ )
+ cases: list[TestCase] = [
+ TestCase(
+ name="number/int",
+ value=build_segment(1),
+ ),
+ TestCase(
+ name="number/float",
+ value=build_segment(1.0),
+ ),
+ TestCase(
+ name="string",
+ value=build_segment("a"),
+ ),
+ TestCase(
+ name="object",
+ value=build_segment({}),
+ ),
+ TestCase(
+ name="file",
+ value=build_segment(test_file),
+ ),
+ TestCase(
+ name="array[any]",
+ value=build_segment([1, "a"]),
+ ),
+ TestCase(
+ name="array[string]",
+ value=build_segment(["a", "b"]),
+ ),
+ TestCase(
+ name="array[number]/int",
+ value=build_segment([1, 2]),
+ ),
+ TestCase(
+ name="array[number]/float",
+ value=build_segment([1.0, 2.0]),
+ ),
+ TestCase(
+ name="array[number]/mixed",
+ value=build_segment([1, 2.0]),
+ ),
+ TestCase(
+ name="array[object]",
+ value=build_segment([{}, {"a": 1}]),
+ ),
+ TestCase(
+ name="none",
+ value=build_segment(None),
+ ),
+ ]
+
+ for idx, c in enumerate(cases, 1):
+ fail_msg = f"test case {c.name} failed, index={idx}"
+ draft_var = WorkflowDraftVariable()
+ draft_var.set_value(c.value)
+ assert c.value == draft_var.get_value(), fail_msg
+
+ def test_file_variable_preserves_all_fields(self):
+ """Test that File type variables preserve all fields during encoding/decoding."""
+ tenant_id = "test_tenant_id"
+
+ # Create a File with specific field values
+ test_file = File(
+ id="test_file_id",
+ tenant_id=tenant_id,
+ type=FileType.IMAGE,
+ transfer_method=FileTransferMethod.REMOTE_URL,
+ remote_url="https://example.com/test.jpg",
+ filename="test.jpg",
+ extension=".jpg",
+ mime_type="image/jpeg",
+ size=12345, # Specific size to test preservation
+ storage_key="test_storage_key",
+ )
+
+ # Create a FileSegment and WorkflowDraftVariable
+ file_segment = build_segment(test_file)
+ draft_var = WorkflowDraftVariable()
+ draft_var.set_value(file_segment)
+
+ # Retrieve the value and verify all fields are preserved
+ retrieved_segment = draft_var.get_value()
+ retrieved_file = retrieved_segment.value
+
+ # Verify all important fields are preserved
+ assert retrieved_file.id == test_file.id
+ assert retrieved_file.tenant_id == test_file.tenant_id
+ assert retrieved_file.type == test_file.type
+ assert retrieved_file.transfer_method == test_file.transfer_method
+ assert retrieved_file.remote_url == test_file.remote_url
+ assert retrieved_file.filename == test_file.filename
+ assert retrieved_file.extension == test_file.extension
+ assert retrieved_file.mime_type == test_file.mime_type
+ assert retrieved_file.size == test_file.size # This was the main issue being fixed
+ # Note: storage_key is not serialized in model_dump() so it won't be preserved
+
+ # Verify the segments have the same type and the important fields match
+ assert file_segment.value_type == retrieved_segment.value_type
+
+ def test_get_and_set_value(self):
+ draft_var = WorkflowDraftVariable()
+ int_var = IntegerSegment(value=1)
+ draft_var.set_value(int_var)
+ value = draft_var.get_value()
+ assert value == int_var
diff --git a/api/tests/unit_tests/services/services_test_help.py b/api/tests/unit_tests/services/services_test_help.py
new file mode 100644
index 0000000000..c6b962f7fc
--- /dev/null
+++ b/api/tests/unit_tests/services/services_test_help.py
@@ -0,0 +1,59 @@
+from unittest.mock import MagicMock
+
+
+class ServiceDbTestHelper:
+ """
+ Helper class for service database query tests.
+ """
+
+ @staticmethod
+ def setup_db_query_filter_by_mock(mock_db, query_results):
+ """
+ Smart database query mock that responds based on model type and query parameters.
+
+ Args:
+ mock_db: Mock database session
+ query_results: Dict mapping (model_name, filter_key, filter_value) to return value
+ Example: {('Account', 'email', 'test@example.com'): mock_account}
+ """
+
+ def query_side_effect(model):
+ mock_query = MagicMock()
+
+ def filter_by_side_effect(**kwargs):
+ mock_filter_result = MagicMock()
+
+ def first_side_effect():
+ # Find matching result based on model and filter parameters
+ for (model_name, filter_key, filter_value), result in query_results.items():
+ if model.__name__ == model_name and filter_key in kwargs and kwargs[filter_key] == filter_value:
+ return result
+ return None
+
+ mock_filter_result.first.side_effect = first_side_effect
+
+ # Handle order_by calls for complex queries
+ def order_by_side_effect(*args, **kwargs):
+ mock_order_result = MagicMock()
+
+ def order_first_side_effect():
+ # Look for order_by results in the same query_results dict
+ for (model_name, filter_key, filter_value), result in query_results.items():
+ if (
+ model.__name__ == model_name
+ and filter_key == "order_by"
+ and filter_value == "first_available"
+ ):
+ return result
+ return None
+
+ mock_order_result.first.side_effect = order_first_side_effect
+ return mock_order_result
+
+ mock_filter_result.order_by.side_effect = order_by_side_effect
+ return mock_filter_result
+
+ mock_query.filter_by.side_effect = filter_by_side_effect
+ return mock_query
+
+ mock_db.session.query.side_effect = query_side_effect
diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py
new file mode 100644
index 0000000000..13900ab6d1
--- /dev/null
+++ b/api/tests/unit_tests/services/test_account_service.py
@@ -0,0 +1,1545 @@
+import json
+from datetime import datetime, timedelta
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from configs import dify_config
+from models.account import Account
+from services.account_service import AccountService, RegisterService, TenantService
+from services.errors.account import (
+ AccountAlreadyInTenantError,
+ AccountLoginError,
+ AccountNotFoundError,
+ AccountPasswordError,
+ AccountRegisterError,
+ CurrentPasswordIncorrectError,
+)
+from tests.unit_tests.services.services_test_help import ServiceDbTestHelper
+
+
+class TestAccountAssociatedDataFactory:
+ """Factory class for creating test data and mock objects for account service tests."""
+
+ @staticmethod
+ def create_account_mock(
+ account_id: str = "user-123",
+ email: str = "test@example.com",
+ name: str = "Test User",
+ status: str = "active",
+ password: str = "hashed_password",
+ password_salt: str = "salt",
+ interface_language: str = "en-US",
+ interface_theme: str = "light",
+ timezone: str = "UTC",
+ **kwargs,
+ ) -> MagicMock:
+ """Create a mock account with specified attributes."""
+ account = MagicMock(spec=Account)
+ account.id = account_id
+ account.email = email
+ account.name = name
+ account.status = status
+ account.password = password
+ account.password_salt = password_salt
+ account.interface_language = interface_language
+ account.interface_theme = interface_theme
+ account.timezone = timezone
+ # Set last_active_at to a datetime object that's older than 10 minutes
+ account.last_active_at = datetime.now() - timedelta(minutes=15)
+ account.initialized_at = None
+ for key, value in kwargs.items():
+ setattr(account, key, value)
+ return account
+
+ @staticmethod
+ def create_tenant_join_mock(
+ tenant_id: str = "tenant-456",
+ account_id: str = "user-123",
+ current: bool = True,
+ role: str = "normal",
+ **kwargs,
+ ) -> MagicMock:
+ """Create a mock tenant account join record."""
+ tenant_join = MagicMock()
+ tenant_join.tenant_id = tenant_id
+ tenant_join.account_id = account_id
+ tenant_join.current = current
+ tenant_join.role = role
+ for key, value in kwargs.items():
+ setattr(tenant_join, key, value)
+ return tenant_join
+
+ @staticmethod
+ def create_feature_service_mock(allow_register: bool = True):
+ """Create a mock feature service."""
+ mock_service = MagicMock()
+ mock_service.get_system_features.return_value.is_allow_register = allow_register
+ return mock_service
+
+ @staticmethod
+ def create_billing_service_mock(email_frozen: bool = False):
+ """Create a mock billing service."""
+ mock_service = MagicMock()
+ mock_service.is_email_in_freeze.return_value = email_frozen
+ return mock_service
+
+
+class TestAccountService:
+ """
+ Comprehensive unit tests for AccountService methods.
+
+ This test suite covers all account-related operations including:
+ - Authentication and login
+ - Account creation and registration
+ - Password management
+ - JWT token generation
+ - User loading and tenant management
+ - Error conditions and edge cases
+ """
+
+ @pytest.fixture
+ def mock_db_dependencies(self):
+ """Common mock setup for database dependencies."""
+ with patch("services.account_service.db") as mock_db:
+ mock_db.session.add = MagicMock()
+ mock_db.session.commit = MagicMock()
+ yield {
+ "db": mock_db,
+ }
+
+ @pytest.fixture
+ def mock_password_dependencies(self):
+ """Mock setup for password-related functions."""
+ with (
+ patch("services.account_service.compare_password") as mock_compare_password,
+ patch("services.account_service.hash_password") as mock_hash_password,
+ patch("services.account_service.valid_password") as mock_valid_password,
+ ):
+ yield {
+ "compare_password": mock_compare_password,
+ "hash_password": mock_hash_password,
+ "valid_password": mock_valid_password,
+ }
+
+ @pytest.fixture
+ def mock_external_service_dependencies(self):
+ """Mock setup for external service dependencies."""
+ with (
+ patch("services.account_service.FeatureService") as mock_feature_service,
+ patch("services.account_service.BillingService") as mock_billing_service,
+ patch("services.account_service.PassportService") as mock_passport_service,
+ ):
+ yield {
+ "feature_service": mock_feature_service,
+ "billing_service": mock_billing_service,
+ "passport_service": mock_passport_service,
+ }
+
+ @pytest.fixture
+ def mock_db_with_autospec(self):
+ """
+ Mock database with autospec for more realistic behavior.
+ This approach preserves the actual method signatures and behavior.
+ """
+ with patch("services.account_service.db", autospec=True) as mock_db:
+ # Create a more realistic session mock
+ mock_session = MagicMock()
+ mock_db.session = mock_session
+
+ # Setup basic session methods
+ mock_session.add = MagicMock()
+ mock_session.commit = MagicMock()
+ mock_session.query = MagicMock()
+
+ yield mock_db
+
+ def _assert_database_operations_called(self, mock_db):
+ """Helper method to verify database operations were called."""
+ mock_db.session.commit.assert_called()
+
+ def _assert_database_operations_not_called(self, mock_db):
+ """Helper method to verify database operations were not called."""
+ mock_db.session.commit.assert_not_called()
+
+ def _assert_exception_raised(self, exception_type, callable_func, *args, **kwargs):
+ """Helper method to verify that specific exception is raised."""
+ with pytest.raises(exception_type):
+ callable_func(*args, **kwargs)
+
+ # ==================== Authentication Tests ====================
+
+ def test_authenticate_success(self, mock_db_dependencies, mock_password_dependencies):
+ """Test successful authentication with correct email and password."""
+ # Setup test data
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+
+ # Setup smart database query mock
+ query_results = {("Account", "email", "test@example.com"): mock_account}
+ ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
+
+ mock_password_dependencies["compare_password"].return_value = True
+
+ # Execute test
+ result = AccountService.authenticate("test@example.com", "password")
+
+ # Verify results
+ assert result == mock_account
+ self._assert_database_operations_called(mock_db_dependencies["db"])
+
+ def test_authenticate_account_not_found(self, mock_db_dependencies):
+ """Test authentication when account does not exist."""
+ # Setup smart database query mock - no matching results
+ query_results = {("Account", "email", "notfound@example.com"): None}
+ ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
+
+ # Execute test and verify exception
+ self._assert_exception_raised(
+ AccountNotFoundError, AccountService.authenticate, "notfound@example.com", "password"
+ )
+
+ def test_authenticate_account_banned(self, mock_db_dependencies):
+ """Test authentication when account is banned."""
+ # Setup test data
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock(status="banned")
+
+ # Setup smart database query mock
+ query_results = {("Account", "email", "banned@example.com"): mock_account}
+ ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
+
+ # Execute test and verify exception
+ self._assert_exception_raised(AccountLoginError, AccountService.authenticate, "banned@example.com", "password")
+
+ def test_authenticate_password_error(self, mock_db_dependencies, mock_password_dependencies):
+ """Test authentication with wrong password."""
+ # Setup test data
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+
+ # Setup smart database query mock
+ query_results = {("Account", "email", "test@example.com"): mock_account}
+ ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
+
+ mock_password_dependencies["compare_password"].return_value = False
+
+ # Execute test and verify exception
+ self._assert_exception_raised(
+ AccountPasswordError, AccountService.authenticate, "test@example.com", "wrongpassword"
+ )
+
+ def test_authenticate_pending_account_activates(self, mock_db_dependencies, mock_password_dependencies):
+ """Test authentication for a pending account, which should activate on login."""
+ # Setup test data
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock(status="pending")
+
+ # Setup smart database query mock
+ query_results = {("Account", "email", "pending@example.com"): mock_account}
+ ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
+
+ mock_password_dependencies["compare_password"].return_value = True
+
+ # Execute test
+ result = AccountService.authenticate("pending@example.com", "password")
+
+ # Verify results
+ assert result == mock_account
+ assert mock_account.status == "active"
+ self._assert_database_operations_called(mock_db_dependencies["db"])
+
+ # ==================== Account Creation Tests ====================
+
+ def test_create_account_success(
+ self, mock_db_dependencies, mock_password_dependencies, mock_external_service_dependencies
+ ):
+ """Test successful account creation with all required parameters."""
+ # Setup mocks
+ mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
+ mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
+ mock_password_dependencies["hash_password"].return_value = b"hashed_password"
+
+ # Execute test
+ result = AccountService.create_account(
+ email="test@example.com",
+ name="Test User",
+ interface_language="en-US",
+ password="password123",
+ interface_theme="light",
+ )
+
+ # Verify results
+ assert result.email == "test@example.com"
+ assert result.name == "Test User"
+ assert result.interface_language == "en-US"
+ assert result.interface_theme == "light"
+ assert result.password is not None
+ assert result.password_salt is not None
+ assert result.timezone is not None
+
+ # Verify database operations
+ mock_db_dependencies["db"].session.add.assert_called_once()
+ added_account = mock_db_dependencies["db"].session.add.call_args[0][0]
+ assert added_account.email == "test@example.com"
+ assert added_account.name == "Test User"
+ assert added_account.interface_language == "en-US"
+ assert added_account.interface_theme == "light"
+ assert added_account.password is not None
+ assert added_account.password_salt is not None
+ assert added_account.timezone is not None
+ self._assert_database_operations_called(mock_db_dependencies["db"])
+
+ def test_create_account_registration_disabled(self, mock_external_service_dependencies):
+ """Test account creation when registration is disabled."""
+ # Setup mocks
+ mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = False
+
+ # Execute test and verify exception
+ self._assert_exception_raised(
+ Exception, # AccountNotFound
+ AccountService.create_account,
+ email="test@example.com",
+ name="Test User",
+ interface_language="en-US",
+ )
+
+ def test_create_account_email_frozen(self, mock_db_dependencies, mock_external_service_dependencies):
+ """Test account creation with frozen email address."""
+ # Setup mocks
+ mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
+ mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = True
+ dify_config.BILLING_ENABLED = True
+
+ # Execute test and verify exception
+ self._assert_exception_raised(
+ AccountRegisterError,
+ AccountService.create_account,
+ email="frozen@example.com",
+ name="Test User",
+ interface_language="en-US",
+ )
+ dify_config.BILLING_ENABLED = False
+
+ def test_create_account_without_password(self, mock_db_dependencies, mock_external_service_dependencies):
+ """Test account creation without password (for invite-based registration)."""
+ # Setup mocks
+ mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
+ mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
+
+ # Execute test
+ result = AccountService.create_account(
+ email="test@example.com",
+ name="Test User",
+ interface_language="zh-CN",
+ password=None,
+ interface_theme="dark",
+ )
+
+ # Verify results
+ assert result.email == "test@example.com"
+ assert result.name == "Test User"
+ assert result.interface_language == "zh-CN"
+ assert result.interface_theme == "dark"
+ assert result.password is None
+ assert result.password_salt is None
+ assert result.timezone is not None
+
+ # Verify database operations
+ mock_db_dependencies["db"].session.add.assert_called_once()
+ added_account = mock_db_dependencies["db"].session.add.call_args[0][0]
+ assert added_account.email == "test@example.com"
+ assert added_account.name == "Test User"
+ assert added_account.interface_language == "zh-CN"
+ assert added_account.interface_theme == "dark"
+ assert added_account.password is None
+ assert added_account.password_salt is None
+ assert added_account.timezone is not None
+ self._assert_database_operations_called(mock_db_dependencies["db"])
+
+ # ==================== Password Management Tests ====================
+
+ def test_update_account_password_success(self, mock_db_dependencies, mock_password_dependencies):
+ """Test successful password update with correct current password and valid new password."""
+ # Setup test data
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+ mock_password_dependencies["compare_password"].return_value = True
+ mock_password_dependencies["valid_password"].return_value = None
+ mock_password_dependencies["hash_password"].return_value = b"new_hashed_password"
+
+ # Execute test
+ result = AccountService.update_account_password(mock_account, "old_password", "new_password123")
+
+ # Verify results
+ assert result == mock_account
+ assert mock_account.password is not None
+ assert mock_account.password_salt is not None
+
+ # Verify password validation was called
+ mock_password_dependencies["compare_password"].assert_called_once_with(
+ "old_password", "hashed_password", "salt"
+ )
+ mock_password_dependencies["valid_password"].assert_called_once_with("new_password123")
+
+ # Verify database operations
+ self._assert_database_operations_called(mock_db_dependencies["db"])
+
+ def test_update_account_password_current_password_incorrect(self, mock_password_dependencies):
+ """Test password update with incorrect current password."""
+ # Setup test data
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+ mock_password_dependencies["compare_password"].return_value = False
+
+ # Execute test and verify exception
+ self._assert_exception_raised(
+ CurrentPasswordIncorrectError,
+ AccountService.update_account_password,
+ mock_account,
+ "wrong_password",
+ "new_password123",
+ )
+
+ # Verify password comparison was called
+ mock_password_dependencies["compare_password"].assert_called_once_with(
+ "wrong_password", "hashed_password", "salt"
+ )
+
+ def test_update_account_password_invalid_new_password(self, mock_password_dependencies):
+ """Test password update with invalid new password."""
+ # Setup test data
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+ mock_password_dependencies["compare_password"].return_value = True
+ mock_password_dependencies["valid_password"].side_effect = ValueError("Password too short")
+
+ # Execute test and verify exception
+ self._assert_exception_raised(
+ ValueError, AccountService.update_account_password, mock_account, "old_password", "short"
+ )
+
+ # Verify password validation was called
+ mock_password_dependencies["valid_password"].assert_called_once_with("short")
+
+ # ==================== User Loading Tests ====================
+
+ def test_load_user_success(self, mock_db_dependencies):
+ """Test successful user loading with current tenant."""
+ # Setup test data
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+ mock_tenant_join = TestAccountAssociatedDataFactory.create_tenant_join_mock()
+
+ # Setup smart database query mock
+ query_results = {
+ ("Account", "id", "user-123"): mock_account,
+ ("TenantAccountJoin", "account_id", "user-123"): mock_tenant_join,
+ }
+ ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
+
+ # Mock datetime
+ with patch("services.account_service.datetime") as mock_datetime:
+ mock_now = datetime.now()
+ mock_datetime.now.return_value = mock_now
+ mock_datetime.UTC = "UTC"
+
+ # Execute test
+ result = AccountService.load_user("user-123")
+
+ # Verify results
+ assert result == mock_account
+ assert mock_account.set_tenant_id.called
+
+ def test_load_user_not_found(self, mock_db_dependencies):
+ """Test user loading when user does not exist."""
+ # Setup smart database query mock - no matching results
+ query_results = {("Account", "id", "non-existent-user"): None}
+ ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
+
+ # Execute test
+ result = AccountService.load_user("non-existent-user")
+
+ # Verify results
+ assert result is None
+
+ def test_load_user_banned(self, mock_db_dependencies):
+ """Test user loading when user is banned."""
+ # Setup test data
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock(status="banned")
+
+ # Setup smart database query mock
+ query_results = {("Account", "id", "user-123"): mock_account}
+ ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
+
+ # Execute test and verify exception
+ self._assert_exception_raised(
+ Exception, # Unauthorized
+ AccountService.load_user,
+ "user-123",
+ )
+
+ def test_load_user_no_current_tenant(self, mock_db_dependencies):
+ """Test user loading when user has no current tenant but has available tenants."""
+ # Setup test data
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+ mock_available_tenant = TestAccountAssociatedDataFactory.create_tenant_join_mock(current=False)
+
+ # Setup smart database query mock for complex scenario
+ query_results = {
+ ("Account", "id", "user-123"): mock_account,
+ ("TenantAccountJoin", "account_id", "user-123"): None, # No current tenant
+ ("TenantAccountJoin", "order_by", "first_available"): mock_available_tenant, # First available tenant
+ }
+ ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
+
+ # Mock datetime
+ with patch("services.account_service.datetime") as mock_datetime:
+ mock_now = datetime.now()
+ mock_datetime.now.return_value = mock_now
+ mock_datetime.UTC = "UTC"
+
+ # Execute test
+ result = AccountService.load_user("user-123")
+
+ # Verify results
+ assert result == mock_account
+ assert mock_available_tenant.current is True
+ self._assert_database_operations_called(mock_db_dependencies["db"])
+
+ def test_load_user_no_tenants(self, mock_db_dependencies):
+ """Test user loading when user has no tenants at all."""
+ # Setup test data
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+
+ # Setup smart database query mock for no tenants scenario
+ query_results = {
+ ("Account", "id", "user-123"): mock_account,
+ ("TenantAccountJoin", "account_id", "user-123"): None, # No current tenant
+ ("TenantAccountJoin", "order_by", "first_available"): None, # No available tenants
+ }
+ ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
+
+ # Mock datetime
+ with patch("services.account_service.datetime") as mock_datetime:
+ mock_now = datetime.now()
+ mock_datetime.now.return_value = mock_now
+ mock_datetime.UTC = "UTC"
+
+ # Execute test
+ result = AccountService.load_user("user-123")
+
+ # Verify results
+ assert result is None
+
+
+class TestTenantService:
+ """
+ Comprehensive unit tests for TenantService methods.
+
+ This test suite covers all tenant-related operations including:
+ - Tenant creation and management
+ - Member management and permissions
+ - Tenant switching
+ - Role updates and permission checks
+ - Error conditions and edge cases
+ """
+
+ @pytest.fixture
+ def mock_db_dependencies(self):
+ """Common mock setup for database dependencies."""
+ with patch("services.account_service.db") as mock_db:
+ mock_db.session.add = MagicMock()
+ mock_db.session.commit = MagicMock()
+ yield {
+ "db": mock_db,
+ }
+
+ @pytest.fixture
+ def mock_rsa_dependencies(self):
+ """Mock setup for RSA-related functions."""
+ with patch("services.account_service.generate_key_pair") as mock_generate_key_pair:
+ yield mock_generate_key_pair
+
+ @pytest.fixture
+ def mock_external_service_dependencies(self):
+ """Mock setup for external service dependencies."""
+ with (
+ patch("services.account_service.FeatureService") as mock_feature_service,
+ patch("services.account_service.BillingService") as mock_billing_service,
+ ):
+ yield {
+ "feature_service": mock_feature_service,
+ "billing_service": mock_billing_service,
+ }
+
+ def _assert_database_operations_called(self, mock_db):
+ """Helper method to verify database operations were called."""
+ mock_db.session.commit.assert_called()
+
+ def _assert_exception_raised(self, exception_type, callable_func, *args, **kwargs):
+ """Helper method to verify that specific exception is raised."""
+ with pytest.raises(exception_type):
+ callable_func(*args, **kwargs)
+
+ # ==================== Tenant Creation Tests ====================
+
+ def test_create_owner_tenant_if_not_exist_new_user(
+ self, mock_db_dependencies, mock_rsa_dependencies, mock_external_service_dependencies
+ ):
+ """Test creating owner tenant for new user without existing tenants."""
+ # Setup test data
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+
+ # Setup smart database query mock - no existing tenant joins
+ query_results = {
+ ("TenantAccountJoin", "account_id", "user-123"): None,
+ ("TenantAccountJoin", "tenant_id", "tenant-456"): None, # For has_roles check
+ }
+ ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
+
+ # Setup external service mocks
+ mock_external_service_dependencies[
+ "feature_service"
+ ].get_system_features.return_value.is_allow_create_workspace = True
+ mock_external_service_dependencies[
+ "feature_service"
+ ].get_system_features.return_value.license.workspaces.is_available.return_value = True
+
+ # Mock tenant creation
+ mock_tenant = MagicMock()
+ mock_tenant.id = "tenant-456"
+ mock_tenant.name = "Test User's Workspace"
+
+ # Mock database operations
+ mock_db_dependencies["db"].session.add = MagicMock()
+
+ # Mock RSA key generation
+ mock_rsa_dependencies.return_value = "mock_public_key"
+
+ # Mock has_roles method to return False (no existing owner)
+ with patch("services.account_service.TenantService.has_roles") as mock_has_roles:
+ mock_has_roles.return_value = False
+
+ # Mock Tenant creation to set proper ID
+ with patch("services.account_service.Tenant") as mock_tenant_class:
+ mock_tenant_instance = MagicMock()
+ mock_tenant_instance.id = "tenant-456"
+ mock_tenant_instance.name = "Test User's Workspace"
+ mock_tenant_class.return_value = mock_tenant_instance
+
+ # Execute test
+ TenantService.create_owner_tenant_if_not_exist(mock_account)
+
+ # Verify tenant was created with correct parameters
+ mock_db_dependencies["db"].session.add.assert_called()
+
+ # Get all calls to session.add
+ add_calls = mock_db_dependencies["db"].session.add.call_args_list
+
+ # Should have at least 2 calls: one for Tenant, one for TenantAccountJoin
+ assert len(add_calls) >= 2
+
+ # Verify Tenant was added with correct name
+ tenant_added = False
+ tenant_account_join_added = False
+
+ for call in add_calls:
+ added_object = call[0][0] # First argument of the call
+
+ # Check if it's a Tenant object
+ if hasattr(added_object, "name") and hasattr(added_object, "id"):
+ # This should be a Tenant object
+ assert added_object.name == "Test User's Workspace"
+ tenant_added = True
+
+ # Check if it's a TenantAccountJoin object
+ elif (
+ hasattr(added_object, "tenant_id")
+ and hasattr(added_object, "account_id")
+ and hasattr(added_object, "role")
+ ):
+ # This should be a TenantAccountJoin object
+ assert added_object.tenant_id is not None
+ assert added_object.account_id == "user-123"
+ assert added_object.role == "owner"
+ tenant_account_join_added = True
+
+ assert tenant_added, "Tenant object was not added to database"
+ assert tenant_account_join_added, "TenantAccountJoin object was not added to database"
+
+ self._assert_database_operations_called(mock_db_dependencies["db"])
+ assert mock_rsa_dependencies.called, "RSA key generation was not called"
+
+ # ==================== Member Management Tests ====================
+
+ def test_create_tenant_member_success(self, mock_db_dependencies):
+ """Test successful tenant member creation."""
+ # Setup test data
+ mock_tenant = MagicMock()
+ mock_tenant.id = "tenant-456"
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+
+ # Setup smart database query mock - no existing member
+ query_results = {("TenantAccountJoin", "tenant_id", "tenant-456"): None}
+ ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
+
+ # Mock database operations
+ mock_db_dependencies["db"].session.add = MagicMock()
+
+ # Execute test
+ result = TenantService.create_tenant_member(mock_tenant, mock_account, "normal")
+
+ # Verify member was created with correct parameters
+ assert result is not None
+ mock_db_dependencies["db"].session.add.assert_called_once()
+
+ # Verify the TenantAccountJoin object was added with correct parameters
+ added_tenant_account_join = mock_db_dependencies["db"].session.add.call_args[0][0]
+ assert added_tenant_account_join.tenant_id == "tenant-456"
+ assert added_tenant_account_join.account_id == "user-123"
+ assert added_tenant_account_join.role == "normal"
+
+ self._assert_database_operations_called(mock_db_dependencies["db"])
+
+ # ==================== Tenant Switching Tests ====================
+
+ def test_switch_tenant_success(self):
+ """Test successful tenant switching."""
+ # Setup test data
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+ mock_tenant_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
+ tenant_id="tenant-456", account_id="user-123", current=False
+ )
+
+ # Mock the complex query in switch_tenant method
+ with patch("services.account_service.db") as mock_db:
+ # Mock the join query that returns the tenant_account_join
+ mock_query = MagicMock()
+ mock_filter = MagicMock()
+ mock_filter.first.return_value = mock_tenant_join
+ mock_query.filter.return_value = mock_filter
+ mock_query.join.return_value = mock_query
+ mock_db.session.query.return_value = mock_query
+
+ # Execute test
+ TenantService.switch_tenant(mock_account, "tenant-456")
+
+ # Verify tenant was switched
+ assert mock_tenant_join.current is True
+ self._assert_database_operations_called(mock_db)
+
+ def test_switch_tenant_no_tenant_id(self):
+ """Test tenant switching without providing tenant ID."""
+ # Setup test data
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+
+ # Execute test and verify exception
+ self._assert_exception_raised(ValueError, TenantService.switch_tenant, mock_account, None)
+
+ # ==================== Role Management Tests ====================
+
+ def test_update_member_role_success(self):
+ """Test successful member role update."""
+ # Setup test data
+ mock_tenant = MagicMock()
+ mock_tenant.id = "tenant-456"
+ mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
+ mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
+ mock_target_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
+ tenant_id="tenant-456", account_id="member-789", role="normal"
+ )
+ mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
+ tenant_id="tenant-456", account_id="operator-123", role="owner"
+ )
+
+ # Mock the database queries in update_member_role method
+ with patch("services.account_service.db") as mock_db:
+ # Mock the first query for operator permission check
+ mock_query1 = MagicMock()
+ mock_filter1 = MagicMock()
+ mock_filter1.first.return_value = mock_operator_join
+ mock_query1.filter_by.return_value = mock_filter1
+
+ # Mock the second query for target member
+ mock_query2 = MagicMock()
+ mock_filter2 = MagicMock()
+ mock_filter2.first.return_value = mock_target_join
+ mock_query2.filter_by.return_value = mock_filter2
+
+ # Make the query method return different mocks for different calls
+ mock_db.session.query.side_effect = [mock_query1, mock_query2]
+
+ # Execute test
+ TenantService.update_member_role(mock_tenant, mock_member, "admin", mock_operator)
+
+ # Verify role was updated
+ assert mock_target_join.role == "admin"
+ self._assert_database_operations_called(mock_db)
+
+ # ==================== Permission Check Tests ====================
+
+ def test_check_member_permission_success(self, mock_db_dependencies):
+ """Test successful member permission check."""
+ # Setup test data
+ mock_tenant = MagicMock()
+ mock_tenant.id = "tenant-456"
+ mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
+ mock_member = TestAccountAssociatedDataFactory.create_account_mock(account_id="member-789")
+ mock_operator_join = TestAccountAssociatedDataFactory.create_tenant_join_mock(
+ tenant_id="tenant-456", account_id="operator-123", role="owner"
+ )
+
+ # Setup smart database query mock
+ query_results = {("TenantAccountJoin", "tenant_id", "tenant-456"): mock_operator_join}
+ ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
+
+ # Execute test - should not raise exception
+ TenantService.check_member_permission(mock_tenant, mock_operator, mock_member, "add")
+
+ def test_check_member_permission_operate_self(self):
+ """Test member permission check when operator tries to operate self."""
+ # Setup test data
+ mock_tenant = MagicMock()
+ mock_tenant.id = "tenant-456"
+ mock_operator = TestAccountAssociatedDataFactory.create_account_mock(account_id="operator-123")
+
+ # Execute test and verify exception
+ from services.errors.account import CannotOperateSelfError
+
+ self._assert_exception_raised(
+ CannotOperateSelfError,
+ TenantService.check_member_permission,
+ mock_tenant,
+ mock_operator,
+ mock_operator, # Same as operator
+ "add",
+ )
+
+
+class TestRegisterService:
+ """
+ Comprehensive unit tests for RegisterService methods.
+
+ This test suite covers all registration-related operations including:
+ - System setup
+ - Account registration
+ - Member invitation
+ - Token management
+ - Invitation validation
+ - Error conditions and edge cases
+ """
+
+ @pytest.fixture
+ def mock_db_dependencies(self):
+ """Common mock setup for database dependencies."""
+ with patch("services.account_service.db") as mock_db:
+ mock_db.session.add = MagicMock()
+ mock_db.session.commit = MagicMock()
+ mock_db.session.begin_nested = MagicMock()
+ mock_db.session.rollback = MagicMock()
+ yield {
+ "db": mock_db,
+ }
+
+ @pytest.fixture
+ def mock_redis_dependencies(self):
+ """Mock setup for Redis-related functions."""
+ with patch("services.account_service.redis_client") as mock_redis:
+ yield mock_redis
+
+ @pytest.fixture
+ def mock_external_service_dependencies(self):
+ """Mock setup for external service dependencies."""
+ with (
+ patch("services.account_service.FeatureService") as mock_feature_service,
+ patch("services.account_service.BillingService") as mock_billing_service,
+ patch("services.account_service.PassportService") as mock_passport_service,
+ ):
+ yield {
+ "feature_service": mock_feature_service,
+ "billing_service": mock_billing_service,
+ "passport_service": mock_passport_service,
+ }
+
+ @pytest.fixture
+ def mock_task_dependencies(self):
+ """Mock setup for task dependencies."""
+ with patch("services.account_service.send_invite_member_mail_task") as mock_send_mail:
+ yield mock_send_mail
+
+ def _assert_database_operations_called(self, mock_db):
+ """Helper method to verify database operations were called."""
+ mock_db.session.commit.assert_called()
+
+ def _assert_database_operations_not_called(self, mock_db):
+ """Helper method to verify database operations were not called."""
+ mock_db.session.commit.assert_not_called()
+
+ def _assert_exception_raised(self, exception_type, callable_func, *args, **kwargs):
+ """Helper method to verify that specific exception is raised."""
+ with pytest.raises(exception_type):
+ callable_func(*args, **kwargs)
+
+ # ==================== Setup Tests ====================
+
+ def test_setup_success(self, mock_db_dependencies, mock_external_service_dependencies):
+ """Test successful system setup."""
+ # Setup mocks
+ mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
+ mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
+
+ # Mock AccountService.create_account
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+ with patch("services.account_service.AccountService.create_account") as mock_create_account:
+ mock_create_account.return_value = mock_account
+
+ # Mock TenantService.create_owner_tenant_if_not_exist
+ with patch("services.account_service.TenantService.create_owner_tenant_if_not_exist") as mock_create_tenant:
+ # Mock DifySetup
+ with patch("services.account_service.DifySetup") as mock_dify_setup:
+ mock_dify_setup_instance = MagicMock()
+ mock_dify_setup.return_value = mock_dify_setup_instance
+
+ # Execute test
+ RegisterService.setup("admin@example.com", "Admin User", "password123", "192.168.1.1")
+
+ # Verify results
+ mock_create_account.assert_called_once_with(
+ email="admin@example.com",
+ name="Admin User",
+ interface_language="en-US",
+ password="password123",
+ is_setup=True,
+ )
+ mock_create_tenant.assert_called_once_with(account=mock_account, is_setup=True)
+ mock_dify_setup.assert_called_once()
+ self._assert_database_operations_called(mock_db_dependencies["db"])
+
+ def test_setup_failure_rollback(self, mock_db_dependencies, mock_external_service_dependencies):
+ """Test setup failure with proper rollback."""
+ # Setup mocks to simulate failure
+ mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
+ mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
+
+ # Mock AccountService.create_account to raise exception
+ with patch("services.account_service.AccountService.create_account") as mock_create_account:
+ mock_create_account.side_effect = Exception("Database error")
+
+ # Execute test and verify exception
+ self._assert_exception_raised(
+ ValueError,
+ RegisterService.setup,
+ "admin@example.com",
+ "Admin User",
+ "password123",
+ "192.168.1.1",
+ )
+
+ # Verify rollback operations were called
+ mock_db_dependencies["db"].session.query.assert_called()
+
+ # ==================== Registration Tests ====================
+
+ def test_register_success(self, mock_db_dependencies, mock_external_service_dependencies):
+ """Test successful account registration."""
+ # Setup mocks
+ mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
+ mock_external_service_dependencies[
+ "feature_service"
+ ].get_system_features.return_value.is_allow_create_workspace = True
+ mock_external_service_dependencies[
+ "feature_service"
+ ].get_system_features.return_value.license.workspaces.is_available.return_value = True
+ mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
+
+ # Mock AccountService.create_account
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+ with patch("services.account_service.AccountService.create_account") as mock_create_account:
+ mock_create_account.return_value = mock_account
+
+ # Mock TenantService.create_tenant and create_tenant_member
+ with (
+ patch("services.account_service.TenantService.create_tenant") as mock_create_tenant,
+ patch("services.account_service.TenantService.create_tenant_member") as mock_create_member,
+ patch("services.account_service.tenant_was_created") as mock_event,
+ ):
+ mock_tenant = MagicMock()
+ mock_tenant.id = "tenant-456"
+ mock_create_tenant.return_value = mock_tenant
+
+ # Execute test
+ result = RegisterService.register(
+ email="test@example.com",
+ name="Test User",
+ password="password123",
+ language="en-US",
+ )
+
+ # Verify results
+ assert result == mock_account
+ assert result.status == "active"
+ assert result.initialized_at is not None
+ mock_create_account.assert_called_once_with(
+ email="test@example.com",
+ name="Test User",
+ interface_language="en-US",
+ password="password123",
+ is_setup=False,
+ )
+ mock_create_tenant.assert_called_once_with("Test User's Workspace")
+ mock_create_member.assert_called_once_with(mock_tenant, mock_account, role="owner")
+ mock_event.send.assert_called_once_with(mock_tenant)
+ self._assert_database_operations_called(mock_db_dependencies["db"])
+
+ def test_register_with_oauth(self, mock_db_dependencies, mock_external_service_dependencies):
+ """Test account registration with OAuth integration."""
+ # Setup mocks
+ mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
+ mock_external_service_dependencies[
+ "feature_service"
+ ].get_system_features.return_value.is_allow_create_workspace = True
+ mock_external_service_dependencies[
+ "feature_service"
+ ].get_system_features.return_value.license.workspaces.is_available.return_value = True
+ mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
+
+ # Mock AccountService.create_account and link_account_integrate
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+ with (
+ patch("services.account_service.AccountService.create_account") as mock_create_account,
+ patch("services.account_service.AccountService.link_account_integrate") as mock_link_account,
+ ):
+ mock_create_account.return_value = mock_account
+
+ # Mock TenantService methods
+ with (
+ patch("services.account_service.TenantService.create_tenant") as mock_create_tenant,
+ patch("services.account_service.TenantService.create_tenant_member") as mock_create_member,
+ patch("services.account_service.tenant_was_created") as mock_event,
+ ):
+ mock_tenant = MagicMock()
+ mock_create_tenant.return_value = mock_tenant
+
+ # Execute test
+ result = RegisterService.register(
+ email="test@example.com",
+ name="Test User",
+ password=None,
+ open_id="oauth123",
+ provider="google",
+ language="en-US",
+ )
+
+ # Verify results
+ assert result == mock_account
+ mock_link_account.assert_called_once_with("google", "oauth123", mock_account)
+ self._assert_database_operations_called(mock_db_dependencies["db"])
+
+ def test_register_with_pending_status(self, mock_db_dependencies, mock_external_service_dependencies):
+ """Test account registration with pending status."""
+ # Setup mocks
+ mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
+ mock_external_service_dependencies[
+ "feature_service"
+ ].get_system_features.return_value.is_allow_create_workspace = True
+ mock_external_service_dependencies[
+ "feature_service"
+ ].get_system_features.return_value.license.workspaces.is_available.return_value = True
+ mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
+
+ # Mock AccountService.create_account
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+ with patch("services.account_service.AccountService.create_account") as mock_create_account:
+ mock_create_account.return_value = mock_account
+
+ # Mock TenantService methods
+ with (
+ patch("services.account_service.TenantService.create_tenant") as mock_create_tenant,
+ patch("services.account_service.TenantService.create_tenant_member") as mock_create_member,
+ patch("services.account_service.tenant_was_created") as mock_event,
+ ):
+ mock_tenant = MagicMock()
+ mock_create_tenant.return_value = mock_tenant
+
+ # Execute test with pending status
+ from models.account import AccountStatus
+
+ result = RegisterService.register(
+ email="test@example.com",
+ name="Test User",
+ password="password123",
+ language="en-US",
+ status=AccountStatus.PENDING,
+ )
+
+ # Verify results
+ assert result == mock_account
+ assert result.status == "pending"
+ self._assert_database_operations_called(mock_db_dependencies["db"])
+
+ def test_register_workspace_not_allowed(self, mock_db_dependencies, mock_external_service_dependencies):
+ """Test registration when workspace creation is not allowed."""
+ # Setup mocks
+ mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
+ mock_external_service_dependencies[
+ "feature_service"
+ ].get_system_features.return_value.is_allow_create_workspace = True
+ mock_external_service_dependencies[
+ "feature_service"
+ ].get_system_features.return_value.license.workspaces.is_available.return_value = True
+ mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
+
+ # Mock AccountService.create_account
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock()
+ with patch("services.account_service.AccountService.create_account") as mock_create_account:
+ mock_create_account.return_value = mock_account
+
+ # Execute test and verify exception
+ from services.errors.workspace import WorkSpaceNotAllowedCreateError
+
+ with patch("services.account_service.TenantService.create_tenant") as mock_create_tenant:
+ mock_create_tenant.side_effect = WorkSpaceNotAllowedCreateError()
+
+ self._assert_exception_raised(
+ AccountRegisterError,
+ RegisterService.register,
+ email="test@example.com",
+ name="Test User",
+ password="password123",
+ language="en-US",
+ )
+
+ # Verify rollback was called
+ mock_db_dependencies["db"].session.rollback.assert_called()
+
+ def test_register_general_exception(self, mock_db_dependencies, mock_external_service_dependencies):
+ """Test registration with general exception handling."""
+ # Setup mocks
+ mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
+ mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
+
+ # Mock AccountService.create_account to raise exception
+ with patch("services.account_service.AccountService.create_account") as mock_create_account:
+ mock_create_account.side_effect = Exception("Unexpected error")
+
+ # Execute test and verify exception
+ self._assert_exception_raised(
+ AccountRegisterError,
+ RegisterService.register,
+ email="test@example.com",
+ name="Test User",
+ password="password123",
+ language="en-US",
+ )
+
+ # Verify rollback was called
+ mock_db_dependencies["db"].session.rollback.assert_called()
+
+ # ==================== Member Invitation Tests ====================
+
+ def test_invite_new_member_new_account(self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies):
+ """Test inviting a new member who doesn't have an account."""
+ # Setup test data
+ mock_tenant = MagicMock()
+ mock_tenant.id = "tenant-456"
+ mock_tenant.name = "Test Workspace"
+ mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-123", name="Inviter")
+
+ # Mock database queries - need to mock the Session query
+ mock_session = MagicMock()
+ mock_session.query.return_value.filter_by.return_value.first.return_value = None # No existing account
+
+ with patch("services.account_service.Session") as mock_session_class:
+ mock_session_class.return_value.__enter__.return_value = mock_session
+ mock_session_class.return_value.__exit__.return_value = None
+
+ # Mock RegisterService.register
+ mock_new_account = TestAccountAssociatedDataFactory.create_account_mock(
+ account_id="new-user-456", email="newuser@example.com", name="newuser", status="pending"
+ )
+ with patch("services.account_service.RegisterService.register") as mock_register:
+ mock_register.return_value = mock_new_account
+
+ # Mock TenantService methods
+ with (
+ patch("services.account_service.TenantService.check_member_permission") as mock_check_permission,
+ patch("services.account_service.TenantService.create_tenant_member") as mock_create_member,
+ patch("services.account_service.TenantService.switch_tenant") as mock_switch_tenant,
+ patch("services.account_service.RegisterService.generate_invite_token") as mock_generate_token,
+ ):
+ mock_generate_token.return_value = "invite-token-123"
+
+ # Execute test
+ result = RegisterService.invite_new_member(
+ tenant=mock_tenant,
+ email="newuser@example.com",
+ language="en-US",
+ role="normal",
+ inviter=mock_inviter,
+ )
+
+ # Verify results
+ assert result == "invite-token-123"
+ mock_register.assert_called_once_with(
+ email="newuser@example.com",
+ name="newuser",
+ language="en-US",
+ status="pending",
+ is_setup=True,
+ )
+ mock_create_member.assert_called_once_with(mock_tenant, mock_new_account, "normal")
+ mock_switch_tenant.assert_called_once_with(mock_new_account, mock_tenant.id)
+ mock_generate_token.assert_called_once_with(mock_tenant, mock_new_account)
+ mock_task_dependencies.delay.assert_called_once()
+
+ def test_invite_new_member_existing_account(
+ self, mock_db_dependencies, mock_redis_dependencies, mock_task_dependencies
+ ):
+ """Test inviting a new member who already has an account."""
+ # Setup test data
+ mock_tenant = MagicMock()
+ mock_tenant.id = "tenant-456"
+ mock_tenant.name = "Test Workspace"
+ mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-123", name="Inviter")
+ mock_existing_account = TestAccountAssociatedDataFactory.create_account_mock(
+ account_id="existing-user-456", email="existing@example.com", status="pending"
+ )
+
+ # Mock database queries - need to mock the Session query
+ mock_session = MagicMock()
+ mock_session.query.return_value.filter_by.return_value.first.return_value = mock_existing_account
+
+ with patch("services.account_service.Session") as mock_session_class:
+ mock_session_class.return_value.__enter__.return_value = mock_session
+ mock_session_class.return_value.__exit__.return_value = None
+
+ # Mock the db.session.query for TenantAccountJoin
+ mock_db_query = MagicMock()
+ mock_db_query.filter_by.return_value.first.return_value = None # No existing member
+ mock_db_dependencies["db"].session.query.return_value = mock_db_query
+
+ # Mock TenantService methods
+ with (
+ patch("services.account_service.TenantService.check_member_permission") as mock_check_permission,
+ patch("services.account_service.TenantService.create_tenant_member") as mock_create_member,
+ patch("services.account_service.RegisterService.generate_invite_token") as mock_generate_token,
+ ):
+ mock_generate_token.return_value = "invite-token-123"
+
+ # Execute test
+ result = RegisterService.invite_new_member(
+ tenant=mock_tenant,
+ email="existing@example.com",
+ language="en-US",
+ role="normal",
+ inviter=mock_inviter,
+ )
+
+ # Verify results
+ assert result == "invite-token-123"
+ mock_create_member.assert_called_once_with(mock_tenant, mock_existing_account, "normal")
+ mock_generate_token.assert_called_once_with(mock_tenant, mock_existing_account)
+ mock_task_dependencies.delay.assert_called_once()
+
+ def test_invite_new_member_already_in_tenant(self, mock_db_dependencies, mock_redis_dependencies):
+ """Test inviting a member who is already in the tenant."""
+ # Setup test data
+ mock_tenant = MagicMock()
+ mock_tenant.id = "tenant-456"
+ mock_inviter = TestAccountAssociatedDataFactory.create_account_mock(account_id="inviter-123", name="Inviter")
+ mock_existing_account = TestAccountAssociatedDataFactory.create_account_mock(
+ account_id="existing-user-456", email="existing@example.com", status="active"
+ )
+
+ # Mock database queries
+ query_results = {
+ ("Account", "email", "existing@example.com"): mock_existing_account,
+ (
+ "TenantAccountJoin",
+ "tenant_id",
+ "tenant-456",
+ ): TestAccountAssociatedDataFactory.create_tenant_join_mock(),
+ }
+ ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
+
+ # Mock TenantService methods
+ with patch("services.account_service.TenantService.check_member_permission") as mock_check_permission:
+ # Execute test and verify exception
+ self._assert_exception_raised(
+ AccountAlreadyInTenantError,
+ RegisterService.invite_new_member,
+ tenant=mock_tenant,
+ email="existing@example.com",
+ language="en-US",
+ role="normal",
+ inviter=mock_inviter,
+ )
+
+ def test_invite_new_member_no_inviter(self):
+ """Test inviting a member without providing an inviter."""
+ # Setup test data
+ mock_tenant = MagicMock()
+
+ # Execute test and verify exception
+ self._assert_exception_raised(
+ ValueError,
+ RegisterService.invite_new_member,
+ tenant=mock_tenant,
+ email="test@example.com",
+ language="en-US",
+ role="normal",
+ inviter=None,
+ )
+
+ # ==================== Token Management Tests ====================
+
+ def test_generate_invite_token_success(self, mock_redis_dependencies):
+ """Test successful invite token generation."""
+ # Setup test data
+ mock_tenant = MagicMock()
+ mock_tenant.id = "tenant-456"
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock(
+ account_id="user-123", email="test@example.com"
+ )
+
+ # Mock uuid generation
+ with patch("services.account_service.uuid.uuid4") as mock_uuid:
+ mock_uuid.return_value = "test-uuid-123"
+
+ # Execute test
+ result = RegisterService.generate_invite_token(mock_tenant, mock_account)
+
+ # Verify results
+ assert result == "test-uuid-123"
+ mock_redis_dependencies.setex.assert_called_once()
+
+ # Verify the stored data
+ call_args = mock_redis_dependencies.setex.call_args
+ assert call_args[0][0] == "member_invite:token:test-uuid-123"
+ stored_data = json.loads(call_args[0][2])
+ assert stored_data["account_id"] == "user-123"
+ assert stored_data["email"] == "test@example.com"
+ assert stored_data["workspace_id"] == "tenant-456"
+
+ def test_is_valid_invite_token_valid(self, mock_redis_dependencies):
+ """Test checking valid invite token."""
+ # Setup mock
+ mock_redis_dependencies.get.return_value = b'{"test": "data"}'
+
+ # Execute test
+ result = RegisterService.is_valid_invite_token("valid-token")
+
+ # Verify results
+ assert result is True
+ mock_redis_dependencies.get.assert_called_once_with("member_invite:token:valid-token")
+
+ def test_is_valid_invite_token_invalid(self, mock_redis_dependencies):
+ """Test checking invalid invite token."""
+ # Setup mock
+ mock_redis_dependencies.get.return_value = None
+
+ # Execute test
+ result = RegisterService.is_valid_invite_token("invalid-token")
+
+ # Verify results
+ assert result is False
+ mock_redis_dependencies.get.assert_called_once_with("member_invite:token:invalid-token")
+
+ def test_revoke_token_with_workspace_and_email(self, mock_redis_dependencies):
+ """Test revoking token with workspace ID and email."""
+ # Execute test
+ RegisterService.revoke_token("workspace-123", "test@example.com", "token-123")
+
+ # Verify results
+ mock_redis_dependencies.delete.assert_called_once()
+ call_args = mock_redis_dependencies.delete.call_args
+ assert "workspace-123" in call_args[0][0]
+ # The email is hashed, so we check for the hash pattern instead
+ assert "member_invite_token:" in call_args[0][0]
+
+ def test_revoke_token_without_workspace_and_email(self, mock_redis_dependencies):
+ """Test revoking token without workspace ID and email."""
+ # Execute test
+ RegisterService.revoke_token("", "", "token-123")
+
+ # Verify results
+ mock_redis_dependencies.delete.assert_called_once_with("member_invite:token:token-123")
+
+ # ==================== Invitation Validation Tests ====================
+
+ def test_get_invitation_if_token_valid_success(self, mock_db_dependencies, mock_redis_dependencies):
+ """Test successful invitation validation."""
+ # Setup test data
+ mock_tenant = MagicMock()
+ mock_tenant.id = "tenant-456"
+ mock_tenant.status = "normal"
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock(
+ account_id="user-123", email="test@example.com"
+ )
+
+ with patch("services.account_service.RegisterService._get_invitation_by_token") as mock_get_invitation_by_token:
+ # Mock the invitation data returned by _get_invitation_by_token
+ invitation_data = {
+ "account_id": "user-123",
+ "email": "test@example.com",
+ "workspace_id": "tenant-456",
+ }
+ mock_get_invitation_by_token.return_value = invitation_data
+
+ # Mock database queries - complex query mocking
+ mock_query1 = MagicMock()
+ mock_query1.filter.return_value.first.return_value = mock_tenant
+
+ mock_query2 = MagicMock()
+ mock_query2.join.return_value.filter.return_value.first.return_value = (mock_account, "normal")
+
+ mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
+
+ # Execute test
+ result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
+
+ # Verify results
+ assert result is not None
+ assert result["account"] == mock_account
+ assert result["tenant"] == mock_tenant
+ assert result["data"] == invitation_data
+
+ def test_get_invitation_if_token_valid_no_token_data(self, mock_redis_dependencies):
+ """Test invitation validation with no token data."""
+ # Setup mock
+ mock_redis_dependencies.get.return_value = None
+
+ # Execute test
+ result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
+
+ # Verify results
+ assert result is None
+
+ def test_get_invitation_if_token_valid_tenant_not_found(self, mock_db_dependencies, mock_redis_dependencies):
+ """Test invitation validation when tenant is not found."""
+ # Setup mock Redis data
+ invitation_data = {
+ "account_id": "user-123",
+ "email": "test@example.com",
+ "workspace_id": "tenant-456",
+ }
+ mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
+
+ # Mock database queries - no tenant found
+ mock_query = MagicMock()
+ mock_query.filter.return_value.first.return_value = None
+ mock_db_dependencies["db"].session.query.return_value = mock_query
+
+ # Execute test
+ result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
+
+ # Verify results
+ assert result is None
+
+ def test_get_invitation_if_token_valid_account_not_found(self, mock_db_dependencies, mock_redis_dependencies):
+ """Test invitation validation when account is not found."""
+ # Setup test data
+ mock_tenant = MagicMock()
+ mock_tenant.id = "tenant-456"
+ mock_tenant.status = "normal"
+
+ # Mock Redis data
+ invitation_data = {
+ "account_id": "user-123",
+ "email": "test@example.com",
+ "workspace_id": "tenant-456",
+ }
+ mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
+
+ # Mock database queries
+ mock_query1 = MagicMock()
+ mock_query1.filter.return_value.first.return_value = mock_tenant
+
+ mock_query2 = MagicMock()
+ mock_query2.join.return_value.filter.return_value.first.return_value = None # No account found
+
+ mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
+
+ # Execute test
+ result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
+
+ # Verify results
+ assert result is None
+
+ def test_get_invitation_if_token_valid_account_id_mismatch(self, mock_db_dependencies, mock_redis_dependencies):
+ """Test invitation validation when account ID doesn't match."""
+ # Setup test data
+ mock_tenant = MagicMock()
+ mock_tenant.id = "tenant-456"
+ mock_tenant.status = "normal"
+ mock_account = TestAccountAssociatedDataFactory.create_account_mock(
+ account_id="different-user-456", email="test@example.com"
+ )
+
+ # Mock Redis data with different account ID
+ invitation_data = {
+ "account_id": "user-123",
+ "email": "test@example.com",
+ "workspace_id": "tenant-456",
+ }
+ mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
+
+ # Mock database queries
+ mock_query1 = MagicMock()
+ mock_query1.filter.return_value.first.return_value = mock_tenant
+
+ mock_query2 = MagicMock()
+ mock_query2.join.return_value.filter.return_value.first.return_value = (mock_account, "normal")
+
+ mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
+
+ # Execute test
+ result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
+
+ # Verify results
+ assert result is None
+
+ # ==================== Helper Method Tests ====================
+
+ def test_get_invitation_token_key(self):
+ """Test the _get_invitation_token_key helper method."""
+ # Execute test
+ result = RegisterService._get_invitation_token_key("test-token")
+
+ # Verify results
+ assert result == "member_invite:token:test-token"
+
+ def test_get_invitation_by_token_with_workspace_and_email(self, mock_redis_dependencies):
+ """Test _get_invitation_by_token with workspace ID and email."""
+ # Setup mock
+ mock_redis_dependencies.get.return_value = b"user-123"
+
+ # Execute test
+ result = RegisterService._get_invitation_by_token("token-123", "workspace-456", "test@example.com")
+
+ # Verify results
+ assert result is not None
+ assert result["account_id"] == "user-123"
+ assert result["email"] == "test@example.com"
+ assert result["workspace_id"] == "workspace-456"
+
+ def test_get_invitation_by_token_without_workspace_and_email(self, mock_redis_dependencies):
+ """Test _get_invitation_by_token without workspace ID and email."""
+ # Setup mock
+ invitation_data = {
+ "account_id": "user-123",
+ "email": "test@example.com",
+ "workspace_id": "tenant-456",
+ }
+ mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
+
+ # Execute test
+ result = RegisterService._get_invitation_by_token("token-123")
+
+ # Verify results
+ assert result is not None
+ assert result == invitation_data
+
+ def test_get_invitation_by_token_no_data(self, mock_redis_dependencies):
+ """Test _get_invitation_by_token with no data."""
+ # Setup mock
+ mock_redis_dependencies.get.return_value = None
+
+ # Execute test
+ result = RegisterService._get_invitation_by_token("token-123")
+
+ # Verify results
+ assert result is None
diff --git a/api/tests/unit_tests/services/test_dataset_permission.py b/api/tests/unit_tests/services/test_dataset_permission.py
index 066f541c1b..a67252e856 100644
--- a/api/tests/unit_tests/services/test_dataset_permission.py
+++ b/api/tests/unit_tests/services/test_dataset_permission.py
@@ -8,151 +8,298 @@ from services.dataset_service import DatasetService
from services.errors.account import NoPermissionError
+class DatasetPermissionTestDataFactory:
+ """Factory class for creating test data and mock objects for dataset permission tests."""
+
+ @staticmethod
+ def create_dataset_mock(
+ dataset_id: str = "dataset-123",
+ tenant_id: str = "test-tenant-123",
+ created_by: str = "creator-456",
+ permission: DatasetPermissionEnum = DatasetPermissionEnum.ONLY_ME,
+ **kwargs,
+ ) -> Mock:
+ """Create a mock dataset with specified attributes."""
+ dataset = Mock(spec=Dataset)
+ dataset.id = dataset_id
+ dataset.tenant_id = tenant_id
+ dataset.created_by = created_by
+ dataset.permission = permission
+ for key, value in kwargs.items():
+ setattr(dataset, key, value)
+ return dataset
+
+ @staticmethod
+ def create_user_mock(
+ user_id: str = "user-789",
+ tenant_id: str = "test-tenant-123",
+ role: TenantAccountRole = TenantAccountRole.NORMAL,
+ **kwargs,
+ ) -> Mock:
+ """Create a mock user with specified attributes."""
+ user = Mock(spec=Account)
+ user.id = user_id
+ user.current_tenant_id = tenant_id
+ user.current_role = role
+ for key, value in kwargs.items():
+ setattr(user, key, value)
+ return user
+
+ @staticmethod
+ def create_dataset_permission_mock(
+ dataset_id: str = "dataset-123",
+ account_id: str = "user-789",
+ **kwargs,
+ ) -> Mock:
+ """Create a mock dataset permission record."""
+ permission = Mock(spec=DatasetPermission)
+ permission.dataset_id = dataset_id
+ permission.account_id = account_id
+ for key, value in kwargs.items():
+ setattr(permission, key, value)
+ return permission
+
+
class TestDatasetPermissionService:
- """Test cases for dataset permission checking functionality"""
-
- def setup_method(self):
- """Set up test fixtures"""
- # Mock tenant and user
- self.tenant_id = "test-tenant-123"
- self.creator_id = "creator-456"
- self.normal_user_id = "normal-789"
- self.owner_user_id = "owner-999"
-
- # Mock dataset
- self.dataset = Mock(spec=Dataset)
- self.dataset.id = "dataset-123"
- self.dataset.tenant_id = self.tenant_id
- self.dataset.created_by = self.creator_id
-
- # Mock users
- self.creator_user = Mock(spec=Account)
- self.creator_user.id = self.creator_id
- self.creator_user.current_tenant_id = self.tenant_id
- self.creator_user.current_role = TenantAccountRole.EDITOR
-
- self.normal_user = Mock(spec=Account)
- self.normal_user.id = self.normal_user_id
- self.normal_user.current_tenant_id = self.tenant_id
- self.normal_user.current_role = TenantAccountRole.NORMAL
-
- self.owner_user = Mock(spec=Account)
- self.owner_user.id = self.owner_user_id
- self.owner_user.current_tenant_id = self.tenant_id
- self.owner_user.current_role = TenantAccountRole.OWNER
+ """
+ Comprehensive unit tests for DatasetService.check_dataset_permission method.
+
+ This test suite covers all permission scenarios including:
+ - Cross-tenant access restrictions
+ - Owner privilege checks
+ - Different permission levels (ONLY_ME, ALL_TEAM, PARTIAL_TEAM)
+ - Explicit permission checks for PARTIAL_TEAM
+ - Error conditions and logging
+ """
+
+ @pytest.fixture
+ def mock_dataset_service_dependencies(self):
+ """Common mock setup for dataset service dependencies."""
+ with patch("services.dataset_service.db.session") as mock_session:
+ yield {
+ "db_session": mock_session,
+ }
+
+ @pytest.fixture
+ def mock_logging_dependencies(self):
+ """Mock setup for logging tests."""
+ with patch("services.dataset_service.logging") as mock_logging:
+ yield {
+ "logging": mock_logging,
+ }
+
+ def _assert_permission_check_passes(self, dataset: Mock, user: Mock):
+ """Helper method to verify that permission check passes without raising exceptions."""
+ # Should not raise any exception
+ DatasetService.check_dataset_permission(dataset, user)
+
+ def _assert_permission_check_fails(
+ self, dataset: Mock, user: Mock, expected_message: str = "You do not have permission to access this dataset."
+ ):
+ """Helper method to verify that permission check fails with expected error."""
+ with pytest.raises(NoPermissionError, match=expected_message):
+ DatasetService.check_dataset_permission(dataset, user)
+
+ def _assert_database_query_called(self, mock_session: Mock, dataset_id: str, account_id: str):
+ """Helper method to verify database query calls for permission checks."""
+ mock_session.query().filter_by.assert_called_with(dataset_id=dataset_id, account_id=account_id)
+
+ def _assert_database_query_not_called(self, mock_session: Mock):
+ """Helper method to verify that database query was not called."""
+ mock_session.query.assert_not_called()
+
+ # ==================== Cross-Tenant Access Tests ====================
def test_permission_check_different_tenant_should_fail(self):
- """Test that users from different tenants cannot access dataset"""
- self.normal_user.current_tenant_id = "different-tenant"
+ """Test that users from different tenants cannot access dataset regardless of other permissions."""
+ # Create dataset and user from different tenants
+ dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
+ tenant_id="tenant-123", permission=DatasetPermissionEnum.ALL_TEAM
+ )
+ user = DatasetPermissionTestDataFactory.create_user_mock(
+ user_id="user-789", tenant_id="different-tenant-456", role=TenantAccountRole.EDITOR
+ )
+
+ # Should fail due to different tenant
+ self._assert_permission_check_fails(dataset, user)
- with pytest.raises(NoPermissionError, match="You do not have permission to access this dataset."):
- DatasetService.check_dataset_permission(self.dataset, self.normal_user)
+ # ==================== Owner Privilege Tests ====================
def test_owner_can_access_any_dataset(self):
- """Test that tenant owners can access any dataset regardless of permission"""
- self.dataset.permission = DatasetPermissionEnum.ONLY_ME
+ """Test that tenant owners can access any dataset regardless of permission level."""
+ # Create dataset with restrictive permission
+ dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.ONLY_ME)
- # Should not raise any exception
- DatasetService.check_dataset_permission(self.dataset, self.owner_user)
+ # Create owner user
+ owner_user = DatasetPermissionTestDataFactory.create_user_mock(
+ user_id="owner-999", role=TenantAccountRole.OWNER
+ )
+
+ # Owner should have access regardless of dataset permission
+ self._assert_permission_check_passes(dataset, owner_user)
+
+ # ==================== ONLY_ME Permission Tests ====================
def test_only_me_permission_creator_can_access(self):
- """Test ONLY_ME permission allows only creator to access"""
- self.dataset.permission = DatasetPermissionEnum.ONLY_ME
+ """Test ONLY_ME permission allows only the dataset creator to access."""
+ # Create dataset with ONLY_ME permission
+ dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
+ created_by="creator-456", permission=DatasetPermissionEnum.ONLY_ME
+ )
+
+ # Create creator user
+ creator_user = DatasetPermissionTestDataFactory.create_user_mock(
+ user_id="creator-456", role=TenantAccountRole.EDITOR
+ )
# Creator should be able to access
- DatasetService.check_dataset_permission(self.dataset, self.creator_user)
+ self._assert_permission_check_passes(dataset, creator_user)
def test_only_me_permission_others_cannot_access(self):
- """Test ONLY_ME permission denies access to non-creators"""
- self.dataset.permission = DatasetPermissionEnum.ONLY_ME
+ """Test ONLY_ME permission denies access to non-creators."""
+ # Create dataset with ONLY_ME permission
+ dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
+ created_by="creator-456", permission=DatasetPermissionEnum.ONLY_ME
+ )
+
+ # Create normal user (not the creator)
+ normal_user = DatasetPermissionTestDataFactory.create_user_mock(
+ user_id="normal-789", role=TenantAccountRole.NORMAL
+ )
- with pytest.raises(NoPermissionError, match="You do not have permission to access this dataset."):
- DatasetService.check_dataset_permission(self.dataset, self.normal_user)
+ # Non-creator should be denied access
+ self._assert_permission_check_fails(dataset, normal_user)
+
+ # ==================== ALL_TEAM Permission Tests ====================
def test_all_team_permission_allows_access(self):
- """Test ALL_TEAM permission allows any team member to access"""
- self.dataset.permission = DatasetPermissionEnum.ALL_TEAM
+ """Test ALL_TEAM permission allows any team member to access the dataset."""
+ # Create dataset with ALL_TEAM permission
+ dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.ALL_TEAM)
+
+ # Create different types of team members
+ normal_user = DatasetPermissionTestDataFactory.create_user_mock(
+ user_id="normal-789", role=TenantAccountRole.NORMAL
+ )
+ editor_user = DatasetPermissionTestDataFactory.create_user_mock(
+ user_id="editor-456", role=TenantAccountRole.EDITOR
+ )
+
+ # All team members should have access
+ self._assert_permission_check_passes(dataset, normal_user)
+ self._assert_permission_check_passes(dataset, editor_user)
- # Should not raise any exception for team members
- DatasetService.check_dataset_permission(self.dataset, self.normal_user)
- DatasetService.check_dataset_permission(self.dataset, self.creator_user)
+ # ==================== PARTIAL_TEAM Permission Tests ====================
- @patch("services.dataset_service.db.session")
- def test_partial_team_permission_creator_can_access(self, mock_session):
- """Test PARTIAL_TEAM permission allows creator to access"""
- self.dataset.permission = DatasetPermissionEnum.PARTIAL_TEAM
+ def test_partial_team_permission_creator_can_access(self, mock_dataset_service_dependencies):
+ """Test PARTIAL_TEAM permission allows creator to access without database query."""
+ # Create dataset with PARTIAL_TEAM permission
+ dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
+ created_by="creator-456", permission=DatasetPermissionEnum.PARTIAL_TEAM
+ )
- # Should not raise any exception for creator
- DatasetService.check_dataset_permission(self.dataset, self.creator_user)
+ # Create creator user
+ creator_user = DatasetPermissionTestDataFactory.create_user_mock(
+ user_id="creator-456", role=TenantAccountRole.EDITOR
+ )
- # Should not query database for creator
- mock_session.query.assert_not_called()
+ # Creator should have access without database query
+ self._assert_permission_check_passes(dataset, creator_user)
+ self._assert_database_query_not_called(mock_dataset_service_dependencies["db_session"])
- @patch("services.dataset_service.db.session")
- def test_partial_team_permission_with_explicit_permission(self, mock_session):
- """Test PARTIAL_TEAM permission allows users with explicit permission"""
- self.dataset.permission = DatasetPermissionEnum.PARTIAL_TEAM
+ def test_partial_team_permission_with_explicit_permission(self, mock_dataset_service_dependencies):
+ """Test PARTIAL_TEAM permission allows users with explicit permission records."""
+ # Create dataset with PARTIAL_TEAM permission
+ dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.PARTIAL_TEAM)
+
+ # Create normal user (not the creator)
+ normal_user = DatasetPermissionTestDataFactory.create_user_mock(
+ user_id="normal-789", role=TenantAccountRole.NORMAL
+ )
# Mock database query to return a permission record
- mock_permission = Mock(spec=DatasetPermission)
- mock_session.query().filter_by().first.return_value = mock_permission
+ mock_permission = DatasetPermissionTestDataFactory.create_dataset_permission_mock(
+ dataset_id=dataset.id, account_id=normal_user.id
+ )
+ mock_dataset_service_dependencies["db_session"].query().filter_by().first.return_value = mock_permission
- # Should not raise any exception
- DatasetService.check_dataset_permission(self.dataset, self.normal_user)
+ # User with explicit permission should have access
+ self._assert_permission_check_passes(dataset, normal_user)
+ self._assert_database_query_called(mock_dataset_service_dependencies["db_session"], dataset.id, normal_user.id)
- # Verify database was queried correctly
- mock_session.query().filter_by.assert_called_with(dataset_id=self.dataset.id, account_id=self.normal_user.id)
+ def test_partial_team_permission_without_explicit_permission(self, mock_dataset_service_dependencies):
+ """Test PARTIAL_TEAM permission denies users without explicit permission records."""
+ # Create dataset with PARTIAL_TEAM permission
+ dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.PARTIAL_TEAM)
- @patch("services.dataset_service.db.session")
- def test_partial_team_permission_without_explicit_permission(self, mock_session):
- """Test PARTIAL_TEAM permission denies users without explicit permission"""
- self.dataset.permission = DatasetPermissionEnum.PARTIAL_TEAM
+ # Create normal user (not the creator)
+ normal_user = DatasetPermissionTestDataFactory.create_user_mock(
+ user_id="normal-789", role=TenantAccountRole.NORMAL
+ )
# Mock database query to return None (no permission record)
- mock_session.query().filter_by().first.return_value = None
-
- with pytest.raises(NoPermissionError, match="You do not have permission to access this dataset."):
- DatasetService.check_dataset_permission(self.dataset, self.normal_user)
+ mock_dataset_service_dependencies["db_session"].query().filter_by().first.return_value = None
- # Verify database was queried correctly
- mock_session.query().filter_by.assert_called_with(dataset_id=self.dataset.id, account_id=self.normal_user.id)
+ # User without explicit permission should be denied access
+ self._assert_permission_check_fails(dataset, normal_user)
+ self._assert_database_query_called(mock_dataset_service_dependencies["db_session"], dataset.id, normal_user.id)
- @patch("services.dataset_service.db.session")
- def test_partial_team_permission_non_creator_without_permission_fails(self, mock_session):
- """Test that non-creators without explicit permission are denied access"""
- self.dataset.permission = DatasetPermissionEnum.PARTIAL_TEAM
+ def test_partial_team_permission_non_creator_without_permission_fails(self, mock_dataset_service_dependencies):
+ """Test that non-creators without explicit permission are denied access to PARTIAL_TEAM datasets."""
+ # Create dataset with PARTIAL_TEAM permission
+ dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
+ created_by="creator-456", permission=DatasetPermissionEnum.PARTIAL_TEAM
+ )
# Create a different user (not the creator)
- other_user = Mock(spec=Account)
- other_user.id = "other-user-123"
- other_user.current_tenant_id = self.tenant_id
- other_user.current_role = TenantAccountRole.NORMAL
+ other_user = DatasetPermissionTestDataFactory.create_user_mock(
+ user_id="other-user-123", role=TenantAccountRole.NORMAL
+ )
# Mock database query to return None (no permission record)
- mock_session.query().filter_by().first.return_value = None
+ mock_dataset_service_dependencies["db_session"].query().filter_by().first.return_value = None
+
+ # Non-creator without explicit permission should be denied access
+ self._assert_permission_check_fails(dataset, other_user)
+ self._assert_database_query_called(mock_dataset_service_dependencies["db_session"], dataset.id, other_user.id)
- with pytest.raises(NoPermissionError, match="You do not have permission to access this dataset."):
- DatasetService.check_dataset_permission(self.dataset, other_user)
+ # ==================== Enum Usage Tests ====================
def test_partial_team_permission_uses_correct_enum(self):
- """Test that the method correctly uses DatasetPermissionEnum.PARTIAL_TEAM"""
- # This test ensures we're using the enum instead of string literals
- self.dataset.permission = DatasetPermissionEnum.PARTIAL_TEAM
+ """Test that the method correctly uses DatasetPermissionEnum.PARTIAL_TEAM instead of string literals."""
+ # Create dataset with PARTIAL_TEAM permission using enum
+ dataset = DatasetPermissionTestDataFactory.create_dataset_mock(
+ created_by="creator-456", permission=DatasetPermissionEnum.PARTIAL_TEAM
+ )
+
+ # Create creator user
+ creator_user = DatasetPermissionTestDataFactory.create_user_mock(
+ user_id="creator-456", role=TenantAccountRole.EDITOR
+ )
- # Creator should always have access
- DatasetService.check_dataset_permission(self.dataset, self.creator_user)
+ # Creator should always have access regardless of permission level
+ self._assert_permission_check_passes(dataset, creator_user)
- @patch("services.dataset_service.logging")
- @patch("services.dataset_service.db.session")
- def test_permission_denied_logs_debug_message(self, mock_session, mock_logging):
- """Test that permission denied events are logged"""
- self.dataset.permission = DatasetPermissionEnum.PARTIAL_TEAM
- mock_session.query().filter_by().first.return_value = None
+ # ==================== Logging Tests ====================
+
+ def test_permission_denied_logs_debug_message(self, mock_dataset_service_dependencies, mock_logging_dependencies):
+ """Test that permission denied events are properly logged for debugging purposes."""
+ # Create dataset with PARTIAL_TEAM permission
+ dataset = DatasetPermissionTestDataFactory.create_dataset_mock(permission=DatasetPermissionEnum.PARTIAL_TEAM)
+
+ # Create normal user (not the creator)
+ normal_user = DatasetPermissionTestDataFactory.create_user_mock(
+ user_id="normal-789", role=TenantAccountRole.NORMAL
+ )
+
+ # Mock database query to return None (no permission record)
+ mock_dataset_service_dependencies["db_session"].query().filter_by().first.return_value = None
+ # Attempt permission check (should fail)
with pytest.raises(NoPermissionError):
- DatasetService.check_dataset_permission(self.dataset, self.normal_user)
+ DatasetService.check_dataset_permission(dataset, normal_user)
- # Verify debug message was logged
- mock_logging.debug.assert_called_with(
- f"User {self.normal_user.id} does not have permission to access dataset {self.dataset.id}"
+ # Verify debug message was logged with correct user and dataset information
+ mock_logging_dependencies["logging"].debug.assert_called_with(
+ f"User {normal_user.id} does not have permission to access dataset {dataset.id}"
)
diff --git a/api/tests/unit_tests/services/test_dataset_service_batch_update_document_status.py b/api/tests/unit_tests/services/test_dataset_service_batch_update_document_status.py
new file mode 100644
index 0000000000..dc09aca5b2
--- /dev/null
+++ b/api/tests/unit_tests/services/test_dataset_service_batch_update_document_status.py
@@ -0,0 +1,804 @@
+import datetime
+from typing import Optional
+
+# Mock redis_client before importing dataset_service
+from unittest.mock import Mock, call, patch
+
+import pytest
+
+from models.dataset import Dataset, Document
+from services.dataset_service import DocumentService
+from services.errors.document import DocumentIndexingError
+from tests.unit_tests.conftest import redis_mock
+
+
+class DocumentBatchUpdateTestDataFactory:
+ """Factory class for creating test data and mock objects for document batch update tests."""
+
+ @staticmethod
+ def create_dataset_mock(dataset_id: str = "dataset-123", tenant_id: str = "tenant-456") -> Mock:
+ """Create a mock dataset with specified attributes."""
+ dataset = Mock(spec=Dataset)
+ dataset.id = dataset_id
+ dataset.tenant_id = tenant_id
+ return dataset
+
+ @staticmethod
+ def create_user_mock(user_id: str = "user-789") -> Mock:
+ """Create a mock user."""
+ user = Mock()
+ user.id = user_id
+ return user
+
+ @staticmethod
+ def create_document_mock(
+ document_id: str = "doc-1",
+ name: str = "test_document.pdf",
+ enabled: bool = True,
+ archived: bool = False,
+ indexing_status: str = "completed",
+ completed_at: Optional[datetime.datetime] = None,
+ **kwargs,
+ ) -> Mock:
+ """Create a mock document with specified attributes."""
+ document = Mock(spec=Document)
+ document.id = document_id
+ document.name = name
+ document.enabled = enabled
+ document.archived = archived
+ document.indexing_status = indexing_status
+ document.completed_at = completed_at or datetime.datetime.now()
+
+ # Set default values for optional fields
+ document.disabled_at = None
+ document.disabled_by = None
+ document.archived_at = None
+ document.archived_by = None
+ document.updated_at = None
+
+ for key, value in kwargs.items():
+ setattr(document, key, value)
+ return document
+
+ @staticmethod
+ def create_multiple_documents(
+ document_ids: list[str], enabled: bool = True, archived: bool = False, indexing_status: str = "completed"
+ ) -> list[Mock]:
+ """Create multiple mock documents with specified attributes."""
+ documents = []
+ for doc_id in document_ids:
+ doc = DocumentBatchUpdateTestDataFactory.create_document_mock(
+ document_id=doc_id,
+ name=f"document_{doc_id}.pdf",
+ enabled=enabled,
+ archived=archived,
+ indexing_status=indexing_status,
+ )
+ documents.append(doc)
+ return documents
+
+
+class TestDatasetServiceBatchUpdateDocumentStatus:
+ """
+ Comprehensive unit tests for DocumentService.batch_update_document_status method.
+
+ This test suite covers all supported actions (enable, disable, archive, un_archive),
+ error conditions, edge cases, and validates proper interaction with Redis cache,
+ database operations, and async task triggers.
+ """
+
+ @pytest.fixture
+ def mock_document_service_dependencies(self):
+ """Common mock setup for document service dependencies."""
+ with (
+ patch("services.dataset_service.DocumentService.get_document") as mock_get_doc,
+ patch("extensions.ext_database.db.session") as mock_db,
+ patch("services.dataset_service.datetime") as mock_datetime,
+ ):
+ current_time = datetime.datetime(2023, 1, 1, 12, 0, 0)
+ mock_datetime.datetime.now.return_value = current_time
+ mock_datetime.UTC = datetime.UTC
+
+ yield {
+ "get_document": mock_get_doc,
+ "db_session": mock_db,
+ "datetime": mock_datetime,
+ "current_time": current_time,
+ }
+
+ @pytest.fixture
+ def mock_async_task_dependencies(self):
+ """Mock setup for async task dependencies."""
+ with (
+ patch("services.dataset_service.add_document_to_index_task") as mock_add_task,
+ patch("services.dataset_service.remove_document_from_index_task") as mock_remove_task,
+ ):
+ yield {"add_task": mock_add_task, "remove_task": mock_remove_task}
+
+ def _assert_document_enabled(self, document: Mock, user_id: str, current_time: datetime.datetime):
+ """Helper method to verify document was enabled correctly."""
+ assert document.enabled == True
+ assert document.disabled_at is None
+ assert document.disabled_by is None
+ assert document.updated_at == current_time.replace(tzinfo=None)
+
+ def _assert_document_disabled(self, document: Mock, user_id: str, current_time: datetime.datetime):
+ """Helper method to verify document was disabled correctly."""
+ assert document.enabled == False
+ assert document.disabled_at == current_time.replace(tzinfo=None)
+ assert document.disabled_by == user_id
+ assert document.updated_at == current_time.replace(tzinfo=None)
+
+ def _assert_document_archived(self, document: Mock, user_id: str, current_time: datetime.datetime):
+ """Helper method to verify document was archived correctly."""
+ assert document.archived == True
+ assert document.archived_at == current_time.replace(tzinfo=None)
+ assert document.archived_by == user_id
+ assert document.updated_at == current_time.replace(tzinfo=None)
+
+ def _assert_document_unarchived(self, document: Mock):
+ """Helper method to verify document was unarchived correctly."""
+ assert document.archived == False
+ assert document.archived_at is None
+ assert document.archived_by is None
+
+ def _assert_redis_cache_operations(self, document_ids: list[str], action: str = "setex"):
+ """Helper method to verify Redis cache operations."""
+ if action == "setex":
+ expected_calls = [call(f"document_{doc_id}_indexing", 600, 1) for doc_id in document_ids]
+ redis_mock.setex.assert_has_calls(expected_calls)
+ elif action == "get":
+ expected_calls = [call(f"document_{doc_id}_indexing") for doc_id in document_ids]
+ redis_mock.get.assert_has_calls(expected_calls)
+
+ def _assert_async_task_calls(self, mock_task, document_ids: list[str], task_type: str):
+ """Helper method to verify async task calls."""
+ expected_calls = [call(doc_id) for doc_id in document_ids]
+ if task_type in {"add", "remove"}:
+ mock_task.delay.assert_has_calls(expected_calls)
+
+ # ==================== Enable Document Tests ====================
+
+ def test_batch_update_enable_documents_success(
+ self, mock_document_service_dependencies, mock_async_task_dependencies
+ ):
+ """Test successful enabling of disabled documents."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Create disabled documents
+ disabled_docs = DocumentBatchUpdateTestDataFactory.create_multiple_documents(["doc-1", "doc-2"], enabled=False)
+ mock_document_service_dependencies["get_document"].side_effect = disabled_docs
+
+ # Reset module-level Redis mock
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = None
+
+ # Call the method to enable documents
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=["doc-1", "doc-2"], action="enable", user=user
+ )
+
+ # Verify document attributes were updated correctly
+ for doc in disabled_docs:
+ self._assert_document_enabled(doc, user.id, mock_document_service_dependencies["current_time"])
+
+ # Verify Redis cache operations
+ self._assert_redis_cache_operations(["doc-1", "doc-2"], "get")
+ self._assert_redis_cache_operations(["doc-1", "doc-2"], "setex")
+
+ # Verify async tasks were triggered for indexing
+ self._assert_async_task_calls(mock_async_task_dependencies["add_task"], ["doc-1", "doc-2"], "add")
+
+ # Verify database operations
+ mock_db = mock_document_service_dependencies["db_session"]
+ assert mock_db.add.call_count == 2
+ assert mock_db.commit.call_count == 1
+
+ def test_batch_update_enable_already_enabled_document_skipped(self, mock_document_service_dependencies):
+ """Test enabling documents that are already enabled."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Create already enabled document
+ enabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True)
+ mock_document_service_dependencies["get_document"].return_value = enabled_doc
+
+ # Reset module-level Redis mock
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = None
+
+ # Attempt to enable already enabled document
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=["doc-1"], action="enable", user=user
+ )
+
+ # Verify no database operations occurred (document was skipped)
+ mock_db = mock_document_service_dependencies["db_session"]
+ mock_db.commit.assert_not_called()
+
+ # Verify no Redis setex operations occurred (document was skipped)
+ redis_mock.setex.assert_not_called()
+
+ # ==================== Disable Document Tests ====================
+
+ def test_batch_update_disable_documents_success(
+ self, mock_document_service_dependencies, mock_async_task_dependencies
+ ):
+ """Test successful disabling of enabled and completed documents."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Create enabled documents
+ enabled_docs = DocumentBatchUpdateTestDataFactory.create_multiple_documents(["doc-1", "doc-2"], enabled=True)
+ mock_document_service_dependencies["get_document"].side_effect = enabled_docs
+
+ # Reset module-level Redis mock
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = None
+
+ # Call the method to disable documents
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=["doc-1", "doc-2"], action="disable", user=user
+ )
+
+ # Verify document attributes were updated correctly
+ for doc in enabled_docs:
+ self._assert_document_disabled(doc, user.id, mock_document_service_dependencies["current_time"])
+
+ # Verify Redis cache operations for indexing prevention
+ self._assert_redis_cache_operations(["doc-1", "doc-2"], "setex")
+
+ # Verify async tasks were triggered to remove from index
+ self._assert_async_task_calls(mock_async_task_dependencies["remove_task"], ["doc-1", "doc-2"], "remove")
+
+ # Verify database operations
+ mock_db = mock_document_service_dependencies["db_session"]
+ assert mock_db.add.call_count == 2
+ assert mock_db.commit.call_count == 1
+
+ def test_batch_update_disable_already_disabled_document_skipped(
+ self, mock_document_service_dependencies, mock_async_task_dependencies
+ ):
+ """Test disabling documents that are already disabled."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Create already disabled document
+ disabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=False)
+ mock_document_service_dependencies["get_document"].return_value = disabled_doc
+
+ # Reset module-level Redis mock
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = None
+
+ # Attempt to disable already disabled document
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=["doc-1"], action="disable", user=user
+ )
+
+ # Verify no database operations occurred (document was skipped)
+ mock_db = mock_document_service_dependencies["db_session"]
+ mock_db.commit.assert_not_called()
+
+ # Verify no Redis setex operations occurred (document was skipped)
+ redis_mock.setex.assert_not_called()
+
+ # Verify no async tasks were triggered (document was skipped)
+ mock_async_task_dependencies["add_task"].delay.assert_not_called()
+
+ def test_batch_update_disable_non_completed_document_error(self, mock_document_service_dependencies):
+ """Test that DocumentIndexingError is raised when trying to disable non-completed documents."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Create a document that's not completed
+ non_completed_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(
+ enabled=True,
+ indexing_status="indexing", # Not completed
+ completed_at=None, # Not completed
+ )
+ mock_document_service_dependencies["get_document"].return_value = non_completed_doc
+
+ # Verify that DocumentIndexingError is raised
+ with pytest.raises(DocumentIndexingError) as exc_info:
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=["doc-1"], action="disable", user=user
+ )
+
+ # Verify error message indicates document is not completed
+ assert "is not completed" in str(exc_info.value)
+
+ # ==================== Archive Document Tests ====================
+
+ def test_batch_update_archive_documents_success(
+ self, mock_document_service_dependencies, mock_async_task_dependencies
+ ):
+ """Test successful archiving of unarchived documents."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Create unarchived enabled document
+ unarchived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True, archived=False)
+ mock_document_service_dependencies["get_document"].return_value = unarchived_doc
+
+ # Reset module-level Redis mock
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = None
+
+ # Call the method to archive documents
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=["doc-1"], action="archive", user=user
+ )
+
+ # Verify document attributes were updated correctly
+ self._assert_document_archived(unarchived_doc, user.id, mock_document_service_dependencies["current_time"])
+
+ # Verify Redis cache was set (because document was enabled)
+ redis_mock.setex.assert_called_once_with("document_doc-1_indexing", 600, 1)
+
+ # Verify async task was triggered to remove from index (because enabled)
+ mock_async_task_dependencies["remove_task"].delay.assert_called_once_with("doc-1")
+
+ # Verify database operations
+ mock_db = mock_document_service_dependencies["db_session"]
+ mock_db.add.assert_called_once()
+ mock_db.commit.assert_called_once()
+
+ def test_batch_update_archive_already_archived_document_skipped(self, mock_document_service_dependencies):
+ """Test archiving documents that are already archived."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Create already archived document
+ archived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True, archived=True)
+ mock_document_service_dependencies["get_document"].return_value = archived_doc
+
+ # Reset module-level Redis mock
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = None
+
+ # Attempt to archive already archived document
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=["doc-3"], action="archive", user=user
+ )
+
+ # Verify no database operations occurred (document was skipped)
+ mock_db = mock_document_service_dependencies["db_session"]
+ mock_db.commit.assert_not_called()
+
+ # Verify no Redis setex operations occurred (document was skipped)
+ redis_mock.setex.assert_not_called()
+
+ def test_batch_update_archive_disabled_document_no_index_removal(
+ self, mock_document_service_dependencies, mock_async_task_dependencies
+ ):
+ """Test archiving disabled documents (should not trigger index removal)."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Set up disabled, unarchived document
+ disabled_unarchived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=False, archived=False)
+ mock_document_service_dependencies["get_document"].return_value = disabled_unarchived_doc
+
+ # Reset module-level Redis mock
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = None
+
+ # Archive the disabled document
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=["doc-1"], action="archive", user=user
+ )
+
+ # Verify document was archived
+ self._assert_document_archived(
+ disabled_unarchived_doc, user.id, mock_document_service_dependencies["current_time"]
+ )
+
+ # Verify no Redis cache was set (document is disabled)
+ redis_mock.setex.assert_not_called()
+
+ # Verify no index removal task was triggered (document is disabled)
+ mock_async_task_dependencies["remove_task"].delay.assert_not_called()
+
+ # Verify database operations still occurred
+ mock_db = mock_document_service_dependencies["db_session"]
+ mock_db.add.assert_called_once()
+ mock_db.commit.assert_called_once()
+
+ # ==================== Unarchive Document Tests ====================
+
+ def test_batch_update_unarchive_documents_success(
+ self, mock_document_service_dependencies, mock_async_task_dependencies
+ ):
+ """Test successful unarchiving of archived documents."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Create mock archived document
+ archived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True, archived=True)
+ mock_document_service_dependencies["get_document"].return_value = archived_doc
+
+ # Reset module-level Redis mock
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = None
+
+ # Call the method to unarchive documents
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=["doc-1"], action="un_archive", user=user
+ )
+
+ # Verify document attributes were updated correctly
+ self._assert_document_unarchived(archived_doc)
+ assert archived_doc.updated_at == mock_document_service_dependencies["current_time"].replace(tzinfo=None)
+
+ # Verify Redis cache was set (because document is enabled)
+ redis_mock.setex.assert_called_once_with("document_doc-1_indexing", 600, 1)
+
+ # Verify async task was triggered to add back to index (because enabled)
+ mock_async_task_dependencies["add_task"].delay.assert_called_once_with("doc-1")
+
+ # Verify database operations
+ mock_db = mock_document_service_dependencies["db_session"]
+ mock_db.add.assert_called_once()
+ mock_db.commit.assert_called_once()
+
+ def test_batch_update_unarchive_already_unarchived_document_skipped(
+ self, mock_document_service_dependencies, mock_async_task_dependencies
+ ):
+ """Test unarchiving documents that are already unarchived."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Create already unarchived document
+ unarchived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True, archived=False)
+ mock_document_service_dependencies["get_document"].return_value = unarchived_doc
+
+ # Reset module-level Redis mock
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = None
+
+ # Attempt to unarchive already unarchived document
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=["doc-1"], action="un_archive", user=user
+ )
+
+ # Verify no database operations occurred (document was skipped)
+ mock_db = mock_document_service_dependencies["db_session"]
+ mock_db.commit.assert_not_called()
+
+ # Verify no Redis setex operations occurred (document was skipped)
+ redis_mock.setex.assert_not_called()
+
+ # Verify no async tasks were triggered (document was skipped)
+ mock_async_task_dependencies["add_task"].delay.assert_not_called()
+
+ def test_batch_update_unarchive_disabled_document_no_index_addition(
+ self, mock_document_service_dependencies, mock_async_task_dependencies
+ ):
+ """Test unarchiving disabled documents (should not trigger index addition)."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Create mock archived but disabled document
+ archived_disabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=False, archived=True)
+ mock_document_service_dependencies["get_document"].return_value = archived_disabled_doc
+
+ # Reset module-level Redis mock
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = None
+
+ # Unarchive the disabled document
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=["doc-1"], action="un_archive", user=user
+ )
+
+ # Verify document was unarchived
+ self._assert_document_unarchived(archived_disabled_doc)
+ assert archived_disabled_doc.updated_at == mock_document_service_dependencies["current_time"].replace(
+ tzinfo=None
+ )
+
+ # Verify no Redis cache was set (document is disabled)
+ redis_mock.setex.assert_not_called()
+
+ # Verify no index addition task was triggered (document is disabled)
+ mock_async_task_dependencies["add_task"].delay.assert_not_called()
+
+ # Verify database operations still occurred
+ mock_db = mock_document_service_dependencies["db_session"]
+ mock_db.add.assert_called_once()
+ mock_db.commit.assert_called_once()
+
+ # ==================== Error Handling Tests ====================
+
+ def test_batch_update_document_indexing_error_redis_cache_hit(self, mock_document_service_dependencies):
+ """Test that DocumentIndexingError is raised when documents are currently being indexed."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Create mock enabled document
+ enabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True)
+ mock_document_service_dependencies["get_document"].return_value = enabled_doc
+
+ # Set up mock to indicate document is being indexed
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = "indexing"
+
+ # Verify that DocumentIndexingError is raised
+ with pytest.raises(DocumentIndexingError) as exc_info:
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=["doc-1"], action="enable", user=user
+ )
+
+ # Verify error message contains document name
+ assert "test_document.pdf" in str(exc_info.value)
+ assert "is being indexed" in str(exc_info.value)
+
+ # Verify Redis cache was checked
+ redis_mock.get.assert_called_once_with("document_doc-1_indexing")
+
+ def test_batch_update_invalid_action_error(self, mock_document_service_dependencies):
+ """Test that ValueError is raised when an invalid action is provided."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Create mock document
+ doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=True)
+ mock_document_service_dependencies["get_document"].return_value = doc
+
+ # Reset module-level Redis mock
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = None
+
+ # Test with invalid action
+ invalid_action = "invalid_action"
+ with pytest.raises(ValueError) as exc_info:
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=["doc-1"], action=invalid_action, user=user
+ )
+
+ # Verify error message contains the invalid action
+ assert invalid_action in str(exc_info.value)
+ assert "Invalid action" in str(exc_info.value)
+
+ # Verify no Redis operations occurred
+ redis_mock.setex.assert_not_called()
+
+ def test_batch_update_async_task_error_handling(
+ self, mock_document_service_dependencies, mock_async_task_dependencies
+ ):
+ """Test handling of async task errors during batch operations."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Create mock disabled document
+ disabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock(enabled=False)
+ mock_document_service_dependencies["get_document"].return_value = disabled_doc
+
+ # Mock async task to raise an exception
+ mock_async_task_dependencies["add_task"].delay.side_effect = Exception("Celery task error")
+
+ # Reset module-level Redis mock
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = None
+
+ # Verify that async task error is propagated
+ with pytest.raises(Exception) as exc_info:
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=["doc-1"], action="enable", user=user
+ )
+
+ # Verify error message
+ assert "Celery task error" in str(exc_info.value)
+
+ # Verify database operations completed successfully
+ mock_db = mock_document_service_dependencies["db_session"]
+ mock_db.add.assert_called_once()
+ mock_db.commit.assert_called_once()
+
+ # Verify Redis cache was set successfully
+ redis_mock.setex.assert_called_once_with("document_doc-1_indexing", 600, 1)
+
+ # Verify document was updated
+ self._assert_document_enabled(disabled_doc, user.id, mock_document_service_dependencies["current_time"])
+
+ # ==================== Edge Case Tests ====================
+
+ def test_batch_update_empty_document_list(self, mock_document_service_dependencies):
+ """Test batch operations with an empty document ID list."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Call method with empty document list
+ result = DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=[], action="enable", user=user
+ )
+
+ # Verify no document lookups were performed
+ mock_document_service_dependencies["get_document"].assert_not_called()
+
+ # Verify method returns None (early return)
+ assert result is None
+
+ def test_batch_update_document_not_found_skipped(self, mock_document_service_dependencies):
+ """Test behavior when some documents don't exist in the database."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Mock document service to return None (document not found)
+ mock_document_service_dependencies["get_document"].return_value = None
+
+ # Call method with non-existent document ID
+ # This should not raise an error, just skip the missing document
+ try:
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=["non-existent-doc"], action="enable", user=user
+ )
+ except Exception as e:
+ pytest.fail(f"Method should not raise exception for missing documents: {e}")
+
+ # Verify document lookup was attempted
+ mock_document_service_dependencies["get_document"].assert_called_once_with(dataset.id, "non-existent-doc")
+
+ def test_batch_update_mixed_document_states_and_actions(
+ self, mock_document_service_dependencies, mock_async_task_dependencies
+ ):
+ """Test batch operations on documents with mixed states and various scenarios."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Create documents in various states
+ disabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock("doc-1", enabled=False)
+ enabled_doc = DocumentBatchUpdateTestDataFactory.create_document_mock("doc-2", enabled=True)
+ archived_doc = DocumentBatchUpdateTestDataFactory.create_document_mock("doc-3", enabled=True, archived=True)
+
+ # Mix of different document states
+ documents = [disabled_doc, enabled_doc, archived_doc]
+ mock_document_service_dependencies["get_document"].side_effect = documents
+
+ # Reset module-level Redis mock
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = None
+
+ # Perform enable operation on mixed state documents
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=["doc-1", "doc-2", "doc-3"], action="enable", user=user
+ )
+
+ # Verify only the disabled document was processed
+ # (enabled and archived documents should be skipped for enable action)
+
+ # Only one add should occur (for the disabled document that was enabled)
+ mock_db = mock_document_service_dependencies["db_session"]
+ mock_db.add.assert_called_once()
+ # Only one commit should occur
+ mock_db.commit.assert_called_once()
+
+ # Only one Redis setex should occur (for the document that was enabled)
+ redis_mock.setex.assert_called_once_with("document_doc-1_indexing", 600, 1)
+
+ # Only one async task should be triggered (for the document that was enabled)
+ mock_async_task_dependencies["add_task"].delay.assert_called_once_with("doc-1")
+
+ # ==================== Performance Tests ====================
+
+ def test_batch_update_large_document_list_performance(
+ self, mock_document_service_dependencies, mock_async_task_dependencies
+ ):
+ """Test batch operations with a large number of documents."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Create large list of document IDs
+ document_ids = [f"doc-{i}" for i in range(1, 101)] # 100 documents
+
+ # Create mock documents
+ mock_documents = DocumentBatchUpdateTestDataFactory.create_multiple_documents(
+ document_ids,
+ enabled=False, # All disabled, will be enabled
+ )
+ mock_document_service_dependencies["get_document"].side_effect = mock_documents
+
+ # Reset module-level Redis mock
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = None
+
+ # Perform batch enable operation
+ DocumentService.batch_update_document_status(
+ dataset=dataset, document_ids=document_ids, action="enable", user=user
+ )
+
+ # Verify all documents were processed
+ assert mock_document_service_dependencies["get_document"].call_count == 100
+
+ # Verify all documents were updated
+ for mock_doc in mock_documents:
+ self._assert_document_enabled(mock_doc, user.id, mock_document_service_dependencies["current_time"])
+
+ # Verify database operations
+ mock_db = mock_document_service_dependencies["db_session"]
+ assert mock_db.add.call_count == 100
+ assert mock_db.commit.call_count == 1
+
+ # Verify Redis cache operations occurred for each document
+ assert redis_mock.setex.call_count == 100
+
+ # Verify async tasks were triggered for each document
+ assert mock_async_task_dependencies["add_task"].delay.call_count == 100
+
+ # Verify correct Redis cache keys were set
+ expected_redis_calls = [call(f"document_doc-{i}_indexing", 600, 1) for i in range(1, 101)]
+ redis_mock.setex.assert_has_calls(expected_redis_calls)
+
+ # Verify correct async task calls
+ expected_task_calls = [call(f"doc-{i}") for i in range(1, 101)]
+ mock_async_task_dependencies["add_task"].delay.assert_has_calls(expected_task_calls)
+
+ def test_batch_update_mixed_document_states_complex_scenario(
+ self, mock_document_service_dependencies, mock_async_task_dependencies
+ ):
+ """Test complex batch operations with documents in various states."""
+ dataset = DocumentBatchUpdateTestDataFactory.create_dataset_mock()
+ user = DocumentBatchUpdateTestDataFactory.create_user_mock()
+
+ # Create documents in various states
+ doc1 = DocumentBatchUpdateTestDataFactory.create_document_mock("doc-1", enabled=False) # Will be enabled
+ doc2 = DocumentBatchUpdateTestDataFactory.create_document_mock(
+ "doc-2", enabled=True
+ ) # Already enabled, will be skipped
+ doc3 = DocumentBatchUpdateTestDataFactory.create_document_mock(
+ "doc-3", enabled=True
+ ) # Already enabled, will be skipped
+ doc4 = DocumentBatchUpdateTestDataFactory.create_document_mock(
+ "doc-4", enabled=True
+ ) # Not affected by enable action
+ doc5 = DocumentBatchUpdateTestDataFactory.create_document_mock(
+ "doc-5", enabled=True, archived=True
+ ) # Not affected by enable action
+ doc6 = None # Non-existent, will be skipped
+
+ mock_document_service_dependencies["get_document"].side_effect = [doc1, doc2, doc3, doc4, doc5, doc6]
+
+ # Reset module-level Redis mock
+ redis_mock.reset_mock()
+ redis_mock.get.return_value = None
+
+ # Perform mixed batch operations
+ DocumentService.batch_update_document_status(
+ dataset=dataset,
+ document_ids=["doc-1", "doc-2", "doc-3", "doc-4", "doc-5", "doc-6"],
+ action="enable", # This will only affect doc1
+ user=user,
+ )
+
+ # Verify document 1 was enabled
+ self._assert_document_enabled(doc1, user.id, mock_document_service_dependencies["current_time"])
+
+ # Verify other documents were skipped appropriately
+ assert doc2.enabled == True # No change
+ assert doc3.enabled == True # No change
+ assert doc4.enabled == True # No change
+ assert doc5.enabled == True # No change
+
+ # Verify database commits occurred for processed documents
+ # Only doc1 should be added (others were skipped, doc6 doesn't exist)
+ mock_db = mock_document_service_dependencies["db_session"]
+ assert mock_db.add.call_count == 1
+ assert mock_db.commit.call_count == 1
+
+ # Verify Redis cache operations occurred for processed documents
+ # Only doc1 should have Redis operations
+ assert redis_mock.setex.call_count == 1
+
+ # Verify async tasks were triggered for processed documents
+ # Only doc1 should trigger tasks
+ assert mock_async_task_dependencies["add_task"].delay.call_count == 1
+
+ # Verify correct Redis cache keys were set
+ expected_redis_calls = [call("document_doc-1_indexing", 600, 1)]
+ redis_mock.setex.assert_has_calls(expected_redis_calls)
+
+ # Verify correct async task calls
+ expected_task_calls = [call("doc-1")]
+ mock_async_task_dependencies["add_task"].delay.assert_has_calls(expected_task_calls)
diff --git a/api/tests/unit_tests/services/test_dataset_service_update_dataset.py b/api/tests/unit_tests/services/test_dataset_service_update_dataset.py
new file mode 100644
index 0000000000..87b46f213b
--- /dev/null
+++ b/api/tests/unit_tests/services/test_dataset_service_update_dataset.py
@@ -0,0 +1,632 @@
+import datetime
+from typing import Any, Optional
+
+# Mock redis_client before importing dataset_service
+from unittest.mock import Mock, patch
+
+import pytest
+
+from core.model_runtime.entities.model_entities import ModelType
+from models.dataset import Dataset, ExternalKnowledgeBindings
+from services.dataset_service import DatasetService
+from services.errors.account import NoPermissionError
+
+
+class DatasetUpdateTestDataFactory:
+ """Factory class for creating test data and mock objects for dataset update tests."""
+
+ @staticmethod
+ def create_dataset_mock(
+ dataset_id: str = "dataset-123",
+ provider: str = "vendor",
+ name: str = "old_name",
+ description: str = "old_description",
+ indexing_technique: str = "high_quality",
+ retrieval_model: str = "old_model",
+ embedding_model_provider: Optional[str] = None,
+ embedding_model: Optional[str] = None,
+ collection_binding_id: Optional[str] = None,
+ **kwargs,
+ ) -> Mock:
+ """Create a mock dataset with specified attributes."""
+ dataset = Mock(spec=Dataset)
+ dataset.id = dataset_id
+ dataset.provider = provider
+ dataset.name = name
+ dataset.description = description
+ dataset.indexing_technique = indexing_technique
+ dataset.retrieval_model = retrieval_model
+ dataset.embedding_model_provider = embedding_model_provider
+ dataset.embedding_model = embedding_model
+ dataset.collection_binding_id = collection_binding_id
+ for key, value in kwargs.items():
+ setattr(dataset, key, value)
+ return dataset
+
+ @staticmethod
+ def create_user_mock(user_id: str = "user-789") -> Mock:
+ """Create a mock user."""
+ user = Mock()
+ user.id = user_id
+ return user
+
+ @staticmethod
+ def create_external_binding_mock(
+ external_knowledge_id: str = "old_knowledge_id", external_knowledge_api_id: str = "old_api_id"
+ ) -> Mock:
+ """Create a mock external knowledge binding."""
+ binding = Mock(spec=ExternalKnowledgeBindings)
+ binding.external_knowledge_id = external_knowledge_id
+ binding.external_knowledge_api_id = external_knowledge_api_id
+ return binding
+
+ @staticmethod
+ def create_embedding_model_mock(model: str = "text-embedding-ada-002", provider: str = "openai") -> Mock:
+ """Create a mock embedding model."""
+ embedding_model = Mock()
+ embedding_model.model = model
+ embedding_model.provider = provider
+ return embedding_model
+
+ @staticmethod
+ def create_collection_binding_mock(binding_id: str = "binding-456") -> Mock:
+ """Create a mock collection binding."""
+ binding = Mock()
+ binding.id = binding_id
+ return binding
+
+ @staticmethod
+ def create_current_user_mock(tenant_id: str = "tenant-123") -> Mock:
+ """Create a mock current user."""
+ current_user = Mock()
+ current_user.current_tenant_id = tenant_id
+ return current_user
+
+
+class TestDatasetServiceUpdateDataset:
+ """
+ Comprehensive unit tests for DatasetService.update_dataset method.
+
+ This test suite covers all supported scenarios including:
+ - External dataset updates
+ - Internal dataset updates with different indexing techniques
+ - Embedding model updates
+ - Permission checks
+ - Error conditions and edge cases
+ """
+
+ @pytest.fixture
+ def mock_dataset_service_dependencies(self):
+ """Common mock setup for dataset service dependencies."""
+ with (
+ patch("services.dataset_service.DatasetService.get_dataset") as mock_get_dataset,
+ patch("services.dataset_service.DatasetService.check_dataset_permission") as mock_check_perm,
+ patch("extensions.ext_database.db.session") as mock_db,
+ patch("services.dataset_service.datetime") as mock_datetime,
+ ):
+ current_time = datetime.datetime(2023, 1, 1, 12, 0, 0)
+ mock_datetime.datetime.now.return_value = current_time
+ mock_datetime.UTC = datetime.UTC
+
+ yield {
+ "get_dataset": mock_get_dataset,
+ "check_permission": mock_check_perm,
+ "db_session": mock_db,
+ "datetime": mock_datetime,
+ "current_time": current_time,
+ }
+
+ @pytest.fixture
+ def mock_external_provider_dependencies(self):
+ """Mock setup for external provider tests."""
+ with patch("services.dataset_service.Session") as mock_session:
+ from extensions.ext_database import db
+
+ with patch.object(db.__class__, "engine", new_callable=Mock):
+ session_mock = Mock()
+ mock_session.return_value.__enter__.return_value = session_mock
+ yield session_mock
+
+ @pytest.fixture
+ def mock_internal_provider_dependencies(self):
+ """Mock setup for internal provider tests."""
+ with (
+ patch("services.dataset_service.ModelManager") as mock_model_manager,
+ patch(
+ "services.dataset_service.DatasetCollectionBindingService.get_dataset_collection_binding"
+ ) as mock_get_binding,
+ patch("services.dataset_service.deal_dataset_vector_index_task") as mock_task,
+ patch("services.dataset_service.current_user") as mock_current_user,
+ ):
+ mock_current_user.current_tenant_id = "tenant-123"
+ yield {
+ "model_manager": mock_model_manager,
+ "get_binding": mock_get_binding,
+ "task": mock_task,
+ "current_user": mock_current_user,
+ }
+
+ def _assert_database_update_called(self, mock_db, dataset_id: str, expected_updates: dict[str, Any]):
+ """Helper method to verify database update calls."""
+ mock_db.query.return_value.filter_by.return_value.update.assert_called_once_with(expected_updates)
+ mock_db.commit.assert_called_once()
+
+ def _assert_external_dataset_update(self, mock_dataset, mock_binding, update_data: dict[str, Any]):
+ """Helper method to verify external dataset updates."""
+ assert mock_dataset.name == update_data.get("name", mock_dataset.name)
+ assert mock_dataset.description == update_data.get("description", mock_dataset.description)
+ assert mock_dataset.retrieval_model == update_data.get("external_retrieval_model", mock_dataset.retrieval_model)
+
+ if "external_knowledge_id" in update_data:
+ assert mock_binding.external_knowledge_id == update_data["external_knowledge_id"]
+ if "external_knowledge_api_id" in update_data:
+ assert mock_binding.external_knowledge_api_id == update_data["external_knowledge_api_id"]
+
+ # ==================== External Dataset Tests ====================
+
+ def test_update_external_dataset_success(
+ self, mock_dataset_service_dependencies, mock_external_provider_dependencies
+ ):
+ """Test successful update of external dataset."""
+ dataset = DatasetUpdateTestDataFactory.create_dataset_mock(
+ provider="external", name="old_name", description="old_description", retrieval_model="old_model"
+ )
+ mock_dataset_service_dependencies["get_dataset"].return_value = dataset
+
+ user = DatasetUpdateTestDataFactory.create_user_mock()
+ binding = DatasetUpdateTestDataFactory.create_external_binding_mock()
+
+ # Mock external knowledge binding query
+ mock_external_provider_dependencies.query.return_value.filter_by.return_value.first.return_value = binding
+
+ update_data = {
+ "name": "new_name",
+ "description": "new_description",
+ "external_retrieval_model": "new_model",
+ "permission": "only_me",
+ "external_knowledge_id": "new_knowledge_id",
+ "external_knowledge_api_id": "new_api_id",
+ }
+
+ result = DatasetService.update_dataset("dataset-123", update_data, user)
+
+ # Verify permission check was called
+ mock_dataset_service_dependencies["check_permission"].assert_called_once_with(dataset, user)
+
+ # Verify dataset and binding updates
+ self._assert_external_dataset_update(dataset, binding, update_data)
+
+ # Verify database operations
+ mock_db = mock_dataset_service_dependencies["db_session"]
+ mock_db.add.assert_any_call(dataset)
+ mock_db.add.assert_any_call(binding)
+ mock_db.commit.assert_called_once()
+
+ # Verify return value
+ assert result == dataset
+
+ def test_update_external_dataset_missing_knowledge_id_error(self, mock_dataset_service_dependencies):
+ """Test error when external knowledge id is missing."""
+ dataset = DatasetUpdateTestDataFactory.create_dataset_mock(provider="external")
+ mock_dataset_service_dependencies["get_dataset"].return_value = dataset
+
+ user = DatasetUpdateTestDataFactory.create_user_mock()
+ update_data = {"name": "new_name", "external_knowledge_api_id": "api_id"}
+
+ with pytest.raises(ValueError) as context:
+ DatasetService.update_dataset("dataset-123", update_data, user)
+
+ assert "External knowledge id is required" in str(context.value)
+
+ def test_update_external_dataset_missing_api_id_error(self, mock_dataset_service_dependencies):
+ """Test error when external knowledge api id is missing."""
+ dataset = DatasetUpdateTestDataFactory.create_dataset_mock(provider="external")
+ mock_dataset_service_dependencies["get_dataset"].return_value = dataset
+
+ user = DatasetUpdateTestDataFactory.create_user_mock()
+ update_data = {"name": "new_name", "external_knowledge_id": "knowledge_id"}
+
+ with pytest.raises(ValueError) as context:
+ DatasetService.update_dataset("dataset-123", update_data, user)
+
+ assert "External knowledge api id is required" in str(context.value)
+
+ def test_update_external_dataset_binding_not_found_error(
+ self, mock_dataset_service_dependencies, mock_external_provider_dependencies
+ ):
+ """Test error when external knowledge binding is not found."""
+ dataset = DatasetUpdateTestDataFactory.create_dataset_mock(provider="external")
+ mock_dataset_service_dependencies["get_dataset"].return_value = dataset
+
+ user = DatasetUpdateTestDataFactory.create_user_mock()
+
+ # Mock external knowledge binding query returning None
+ mock_external_provider_dependencies.query.return_value.filter_by.return_value.first.return_value = None
+
+ update_data = {
+ "name": "new_name",
+ "external_knowledge_id": "knowledge_id",
+ "external_knowledge_api_id": "api_id",
+ }
+
+ with pytest.raises(ValueError) as context:
+ DatasetService.update_dataset("dataset-123", update_data, user)
+
+ assert "External knowledge binding not found" in str(context.value)
+
+ # ==================== Internal Dataset Basic Tests ====================
+
+ def test_update_internal_dataset_basic_success(self, mock_dataset_service_dependencies):
+ """Test successful update of internal dataset with basic fields."""
+ dataset = DatasetUpdateTestDataFactory.create_dataset_mock(
+ provider="vendor",
+ indexing_technique="high_quality",
+ embedding_model_provider="openai",
+ embedding_model="text-embedding-ada-002",
+ collection_binding_id="binding-123",
+ )
+ mock_dataset_service_dependencies["get_dataset"].return_value = dataset
+
+ user = DatasetUpdateTestDataFactory.create_user_mock()
+
+ update_data = {
+ "name": "new_name",
+ "description": "new_description",
+ "indexing_technique": "high_quality",
+ "retrieval_model": "new_model",
+ "embedding_model_provider": "openai",
+ "embedding_model": "text-embedding-ada-002",
+ }
+
+ result = DatasetService.update_dataset("dataset-123", update_data, user)
+
+ # Verify permission check was called
+ mock_dataset_service_dependencies["check_permission"].assert_called_once_with(dataset, user)
+
+ # Verify database update was called with correct filtered data
+ expected_filtered_data = {
+ "name": "new_name",
+ "description": "new_description",
+ "indexing_technique": "high_quality",
+ "retrieval_model": "new_model",
+ "embedding_model_provider": "openai",
+ "embedding_model": "text-embedding-ada-002",
+ "updated_by": user.id,
+ "updated_at": mock_dataset_service_dependencies["current_time"].replace(tzinfo=None),
+ }
+
+ self._assert_database_update_called(
+ mock_dataset_service_dependencies["db_session"], "dataset-123", expected_filtered_data
+ )
+
+ # Verify return value
+ assert result == dataset
+
+ def test_update_internal_dataset_filter_none_values(self, mock_dataset_service_dependencies):
+ """Test that None values are filtered out except for description field."""
+ dataset = DatasetUpdateTestDataFactory.create_dataset_mock(provider="vendor", indexing_technique="high_quality")
+ mock_dataset_service_dependencies["get_dataset"].return_value = dataset
+
+ user = DatasetUpdateTestDataFactory.create_user_mock()
+
+ update_data = {
+ "name": "new_name",
+ "description": None, # Should be included
+ "indexing_technique": "high_quality",
+ "retrieval_model": "new_model",
+ "embedding_model_provider": None, # Should be filtered out
+ "embedding_model": None, # Should be filtered out
+ }
+
+ result = DatasetService.update_dataset("dataset-123", update_data, user)
+
+ # Verify database update was called with filtered data
+ expected_filtered_data = {
+ "name": "new_name",
+ "description": None, # Description should be included even if None
+ "indexing_technique": "high_quality",
+ "retrieval_model": "new_model",
+ "updated_by": user.id,
+ "updated_at": mock_dataset_service_dependencies["current_time"].replace(tzinfo=None),
+ }
+
+ actual_call_args = mock_dataset_service_dependencies[
+ "db_session"
+ ].query.return_value.filter_by.return_value.update.call_args[0][0]
+ # Remove timestamp for comparison as it's dynamic
+ del actual_call_args["updated_at"]
+ del expected_filtered_data["updated_at"]
+
+ assert actual_call_args == expected_filtered_data
+
+ # Verify return value
+ assert result == dataset
+
+ # ==================== Indexing Technique Switch Tests ====================
+
+ def test_update_internal_dataset_indexing_technique_to_economy(
+ self, mock_dataset_service_dependencies, mock_internal_provider_dependencies
+ ):
+ """Test updating internal dataset indexing technique to economy."""
+ dataset = DatasetUpdateTestDataFactory.create_dataset_mock(provider="vendor", indexing_technique="high_quality")
+ mock_dataset_service_dependencies["get_dataset"].return_value = dataset
+
+ user = DatasetUpdateTestDataFactory.create_user_mock()
+
+ update_data = {"indexing_technique": "economy", "retrieval_model": "new_model"}
+
+ result = DatasetService.update_dataset("dataset-123", update_data, user)
+
+ # Verify database update was called with embedding model fields cleared
+ expected_filtered_data = {
+ "indexing_technique": "economy",
+ "embedding_model": None,
+ "embedding_model_provider": None,
+ "collection_binding_id": None,
+ "retrieval_model": "new_model",
+ "updated_by": user.id,
+ "updated_at": mock_dataset_service_dependencies["current_time"].replace(tzinfo=None),
+ }
+
+ self._assert_database_update_called(
+ mock_dataset_service_dependencies["db_session"], "dataset-123", expected_filtered_data
+ )
+
+ # Verify return value
+ assert result == dataset
+
+ def test_update_internal_dataset_indexing_technique_to_high_quality(
+ self, mock_dataset_service_dependencies, mock_internal_provider_dependencies
+ ):
+ """Test updating internal dataset indexing technique to high_quality."""
+ dataset = DatasetUpdateTestDataFactory.create_dataset_mock(provider="vendor", indexing_technique="economy")
+ mock_dataset_service_dependencies["get_dataset"].return_value = dataset
+
+ user = DatasetUpdateTestDataFactory.create_user_mock()
+
+ # Mock embedding model
+ embedding_model = DatasetUpdateTestDataFactory.create_embedding_model_mock()
+ mock_internal_provider_dependencies[
+ "model_manager"
+ ].return_value.get_model_instance.return_value = embedding_model
+
+ # Mock collection binding
+ binding = DatasetUpdateTestDataFactory.create_collection_binding_mock()
+ mock_internal_provider_dependencies["get_binding"].return_value = binding
+
+ update_data = {
+ "indexing_technique": "high_quality",
+ "embedding_model_provider": "openai",
+ "embedding_model": "text-embedding-ada-002",
+ "retrieval_model": "new_model",
+ }
+
+ result = DatasetService.update_dataset("dataset-123", update_data, user)
+
+ # Verify embedding model was validated
+ mock_internal_provider_dependencies["model_manager"].return_value.get_model_instance.assert_called_once_with(
+ tenant_id=mock_internal_provider_dependencies["current_user"].current_tenant_id,
+ provider="openai",
+ model_type=ModelType.TEXT_EMBEDDING,
+ model="text-embedding-ada-002",
+ )
+
+ # Verify collection binding was retrieved
+ mock_internal_provider_dependencies["get_binding"].assert_called_once_with("openai", "text-embedding-ada-002")
+
+ # Verify database update was called with correct data
+ expected_filtered_data = {
+ "indexing_technique": "high_quality",
+ "embedding_model": "text-embedding-ada-002",
+ "embedding_model_provider": "openai",
+ "collection_binding_id": "binding-456",
+ "retrieval_model": "new_model",
+ "updated_by": user.id,
+ "updated_at": mock_dataset_service_dependencies["current_time"].replace(tzinfo=None),
+ }
+
+ self._assert_database_update_called(
+ mock_dataset_service_dependencies["db_session"], "dataset-123", expected_filtered_data
+ )
+
+ # Verify vector index task was triggered
+ mock_internal_provider_dependencies["task"].delay.assert_called_once_with("dataset-123", "add")
+
+ # Verify return value
+ assert result == dataset
+
+ # ==================== Embedding Model Update Tests ====================
+
+ def test_update_internal_dataset_keep_existing_embedding_model(self, mock_dataset_service_dependencies):
+ """Test updating internal dataset without changing embedding model."""
+ dataset = DatasetUpdateTestDataFactory.create_dataset_mock(
+ provider="vendor",
+ indexing_technique="high_quality",
+ embedding_model_provider="openai",
+ embedding_model="text-embedding-ada-002",
+ collection_binding_id="binding-123",
+ )
+ mock_dataset_service_dependencies["get_dataset"].return_value = dataset
+
+ user = DatasetUpdateTestDataFactory.create_user_mock()
+
+ update_data = {"name": "new_name", "indexing_technique": "high_quality", "retrieval_model": "new_model"}
+
+ result = DatasetService.update_dataset("dataset-123", update_data, user)
+
+ # Verify database update was called with existing embedding model preserved
+ expected_filtered_data = {
+ "name": "new_name",
+ "indexing_technique": "high_quality",
+ "embedding_model_provider": "openai",
+ "embedding_model": "text-embedding-ada-002",
+ "collection_binding_id": "binding-123",
+ "retrieval_model": "new_model",
+ "updated_by": user.id,
+ "updated_at": mock_dataset_service_dependencies["current_time"].replace(tzinfo=None),
+ }
+
+ self._assert_database_update_called(
+ mock_dataset_service_dependencies["db_session"], "dataset-123", expected_filtered_data
+ )
+
+ # Verify return value
+ assert result == dataset
+
+ def test_update_internal_dataset_embedding_model_update(
+ self, mock_dataset_service_dependencies, mock_internal_provider_dependencies
+ ):
+ """Test updating internal dataset with new embedding model."""
+ dataset = DatasetUpdateTestDataFactory.create_dataset_mock(
+ provider="vendor",
+ indexing_technique="high_quality",
+ embedding_model_provider="openai",
+ embedding_model="text-embedding-ada-002",
+ )
+ mock_dataset_service_dependencies["get_dataset"].return_value = dataset
+
+ user = DatasetUpdateTestDataFactory.create_user_mock()
+
+ # Mock embedding model
+ embedding_model = DatasetUpdateTestDataFactory.create_embedding_model_mock("text-embedding-3-small")
+ mock_internal_provider_dependencies[
+ "model_manager"
+ ].return_value.get_model_instance.return_value = embedding_model
+
+ # Mock collection binding
+ binding = DatasetUpdateTestDataFactory.create_collection_binding_mock("binding-789")
+ mock_internal_provider_dependencies["get_binding"].return_value = binding
+
+ update_data = {
+ "indexing_technique": "high_quality",
+ "embedding_model_provider": "openai",
+ "embedding_model": "text-embedding-3-small",
+ "retrieval_model": "new_model",
+ }
+
+ result = DatasetService.update_dataset("dataset-123", update_data, user)
+
+ # Verify embedding model was validated
+ mock_internal_provider_dependencies["model_manager"].return_value.get_model_instance.assert_called_once_with(
+ tenant_id=mock_internal_provider_dependencies["current_user"].current_tenant_id,
+ provider="openai",
+ model_type=ModelType.TEXT_EMBEDDING,
+ model="text-embedding-3-small",
+ )
+
+ # Verify collection binding was retrieved
+ mock_internal_provider_dependencies["get_binding"].assert_called_once_with("openai", "text-embedding-3-small")
+
+ # Verify database update was called with correct data
+ expected_filtered_data = {
+ "indexing_technique": "high_quality",
+ "embedding_model": "text-embedding-3-small",
+ "embedding_model_provider": "openai",
+ "collection_binding_id": "binding-789",
+ "retrieval_model": "new_model",
+ "updated_by": user.id,
+ "updated_at": mock_dataset_service_dependencies["current_time"].replace(tzinfo=None),
+ }
+
+ self._assert_database_update_called(
+ mock_dataset_service_dependencies["db_session"], "dataset-123", expected_filtered_data
+ )
+
+ # Verify vector index task was triggered
+ mock_internal_provider_dependencies["task"].delay.assert_called_once_with("dataset-123", "update")
+
+ # Verify return value
+ assert result == dataset
+
+ def test_update_internal_dataset_no_indexing_technique_change(self, mock_dataset_service_dependencies):
+ """Test updating internal dataset without changing indexing technique."""
+ dataset = DatasetUpdateTestDataFactory.create_dataset_mock(
+ provider="vendor",
+ indexing_technique="high_quality",
+ embedding_model_provider="openai",
+ embedding_model="text-embedding-ada-002",
+ collection_binding_id="binding-123",
+ )
+ mock_dataset_service_dependencies["get_dataset"].return_value = dataset
+
+ user = DatasetUpdateTestDataFactory.create_user_mock()
+
+ update_data = {
+ "name": "new_name",
+ "indexing_technique": "high_quality", # Same as current
+ "retrieval_model": "new_model",
+ }
+
+ result = DatasetService.update_dataset("dataset-123", update_data, user)
+
+ # Verify database update was called with correct data
+ expected_filtered_data = {
+ "name": "new_name",
+ "indexing_technique": "high_quality",
+ "embedding_model_provider": "openai",
+ "embedding_model": "text-embedding-ada-002",
+ "collection_binding_id": "binding-123",
+ "retrieval_model": "new_model",
+ "updated_by": user.id,
+ "updated_at": mock_dataset_service_dependencies["current_time"].replace(tzinfo=None),
+ }
+
+ self._assert_database_update_called(
+ mock_dataset_service_dependencies["db_session"], "dataset-123", expected_filtered_data
+ )
+
+ # Verify return value
+ assert result == dataset
+
+ # ==================== Error Handling Tests ====================
+
+ def test_update_dataset_not_found_error(self, mock_dataset_service_dependencies):
+ """Test error when dataset is not found."""
+ mock_dataset_service_dependencies["get_dataset"].return_value = None
+
+ user = DatasetUpdateTestDataFactory.create_user_mock()
+ update_data = {"name": "new_name"}
+
+ with pytest.raises(ValueError) as context:
+ DatasetService.update_dataset("dataset-123", update_data, user)
+
+ assert "Dataset not found" in str(context.value)
+
+ def test_update_dataset_permission_error(self, mock_dataset_service_dependencies):
+ """Test error when user doesn't have permission."""
+ dataset = DatasetUpdateTestDataFactory.create_dataset_mock()
+ mock_dataset_service_dependencies["get_dataset"].return_value = dataset
+
+ user = DatasetUpdateTestDataFactory.create_user_mock()
+ mock_dataset_service_dependencies["check_permission"].side_effect = NoPermissionError("No permission")
+
+ update_data = {"name": "new_name"}
+
+ with pytest.raises(NoPermissionError):
+ DatasetService.update_dataset("dataset-123", update_data, user)
+
+ def test_update_internal_dataset_embedding_model_error(
+ self, mock_dataset_service_dependencies, mock_internal_provider_dependencies
+ ):
+ """Test error when embedding model is not available."""
+ dataset = DatasetUpdateTestDataFactory.create_dataset_mock(provider="vendor", indexing_technique="economy")
+ mock_dataset_service_dependencies["get_dataset"].return_value = dataset
+
+ user = DatasetUpdateTestDataFactory.create_user_mock()
+
+ # Mock model manager to raise error
+ mock_internal_provider_dependencies["model_manager"].return_value.get_model_instance.side_effect = Exception(
+ "No Embedding Model available"
+ )
+
+ update_data = {
+ "indexing_technique": "high_quality",
+ "embedding_model_provider": "invalid_provider",
+ "embedding_model": "invalid_model",
+ "retrieval_model": "new_model",
+ }
+
+ with pytest.raises(Exception) as context:
+ DatasetService.update_dataset("dataset-123", update_data, user)
+
+ assert "No Embedding Model available".lower() in str(context.value).lower()
diff --git a/api/tests/unit_tests/services/workflow/test_workflow_deletion.py b/api/tests/unit_tests/services/workflow/test_workflow_deletion.py
index 223020c2c5..2c87eaf805 100644
--- a/api/tests/unit_tests/services/workflow/test_workflow_deletion.py
+++ b/api/tests/unit_tests/services/workflow/test_workflow_deletion.py
@@ -10,7 +10,8 @@ from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseE
@pytest.fixture
def workflow_setup():
- workflow_service = WorkflowService()
+ mock_session_maker = MagicMock()
+ workflow_service = WorkflowService(mock_session_maker)
session = MagicMock(spec=Session)
tenant_id = "test-tenant-id"
workflow_id = "test-workflow-id"
diff --git a/api/tests/unit_tests/services/workflow/test_workflow_draft_variable_service.py b/api/tests/unit_tests/services/workflow/test_workflow_draft_variable_service.py
new file mode 100644
index 0000000000..8b1348b75b
--- /dev/null
+++ b/api/tests/unit_tests/services/workflow/test_workflow_draft_variable_service.py
@@ -0,0 +1,391 @@
+import dataclasses
+import secrets
+from unittest.mock import MagicMock, Mock, patch
+
+import pytest
+from sqlalchemy import Engine
+from sqlalchemy.orm import Session
+
+from core.variables import StringSegment
+from core.workflow.constants import SYSTEM_VARIABLE_NODE_ID
+from core.workflow.nodes.enums import NodeType
+from models.enums import DraftVariableType
+from models.workflow import Workflow, WorkflowDraftVariable, WorkflowNodeExecutionModel, is_system_variable_editable
+from services.workflow_draft_variable_service import (
+ DraftVariableSaver,
+ VariableResetError,
+ WorkflowDraftVariableService,
+)
+
+
+@pytest.fixture
+def mock_engine() -> Engine:
+ return Mock(spec=Engine)
+
+
+@pytest.fixture
+def mock_session(mock_engine) -> Session:
+ mock_session = Mock(spec=Session)
+ mock_session.get_bind.return_value = mock_engine
+ return mock_session
+
+
+class TestDraftVariableSaver:
+ def _get_test_app_id(self):
+ suffix = secrets.token_hex(6)
+ return f"test_app_id_{suffix}"
+
+ def test__should_variable_be_visible(self):
+ mock_session = MagicMock(spec=Session)
+ test_app_id = self._get_test_app_id()
+ saver = DraftVariableSaver(
+ session=mock_session,
+ app_id=test_app_id,
+ node_id="test_node_id",
+ node_type=NodeType.START,
+ node_execution_id="test_execution_id",
+ )
+ assert saver._should_variable_be_visible("123_456", NodeType.IF_ELSE, "output") == False
+ assert saver._should_variable_be_visible("123", NodeType.START, "output") == True
+
+ def test__normalize_variable_for_start_node(self):
+ @dataclasses.dataclass(frozen=True)
+ class TestCase:
+ name: str
+ input_node_id: str
+ input_name: str
+ expected_node_id: str
+ expected_name: str
+
+ _NODE_ID = "1747228642872"
+ cases = [
+ TestCase(
+ name="name with `sys.` prefix should return the system node_id",
+ input_node_id=_NODE_ID,
+ input_name="sys.workflow_id",
+ expected_node_id=SYSTEM_VARIABLE_NODE_ID,
+ expected_name="workflow_id",
+ ),
+ TestCase(
+ name="name without `sys.` prefix should return the original input node_id",
+ input_node_id=_NODE_ID,
+ input_name="start_input",
+ expected_node_id=_NODE_ID,
+ expected_name="start_input",
+ ),
+ TestCase(
+ name="dummy_variable should return the original input node_id",
+ input_node_id=_NODE_ID,
+ input_name="__dummy__",
+ expected_node_id=_NODE_ID,
+ expected_name="__dummy__",
+ ),
+ ]
+
+ mock_session = MagicMock(spec=Session)
+ test_app_id = self._get_test_app_id()
+ saver = DraftVariableSaver(
+ session=mock_session,
+ app_id=test_app_id,
+ node_id=_NODE_ID,
+ node_type=NodeType.START,
+ node_execution_id="test_execution_id",
+ )
+ for idx, c in enumerate(cases, 1):
+ fail_msg = f"Test case {c.name} failed, index={idx}"
+ node_id, name = saver._normalize_variable_for_start_node(c.input_name)
+ assert node_id == c.expected_node_id, fail_msg
+ assert name == c.expected_name, fail_msg
+
+
+class TestWorkflowDraftVariableService:
+ def _get_test_app_id(self):
+ suffix = secrets.token_hex(6)
+ return f"test_app_id_{suffix}"
+
+ def _create_test_workflow(self, app_id: str) -> Workflow:
+ """Create a real Workflow instance for testing"""
+ return Workflow.new(
+ tenant_id="test_tenant_id",
+ app_id=app_id,
+ type="workflow",
+ version="draft",
+ graph='{"nodes": [], "edges": []}',
+ features="{}",
+ created_by="test_user_id",
+ environment_variables=[],
+ conversation_variables=[],
+ )
+
+ def test_reset_conversation_variable(self, mock_session):
+ """Test resetting a conversation variable"""
+ service = WorkflowDraftVariableService(mock_session)
+
+ test_app_id = self._get_test_app_id()
+ workflow = self._create_test_workflow(test_app_id)
+
+ # Create real conversation variable
+ test_value = StringSegment(value="test_value")
+ variable = WorkflowDraftVariable.new_conversation_variable(
+ app_id=test_app_id, name="test_var", value=test_value, description="Test conversation variable"
+ )
+
+ # Mock the _reset_conv_var method
+ expected_result = WorkflowDraftVariable.new_conversation_variable(
+ app_id=test_app_id,
+ name="test_var",
+ value=StringSegment(value="reset_value"),
+ )
+ with patch.object(service, "_reset_conv_var", return_value=expected_result) as mock_reset_conv:
+ result = service.reset_variable(workflow, variable)
+
+ mock_reset_conv.assert_called_once_with(workflow, variable)
+ assert result == expected_result
+
+ def test_reset_node_variable_with_no_execution_id(self, mock_session):
+ """Test resetting a node variable with no execution ID - should delete variable"""
+ service = WorkflowDraftVariableService(mock_session)
+
+ test_app_id = self._get_test_app_id()
+ workflow = self._create_test_workflow(test_app_id)
+
+ # Create real node variable with no execution ID
+ test_value = StringSegment(value="test_value")
+ variable = WorkflowDraftVariable.new_node_variable(
+ app_id=test_app_id,
+ node_id="test_node_id",
+ name="test_var",
+ value=test_value,
+ node_execution_id="exec-id", # Set initially
+ )
+ # Manually set to None to simulate the test condition
+ variable.node_execution_id = None
+
+ result = service._reset_node_var_or_sys_var(workflow, variable)
+
+ # Should delete the variable and return None
+ mock_session.delete.assert_called_once_with(instance=variable)
+ mock_session.flush.assert_called_once()
+ assert result is None
+
+ def test_reset_node_variable_with_missing_execution_record(
+ self,
+ mock_engine,
+ mock_session,
+ monkeypatch,
+ ):
+ """Test resetting a node variable when execution record doesn't exist"""
+ mock_repo_session = Mock(spec=Session)
+
+ mock_session_maker = MagicMock()
+ # Mock the context manager protocol for sessionmaker
+ mock_session_maker.return_value.__enter__.return_value = mock_repo_session
+ mock_session_maker.return_value.__exit__.return_value = None
+ monkeypatch.setattr("services.workflow_draft_variable_service.sessionmaker", mock_session_maker)
+ service = WorkflowDraftVariableService(mock_session)
+
+ # Mock the repository to return None (no execution record found)
+ service._api_node_execution_repo = Mock()
+ service._api_node_execution_repo.get_execution_by_id.return_value = None
+
+ test_app_id = self._get_test_app_id()
+ workflow = self._create_test_workflow(test_app_id)
+
+ # Create real node variable with execution ID
+ test_value = StringSegment(value="test_value")
+ variable = WorkflowDraftVariable.new_node_variable(
+ app_id=test_app_id, node_id="test_node_id", name="test_var", value=test_value, node_execution_id="exec-id"
+ )
+ # Variable is editable by default from factory method
+
+ result = service._reset_node_var_or_sys_var(workflow, variable)
+
+ mock_session_maker.assert_called_once_with(bind=mock_engine, expire_on_commit=False)
+ # Should delete the variable and return None
+ mock_session.delete.assert_called_once_with(instance=variable)
+ mock_session.flush.assert_called_once()
+ assert result is None
+
+ def test_reset_node_variable_with_valid_execution_record(
+ self,
+ mock_session,
+ monkeypatch,
+ ):
+ """Test resetting a node variable with valid execution record - should restore from execution"""
+ mock_repo_session = Mock(spec=Session)
+
+ mock_session_maker = MagicMock()
+ # Mock the context manager protocol for sessionmaker
+ mock_session_maker.return_value.__enter__.return_value = mock_repo_session
+ mock_session_maker.return_value.__exit__.return_value = None
+ mock_session_maker = monkeypatch.setattr(
+ "services.workflow_draft_variable_service.sessionmaker", mock_session_maker
+ )
+ service = WorkflowDraftVariableService(mock_session)
+
+ # Create mock execution record
+ mock_execution = Mock(spec=WorkflowNodeExecutionModel)
+ mock_execution.outputs_dict = {"test_var": "output_value"}
+
+ # Mock the repository to return the execution record
+ service._api_node_execution_repo = Mock()
+ service._api_node_execution_repo.get_execution_by_id.return_value = mock_execution
+
+ test_app_id = self._get_test_app_id()
+ workflow = self._create_test_workflow(test_app_id)
+
+ # Create real node variable with execution ID
+ test_value = StringSegment(value="original_value")
+ variable = WorkflowDraftVariable.new_node_variable(
+ app_id=test_app_id, node_id="test_node_id", name="test_var", value=test_value, node_execution_id="exec-id"
+ )
+ # Variable is editable by default from factory method
+
+ # Mock workflow methods
+ mock_node_config = {"type": "test_node"}
+ with (
+ patch.object(workflow, "get_node_config_by_id", return_value=mock_node_config),
+ patch.object(workflow, "get_node_type_from_node_config", return_value=NodeType.LLM),
+ ):
+ result = service._reset_node_var_or_sys_var(workflow, variable)
+
+ # Verify last_edited_at was reset
+ assert variable.last_edited_at is None
+ # Verify session.flush was called
+ mock_session.flush.assert_called()
+
+ # Should return the updated variable
+ assert result == variable
+
+ def test_reset_non_editable_system_variable_raises_error(self, mock_session):
+ """Test that resetting a non-editable system variable raises an error"""
+ service = WorkflowDraftVariableService(mock_session)
+
+ test_app_id = self._get_test_app_id()
+ workflow = self._create_test_workflow(test_app_id)
+
+ # Create a non-editable system variable (workflow_id is not editable)
+ test_value = StringSegment(value="test_workflow_id")
+ variable = WorkflowDraftVariable.new_sys_variable(
+ app_id=test_app_id,
+ name="workflow_id", # This is not in _EDITABLE_SYSTEM_VARIABLE
+ value=test_value,
+ node_execution_id="exec-id",
+ editable=False, # Non-editable system variable
+ )
+
+ with pytest.raises(VariableResetError) as exc_info:
+ service.reset_variable(workflow, variable)
+ assert "cannot reset system variable" in str(exc_info.value)
+ assert f"variable_id={variable.id}" in str(exc_info.value)
+
+ def test_reset_editable_system_variable_succeeds(self, mock_session):
+ """Test that resetting an editable system variable succeeds"""
+ service = WorkflowDraftVariableService(mock_session)
+
+ test_app_id = self._get_test_app_id()
+ workflow = self._create_test_workflow(test_app_id)
+
+ # Create an editable system variable (files is editable)
+ test_value = StringSegment(value="[]")
+ variable = WorkflowDraftVariable.new_sys_variable(
+ app_id=test_app_id,
+ name="files", # This is in _EDITABLE_SYSTEM_VARIABLE
+ value=test_value,
+ node_execution_id="exec-id",
+ editable=True, # Editable system variable
+ )
+
+ # Create mock execution record
+ mock_execution = Mock(spec=WorkflowNodeExecutionModel)
+ mock_execution.outputs_dict = {"sys.files": "[]"}
+
+ # Mock the repository to return the execution record
+ service._api_node_execution_repo = Mock()
+ service._api_node_execution_repo.get_execution_by_id.return_value = mock_execution
+
+ result = service._reset_node_var_or_sys_var(workflow, variable)
+
+ # Should succeed and return the variable
+ assert result == variable
+ assert variable.last_edited_at is None
+ mock_session.flush.assert_called()
+
+ def test_reset_query_system_variable_succeeds(self, mock_session):
+ """Test that resetting query system variable (another editable one) succeeds"""
+ service = WorkflowDraftVariableService(mock_session)
+
+ test_app_id = self._get_test_app_id()
+ workflow = self._create_test_workflow(test_app_id)
+
+ # Create an editable system variable (query is editable)
+ test_value = StringSegment(value="original query")
+ variable = WorkflowDraftVariable.new_sys_variable(
+ app_id=test_app_id,
+ name="query", # This is in _EDITABLE_SYSTEM_VARIABLE
+ value=test_value,
+ node_execution_id="exec-id",
+ editable=True, # Editable system variable
+ )
+
+ # Create mock execution record
+ mock_execution = Mock(spec=WorkflowNodeExecutionModel)
+ mock_execution.outputs_dict = {"sys.query": "reset query"}
+
+ # Mock the repository to return the execution record
+ service._api_node_execution_repo = Mock()
+ service._api_node_execution_repo.get_execution_by_id.return_value = mock_execution
+
+ result = service._reset_node_var_or_sys_var(workflow, variable)
+
+ # Should succeed and return the variable
+ assert result == variable
+ assert variable.last_edited_at is None
+ mock_session.flush.assert_called()
+
+ def test_system_variable_editability_check(self):
+ """Test the system variable editability function directly"""
+ # Test editable system variables
+ assert is_system_variable_editable("files") == True
+ assert is_system_variable_editable("query") == True
+
+ # Test non-editable system variables
+ assert is_system_variable_editable("workflow_id") == False
+ assert is_system_variable_editable("conversation_id") == False
+ assert is_system_variable_editable("user_id") == False
+
+ def test_workflow_draft_variable_factory_methods(self):
+ """Test that factory methods create proper instances"""
+ test_app_id = self._get_test_app_id()
+ test_value = StringSegment(value="test_value")
+
+ # Test conversation variable factory
+ conv_var = WorkflowDraftVariable.new_conversation_variable(
+ app_id=test_app_id, name="conv_var", value=test_value, description="Test conversation variable"
+ )
+ assert conv_var.get_variable_type() == DraftVariableType.CONVERSATION
+ assert conv_var.editable == True
+ assert conv_var.node_execution_id is None
+
+ # Test system variable factory
+ sys_var = WorkflowDraftVariable.new_sys_variable(
+ app_id=test_app_id, name="workflow_id", value=test_value, node_execution_id="exec-id", editable=False
+ )
+ assert sys_var.get_variable_type() == DraftVariableType.SYS
+ assert sys_var.editable == False
+ assert sys_var.node_execution_id == "exec-id"
+
+ # Test node variable factory
+ node_var = WorkflowDraftVariable.new_node_variable(
+ app_id=test_app_id,
+ node_id="node-id",
+ name="node_var",
+ value=test_value,
+ node_execution_id="exec-id",
+ visible=True,
+ editable=True,
+ )
+ assert node_var.get_variable_type() == DraftVariableType.NODE
+ assert node_var.visible == True
+ assert node_var.editable == True
+ assert node_var.node_execution_id == "exec-id"
diff --git a/api/tests/unit_tests/services/workflow/test_workflow_node_execution_service_repository.py b/api/tests/unit_tests/services/workflow/test_workflow_node_execution_service_repository.py
new file mode 100644
index 0000000000..32d2f8b7e0
--- /dev/null
+++ b/api/tests/unit_tests/services/workflow/test_workflow_node_execution_service_repository.py
@@ -0,0 +1,288 @@
+from datetime import datetime
+from unittest.mock import MagicMock
+from uuid import uuid4
+
+import pytest
+from sqlalchemy.orm import Session
+
+from models.workflow import WorkflowNodeExecutionModel
+from repositories.sqlalchemy_api_workflow_node_execution_repository import (
+ DifyAPISQLAlchemyWorkflowNodeExecutionRepository,
+)
+
+
+class TestSQLAlchemyWorkflowNodeExecutionServiceRepository:
+ @pytest.fixture
+ def repository(self):
+ mock_session_maker = MagicMock()
+ return DifyAPISQLAlchemyWorkflowNodeExecutionRepository(session_maker=mock_session_maker)
+
+ @pytest.fixture
+ def mock_execution(self):
+ execution = MagicMock(spec=WorkflowNodeExecutionModel)
+ execution.id = str(uuid4())
+ execution.tenant_id = "tenant-123"
+ execution.app_id = "app-456"
+ execution.workflow_id = "workflow-789"
+ execution.workflow_run_id = "run-101"
+ execution.node_id = "node-202"
+ execution.index = 1
+ execution.created_at = "2023-01-01T00:00:00Z"
+ return execution
+
+ def test_get_node_last_execution_found(self, repository, mock_execution):
+ """Test getting the last execution for a node when it exists."""
+ # Arrange
+ mock_session = MagicMock(spec=Session)
+ repository._session_maker.return_value.__enter__.return_value = mock_session
+ mock_session.scalar.return_value = mock_execution
+
+ # Act
+ result = repository.get_node_last_execution(
+ tenant_id="tenant-123",
+ app_id="app-456",
+ workflow_id="workflow-789",
+ node_id="node-202",
+ )
+
+ # Assert
+ assert result == mock_execution
+ mock_session.scalar.assert_called_once()
+ # Verify the query was constructed correctly
+ call_args = mock_session.scalar.call_args[0][0]
+ assert hasattr(call_args, "compile") # It's a SQLAlchemy statement
+
+ def test_get_node_last_execution_not_found(self, repository):
+ """Test getting the last execution for a node when it doesn't exist."""
+ # Arrange
+ mock_session = MagicMock(spec=Session)
+ repository._session_maker.return_value.__enter__.return_value = mock_session
+ mock_session.scalar.return_value = None
+
+ # Act
+ result = repository.get_node_last_execution(
+ tenant_id="tenant-123",
+ app_id="app-456",
+ workflow_id="workflow-789",
+ node_id="node-202",
+ )
+
+ # Assert
+ assert result is None
+ mock_session.scalar.assert_called_once()
+
+ def test_get_executions_by_workflow_run(self, repository, mock_execution):
+ """Test getting all executions for a workflow run."""
+ # Arrange
+ mock_session = MagicMock(spec=Session)
+ repository._session_maker.return_value.__enter__.return_value = mock_session
+ executions = [mock_execution]
+ mock_session.execute.return_value.scalars.return_value.all.return_value = executions
+
+ # Act
+ result = repository.get_executions_by_workflow_run(
+ tenant_id="tenant-123",
+ app_id="app-456",
+ workflow_run_id="run-101",
+ )
+
+ # Assert
+ assert result == executions
+ mock_session.execute.assert_called_once()
+ # Verify the query was constructed correctly
+ call_args = mock_session.execute.call_args[0][0]
+ assert hasattr(call_args, "compile") # It's a SQLAlchemy statement
+
+ def test_get_executions_by_workflow_run_empty(self, repository):
+ """Test getting executions for a workflow run when none exist."""
+ # Arrange
+ mock_session = MagicMock(spec=Session)
+ repository._session_maker.return_value.__enter__.return_value = mock_session
+ mock_session.execute.return_value.scalars.return_value.all.return_value = []
+
+ # Act
+ result = repository.get_executions_by_workflow_run(
+ tenant_id="tenant-123",
+ app_id="app-456",
+ workflow_run_id="run-101",
+ )
+
+ # Assert
+ assert result == []
+ mock_session.execute.assert_called_once()
+
+ def test_get_execution_by_id_found(self, repository, mock_execution):
+ """Test getting execution by ID when it exists."""
+ # Arrange
+ mock_session = MagicMock(spec=Session)
+ repository._session_maker.return_value.__enter__.return_value = mock_session
+ mock_session.scalar.return_value = mock_execution
+
+ # Act
+ result = repository.get_execution_by_id(mock_execution.id)
+
+ # Assert
+ assert result == mock_execution
+ mock_session.scalar.assert_called_once()
+
+ def test_get_execution_by_id_not_found(self, repository):
+ """Test getting execution by ID when it doesn't exist."""
+ # Arrange
+ mock_session = MagicMock(spec=Session)
+ repository._session_maker.return_value.__enter__.return_value = mock_session
+ mock_session.scalar.return_value = None
+
+ # Act
+ result = repository.get_execution_by_id("non-existent-id")
+
+ # Assert
+ assert result is None
+ mock_session.scalar.assert_called_once()
+
+ def test_repository_implements_protocol(self, repository):
+ """Test that the repository implements the required protocol methods."""
+ # Verify all protocol methods are implemented
+ assert hasattr(repository, "get_node_last_execution")
+ assert hasattr(repository, "get_executions_by_workflow_run")
+ assert hasattr(repository, "get_execution_by_id")
+
+ # Verify methods are callable
+ assert callable(repository.get_node_last_execution)
+ assert callable(repository.get_executions_by_workflow_run)
+ assert callable(repository.get_execution_by_id)
+ assert callable(repository.delete_expired_executions)
+ assert callable(repository.delete_executions_by_app)
+ assert callable(repository.get_expired_executions_batch)
+ assert callable(repository.delete_executions_by_ids)
+
+ def test_delete_expired_executions(self, repository):
+ """Test deleting expired executions."""
+ # Arrange
+ mock_session = MagicMock(spec=Session)
+ repository._session_maker.return_value.__enter__.return_value = mock_session
+
+ # Mock the select query to return some IDs first time, then empty to stop loop
+ execution_ids = ["id1", "id2"] # Less than batch_size to trigger break
+
+ # Mock execute method to handle both select and delete statements
+ def mock_execute(stmt):
+ mock_result = MagicMock()
+ # For select statements, return execution IDs
+ if hasattr(stmt, "limit"): # This is our select statement
+ mock_result.scalars.return_value.all.return_value = execution_ids
+ else: # This is our delete statement
+ mock_result.rowcount = 2
+ return mock_result
+
+ mock_session.execute.side_effect = mock_execute
+
+ before_date = datetime(2023, 1, 1)
+
+ # Act
+ result = repository.delete_expired_executions(
+ tenant_id="tenant-123",
+ before_date=before_date,
+ batch_size=1000,
+ )
+
+ # Assert
+ assert result == 2
+ assert mock_session.execute.call_count == 2 # One select call, one delete call
+ mock_session.commit.assert_called_once()
+
+ def test_delete_executions_by_app(self, repository):
+ """Test deleting executions by app."""
+ # Arrange
+ mock_session = MagicMock(spec=Session)
+ repository._session_maker.return_value.__enter__.return_value = mock_session
+
+ # Mock the select query to return some IDs first time, then empty to stop loop
+ execution_ids = ["id1", "id2"]
+
+ # Mock execute method to handle both select and delete statements
+ def mock_execute(stmt):
+ mock_result = MagicMock()
+ # For select statements, return execution IDs
+ if hasattr(stmt, "limit"): # This is our select statement
+ mock_result.scalars.return_value.all.return_value = execution_ids
+ else: # This is our delete statement
+ mock_result.rowcount = 2
+ return mock_result
+
+ mock_session.execute.side_effect = mock_execute
+
+ # Act
+ result = repository.delete_executions_by_app(
+ tenant_id="tenant-123",
+ app_id="app-456",
+ batch_size=1000,
+ )
+
+ # Assert
+ assert result == 2
+ assert mock_session.execute.call_count == 2 # One select call, one delete call
+ mock_session.commit.assert_called_once()
+
+ def test_get_expired_executions_batch(self, repository):
+ """Test getting expired executions batch for backup."""
+ # Arrange
+ mock_session = MagicMock(spec=Session)
+ repository._session_maker.return_value.__enter__.return_value = mock_session
+
+ # Create mock execution objects
+ mock_execution1 = MagicMock()
+ mock_execution1.id = "exec-1"
+ mock_execution2 = MagicMock()
+ mock_execution2.id = "exec-2"
+
+ mock_session.execute.return_value.scalars.return_value.all.return_value = [mock_execution1, mock_execution2]
+
+ before_date = datetime(2023, 1, 1)
+
+ # Act
+ result = repository.get_expired_executions_batch(
+ tenant_id="tenant-123",
+ before_date=before_date,
+ batch_size=1000,
+ )
+
+ # Assert
+ assert len(result) == 2
+ assert result[0].id == "exec-1"
+ assert result[1].id == "exec-2"
+ mock_session.execute.assert_called_once()
+
+ def test_delete_executions_by_ids(self, repository):
+ """Test deleting executions by IDs."""
+ # Arrange
+ mock_session = MagicMock(spec=Session)
+ repository._session_maker.return_value.__enter__.return_value = mock_session
+
+ # Mock the delete query result
+ mock_result = MagicMock()
+ mock_result.rowcount = 3
+ mock_session.execute.return_value = mock_result
+
+ execution_ids = ["id1", "id2", "id3"]
+
+ # Act
+ result = repository.delete_executions_by_ids(execution_ids)
+
+ # Assert
+ assert result == 3
+ mock_session.execute.assert_called_once()
+ mock_session.commit.assert_called_once()
+
+ def test_delete_executions_by_ids_empty_list(self, repository):
+ """Test deleting executions with empty ID list."""
+ # Arrange
+ mock_session = MagicMock(spec=Session)
+ repository._session_maker.return_value.__enter__.return_value = mock_session
+
+ # Act
+ result = repository.delete_executions_by_ids([])
+
+ # Assert
+ assert result == 0
+ mock_session.query.assert_not_called()
+ mock_session.commit.assert_not_called()
diff --git a/api/tests/unit_tests/services/workflow/test_workflow_service.py b/api/tests/unit_tests/services/workflow/test_workflow_service.py
index 13393668ea..9700cbaf0e 100644
--- a/api/tests/unit_tests/services/workflow/test_workflow_service.py
+++ b/api/tests/unit_tests/services/workflow/test_workflow_service.py
@@ -10,7 +10,8 @@ from services.workflow_service import WorkflowService
class TestWorkflowService:
@pytest.fixture
def workflow_service(self):
- return WorkflowService()
+ mock_session_maker = MagicMock()
+ return WorkflowService(mock_session_maker)
@pytest.fixture
def mock_app(self):
diff --git a/api/tests/unit_tests/utils/http_parser/test_oauth_convert_request_to_raw_data.py b/api/tests/unit_tests/utils/http_parser/test_oauth_convert_request_to_raw_data.py
index f788a9756b..293ac253f5 100644
--- a/api/tests/unit_tests/utils/http_parser/test_oauth_convert_request_to_raw_data.py
+++ b/api/tests/unit_tests/utils/http_parser/test_oauth_convert_request_to_raw_data.py
@@ -1,3 +1,5 @@
+import json
+
from werkzeug import Request
from werkzeug.datastructures import Headers
from werkzeug.test import EnvironBuilder
@@ -15,6 +17,59 @@ def test_oauth_convert_request_to_raw_data():
request = Request(builder.get_environ())
raw_request_bytes = oauth_handler._convert_request_to_raw_data(request)
- assert b"GET /test HTTP/1.1" in raw_request_bytes
+ assert b"GET /test? HTTP/1.1" in raw_request_bytes
+ assert b"Content-Type: application/json" in raw_request_bytes
+ assert b"\r\n\r\n" in raw_request_bytes
+
+
+def test_oauth_convert_request_to_raw_data_with_query_params():
+ oauth_handler = OAuthHandler()
+ builder = EnvironBuilder(
+ method="GET",
+ path="/test",
+ query_string="code=abc123&state=xyz789",
+ headers=Headers({"Content-Type": "application/json"}),
+ )
+ request = Request(builder.get_environ())
+ raw_request_bytes = oauth_handler._convert_request_to_raw_data(request)
+
+ assert b"GET /test?code=abc123&state=xyz789 HTTP/1.1" in raw_request_bytes
+ assert b"Content-Type: application/json" in raw_request_bytes
+ assert b"\r\n\r\n" in raw_request_bytes
+
+
+def test_oauth_convert_request_to_raw_data_with_post_body():
+ oauth_handler = OAuthHandler()
+ builder = EnvironBuilder(
+ method="POST",
+ path="/test",
+ data="param1=value1¶m2=value2",
+ headers=Headers({"Content-Type": "application/x-www-form-urlencoded"}),
+ )
+ request = Request(builder.get_environ())
+ raw_request_bytes = oauth_handler._convert_request_to_raw_data(request)
+
+ assert b"POST /test? HTTP/1.1" in raw_request_bytes
+ assert b"Content-Type: application/x-www-form-urlencoded" in raw_request_bytes
+ assert b"\r\n\r\n" in raw_request_bytes
+ assert b"param1=value1¶m2=value2" in raw_request_bytes
+
+
+def test_oauth_convert_request_to_raw_data_with_json_body():
+ oauth_handler = OAuthHandler()
+ json_data = {"code": "abc123", "state": "xyz789", "grant_type": "authorization_code"}
+ builder = EnvironBuilder(
+ method="POST",
+ path="/test",
+ data=json.dumps(json_data),
+ headers=Headers({"Content-Type": "application/json"}),
+ )
+ request = Request(builder.get_environ())
+ raw_request_bytes = oauth_handler._convert_request_to_raw_data(request)
+
+ assert b"POST /test? HTTP/1.1" in raw_request_bytes
assert b"Content-Type: application/json" in raw_request_bytes
assert b"\r\n\r\n" in raw_request_bytes
+ assert b'"code": "abc123"' in raw_request_bytes
+ assert b'"state": "xyz789"' in raw_request_bytes
+ assert b'"grant_type": "authorization_code"' in raw_request_bytes
diff --git a/api/tests/unit_tests/utils/structured_output_parser/__init__.py b/api/tests/unit_tests/utils/structured_output_parser/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/api/tests/unit_tests/utils/structured_output_parser/test_structured_output_parser.py b/api/tests/unit_tests/utils/structured_output_parser/test_structured_output_parser.py
new file mode 100644
index 0000000000..93284eed4b
--- /dev/null
+++ b/api/tests/unit_tests/utils/structured_output_parser/test_structured_output_parser.py
@@ -0,0 +1,465 @@
+from decimal import Decimal
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from core.llm_generator.output_parser.errors import OutputParserError
+from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output
+from core.model_runtime.entities.llm_entities import (
+ LLMResult,
+ LLMResultChunk,
+ LLMResultChunkDelta,
+ LLMResultChunkWithStructuredOutput,
+ LLMResultWithStructuredOutput,
+ LLMUsage,
+)
+from core.model_runtime.entities.message_entities import (
+ AssistantPromptMessage,
+ SystemPromptMessage,
+ TextPromptMessageContent,
+ UserPromptMessage,
+)
+from core.model_runtime.entities.model_entities import AIModelEntity, ModelType
+
+
+def create_mock_usage(prompt_tokens: int = 10, completion_tokens: int = 5) -> LLMUsage:
+ """Create a mock LLMUsage with all required fields"""
+ return LLMUsage(
+ prompt_tokens=prompt_tokens,
+ prompt_unit_price=Decimal("0.001"),
+ prompt_price_unit=Decimal(1),
+ prompt_price=Decimal(str(prompt_tokens)) * Decimal("0.001"),
+ completion_tokens=completion_tokens,
+ completion_unit_price=Decimal("0.002"),
+ completion_price_unit=Decimal(1),
+ completion_price=Decimal(str(completion_tokens)) * Decimal("0.002"),
+ total_tokens=prompt_tokens + completion_tokens,
+ total_price=Decimal(str(prompt_tokens)) * Decimal("0.001") + Decimal(str(completion_tokens)) * Decimal("0.002"),
+ currency="USD",
+ latency=1.5,
+ )
+
+
+def get_model_entity(provider: str, model_name: str, support_structure_output: bool = False) -> AIModelEntity:
+ """Create a mock AIModelEntity for testing"""
+ model_schema = MagicMock()
+ model_schema.model = model_name
+ model_schema.provider = provider
+ model_schema.model_type = ModelType.LLM
+ model_schema.model_provider = provider
+ model_schema.model_name = model_name
+ model_schema.support_structure_output = support_structure_output
+ model_schema.parameter_rules = []
+
+ return model_schema
+
+
+def get_model_instance() -> MagicMock:
+ """Create a mock ModelInstance for testing"""
+ mock_instance = MagicMock()
+ mock_instance.provider = "openai"
+ mock_instance.credentials = {}
+ return mock_instance
+
+
+def test_structured_output_parser():
+ """Test cases for invoke_llm_with_structured_output function"""
+
+ testcases = [
+ # Test case 1: Model with native structured output support, non-streaming
+ {
+ "name": "native_structured_output_non_streaming",
+ "provider": "openai",
+ "model_name": "gpt-4o",
+ "support_structure_output": True,
+ "stream": False,
+ "json_schema": {"type": "object", "properties": {"name": {"type": "string"}}},
+ "expected_llm_response": LLMResult(
+ model="gpt-4o",
+ message=AssistantPromptMessage(content='{"name": "test"}'),
+ usage=create_mock_usage(prompt_tokens=10, completion_tokens=5),
+ ),
+ "expected_result_type": LLMResultWithStructuredOutput,
+ "should_raise": False,
+ },
+ # Test case 2: Model with native structured output support, streaming
+ {
+ "name": "native_structured_output_streaming",
+ "provider": "openai",
+ "model_name": "gpt-4o",
+ "support_structure_output": True,
+ "stream": True,
+ "json_schema": {"type": "object", "properties": {"name": {"type": "string"}}},
+ "expected_llm_response": [
+ LLMResultChunk(
+ model="gpt-4o",
+ prompt_messages=[UserPromptMessage(content="test")],
+ system_fingerprint="test",
+ delta=LLMResultChunkDelta(
+ index=0,
+ message=AssistantPromptMessage(content='{"name":'),
+ usage=create_mock_usage(prompt_tokens=10, completion_tokens=2),
+ ),
+ ),
+ LLMResultChunk(
+ model="gpt-4o",
+ prompt_messages=[UserPromptMessage(content="test")],
+ system_fingerprint="test",
+ delta=LLMResultChunkDelta(
+ index=0,
+ message=AssistantPromptMessage(content=' "test"}'),
+ usage=create_mock_usage(prompt_tokens=10, completion_tokens=3),
+ ),
+ ),
+ ],
+ "expected_result_type": "generator",
+ "should_raise": False,
+ },
+ # Test case 3: Model without native structured output support, non-streaming
+ {
+ "name": "prompt_based_structured_output_non_streaming",
+ "provider": "anthropic",
+ "model_name": "claude-3-sonnet",
+ "support_structure_output": False,
+ "stream": False,
+ "json_schema": {"type": "object", "properties": {"answer": {"type": "string"}}},
+ "expected_llm_response": LLMResult(
+ model="claude-3-sonnet",
+ message=AssistantPromptMessage(content='{"answer": "test response"}'),
+ usage=create_mock_usage(prompt_tokens=15, completion_tokens=8),
+ ),
+ "expected_result_type": LLMResultWithStructuredOutput,
+ "should_raise": False,
+ },
+ # Test case 4: Model without native structured output support, streaming
+ {
+ "name": "prompt_based_structured_output_streaming",
+ "provider": "anthropic",
+ "model_name": "claude-3-sonnet",
+ "support_structure_output": False,
+ "stream": True,
+ "json_schema": {"type": "object", "properties": {"answer": {"type": "string"}}},
+ "expected_llm_response": [
+ LLMResultChunk(
+ model="claude-3-sonnet",
+ prompt_messages=[UserPromptMessage(content="test")],
+ system_fingerprint="test",
+ delta=LLMResultChunkDelta(
+ index=0,
+ message=AssistantPromptMessage(content='{"answer": "test'),
+ usage=create_mock_usage(prompt_tokens=15, completion_tokens=3),
+ ),
+ ),
+ LLMResultChunk(
+ model="claude-3-sonnet",
+ prompt_messages=[UserPromptMessage(content="test")],
+ system_fingerprint="test",
+ delta=LLMResultChunkDelta(
+ index=0,
+ message=AssistantPromptMessage(content=' response"}'),
+ usage=create_mock_usage(prompt_tokens=15, completion_tokens=5),
+ ),
+ ),
+ ],
+ "expected_result_type": "generator",
+ "should_raise": False,
+ },
+ # Test case 5: Streaming with list content
+ {
+ "name": "streaming_with_list_content",
+ "provider": "openai",
+ "model_name": "gpt-4o",
+ "support_structure_output": True,
+ "stream": True,
+ "json_schema": {"type": "object", "properties": {"data": {"type": "string"}}},
+ "expected_llm_response": [
+ LLMResultChunk(
+ model="gpt-4o",
+ prompt_messages=[UserPromptMessage(content="test")],
+ system_fingerprint="test",
+ delta=LLMResultChunkDelta(
+ index=0,
+ message=AssistantPromptMessage(
+ content=[
+ TextPromptMessageContent(data='{"data":'),
+ ]
+ ),
+ usage=create_mock_usage(prompt_tokens=10, completion_tokens=2),
+ ),
+ ),
+ LLMResultChunk(
+ model="gpt-4o",
+ prompt_messages=[UserPromptMessage(content="test")],
+ system_fingerprint="test",
+ delta=LLMResultChunkDelta(
+ index=0,
+ message=AssistantPromptMessage(
+ content=[
+ TextPromptMessageContent(data=' "value"}'),
+ ]
+ ),
+ usage=create_mock_usage(prompt_tokens=10, completion_tokens=3),
+ ),
+ ),
+ ],
+ "expected_result_type": "generator",
+ "should_raise": False,
+ },
+ # Test case 6: Error case - non-string LLM response content (non-streaming)
+ {
+ "name": "error_non_string_content_non_streaming",
+ "provider": "openai",
+ "model_name": "gpt-4o",
+ "support_structure_output": True,
+ "stream": False,
+ "json_schema": {"type": "object", "properties": {"name": {"type": "string"}}},
+ "expected_llm_response": LLMResult(
+ model="gpt-4o",
+ message=AssistantPromptMessage(content=None), # Non-string content
+ usage=create_mock_usage(prompt_tokens=10, completion_tokens=5),
+ ),
+ "expected_result_type": None,
+ "should_raise": True,
+ "expected_error": OutputParserError,
+ },
+ # Test case 7: JSON repair scenario
+ {
+ "name": "json_repair_scenario",
+ "provider": "openai",
+ "model_name": "gpt-4o",
+ "support_structure_output": True,
+ "stream": False,
+ "json_schema": {"type": "object", "properties": {"name": {"type": "string"}}},
+ "expected_llm_response": LLMResult(
+ model="gpt-4o",
+ message=AssistantPromptMessage(content='{"name": "test"'), # Invalid JSON - missing closing brace
+ usage=create_mock_usage(prompt_tokens=10, completion_tokens=5),
+ ),
+ "expected_result_type": LLMResultWithStructuredOutput,
+ "should_raise": False,
+ },
+ # Test case 8: Model with parameter rules for response format
+ {
+ "name": "model_with_parameter_rules",
+ "provider": "openai",
+ "model_name": "gpt-4o",
+ "support_structure_output": True,
+ "stream": False,
+ "json_schema": {"type": "object", "properties": {"result": {"type": "string"}}},
+ "parameter_rules": [
+ MagicMock(name="response_format", options=["json_schema"], required=False),
+ ],
+ "expected_llm_response": LLMResult(
+ model="gpt-4o",
+ message=AssistantPromptMessage(content='{"result": "success"}'),
+ usage=create_mock_usage(prompt_tokens=10, completion_tokens=5),
+ ),
+ "expected_result_type": LLMResultWithStructuredOutput,
+ "should_raise": False,
+ },
+ # Test case 9: Model without native support but with JSON response format rules
+ {
+ "name": "non_native_with_json_rules",
+ "provider": "anthropic",
+ "model_name": "claude-3-sonnet",
+ "support_structure_output": False,
+ "stream": False,
+ "json_schema": {"type": "object", "properties": {"output": {"type": "string"}}},
+ "parameter_rules": [
+ MagicMock(name="response_format", options=["JSON"], required=False),
+ ],
+ "expected_llm_response": LLMResult(
+ model="claude-3-sonnet",
+ message=AssistantPromptMessage(content='{"output": "result"}'),
+ usage=create_mock_usage(prompt_tokens=15, completion_tokens=8),
+ ),
+ "expected_result_type": LLMResultWithStructuredOutput,
+ "should_raise": False,
+ },
+ ]
+
+ for case in testcases:
+ print(f"Running test case: {case['name']}")
+
+ # Setup model entity
+ model_schema = get_model_entity(case["provider"], case["model_name"], case["support_structure_output"])
+
+ # Add parameter rules if specified
+ if "parameter_rules" in case:
+ model_schema.parameter_rules = case["parameter_rules"]
+
+ # Setup model instance
+ model_instance = get_model_instance()
+ model_instance.invoke_llm.return_value = case["expected_llm_response"]
+
+ # Setup prompt messages
+ prompt_messages = [
+ SystemPromptMessage(content="You are a helpful assistant."),
+ UserPromptMessage(content="Generate a response according to the schema."),
+ ]
+
+ if case["should_raise"]:
+ # Test error cases
+ with pytest.raises(case["expected_error"]): # noqa: PT012
+ if case["stream"]:
+ result_generator = invoke_llm_with_structured_output(
+ provider=case["provider"],
+ model_schema=model_schema,
+ model_instance=model_instance,
+ prompt_messages=prompt_messages,
+ json_schema=case["json_schema"],
+ stream=case["stream"],
+ )
+ # Consume the generator to trigger the error
+ list(result_generator)
+ else:
+ invoke_llm_with_structured_output(
+ provider=case["provider"],
+ model_schema=model_schema,
+ model_instance=model_instance,
+ prompt_messages=prompt_messages,
+ json_schema=case["json_schema"],
+ stream=case["stream"],
+ )
+ else:
+ # Test successful cases
+ with patch("core.llm_generator.output_parser.structured_output.json_repair.loads") as mock_json_repair:
+ # Configure json_repair mock for cases that need it
+ if case["name"] == "json_repair_scenario":
+ mock_json_repair.return_value = {"name": "test"}
+
+ result = invoke_llm_with_structured_output(
+ provider=case["provider"],
+ model_schema=model_schema,
+ model_instance=model_instance,
+ prompt_messages=prompt_messages,
+ json_schema=case["json_schema"],
+ stream=case["stream"],
+ model_parameters={"temperature": 0.7, "max_tokens": 100},
+ user="test_user",
+ )
+
+ if case["expected_result_type"] == "generator":
+ # Test streaming results
+ assert hasattr(result, "__iter__")
+ chunks = list(result)
+ assert len(chunks) > 0
+
+ # Verify all chunks are LLMResultChunkWithStructuredOutput
+ for chunk in chunks[:-1]: # All except last
+ assert isinstance(chunk, LLMResultChunkWithStructuredOutput)
+ assert chunk.model == case["model_name"]
+
+ # Last chunk should have structured output
+ last_chunk = chunks[-1]
+ assert isinstance(last_chunk, LLMResultChunkWithStructuredOutput)
+ assert last_chunk.structured_output is not None
+ assert isinstance(last_chunk.structured_output, dict)
+ else:
+ # Test non-streaming results
+ assert isinstance(result, case["expected_result_type"])
+ assert result.model == case["model_name"]
+ assert result.structured_output is not None
+ assert isinstance(result.structured_output, dict)
+
+ # Verify model_instance.invoke_llm was called with correct parameters
+ model_instance.invoke_llm.assert_called_once()
+ call_args = model_instance.invoke_llm.call_args
+
+ assert call_args.kwargs["stream"] == case["stream"]
+ assert call_args.kwargs["user"] == "test_user"
+ assert "temperature" in call_args.kwargs["model_parameters"]
+ assert "max_tokens" in call_args.kwargs["model_parameters"]
+
+
+def test_parse_structured_output_edge_cases():
+ """Test edge cases for structured output parsing"""
+
+ # Test case with list that contains dict (reasoning model scenario)
+ testcase_list_with_dict = {
+ "name": "list_with_dict_parsing",
+ "provider": "deepseek",
+ "model_name": "deepseek-r1",
+ "support_structure_output": False,
+ "stream": False,
+ "json_schema": {"type": "object", "properties": {"thought": {"type": "string"}}},
+ "expected_llm_response": LLMResult(
+ model="deepseek-r1",
+ message=AssistantPromptMessage(content='[{"thought": "reasoning process"}, "other content"]'),
+ usage=create_mock_usage(prompt_tokens=10, completion_tokens=5),
+ ),
+ "expected_result_type": LLMResultWithStructuredOutput,
+ "should_raise": False,
+ }
+
+ # Setup for list parsing test
+ model_schema = get_model_entity(
+ testcase_list_with_dict["provider"],
+ testcase_list_with_dict["model_name"],
+ testcase_list_with_dict["support_structure_output"],
+ )
+
+ model_instance = get_model_instance()
+ model_instance.invoke_llm.return_value = testcase_list_with_dict["expected_llm_response"]
+
+ prompt_messages = [UserPromptMessage(content="Test reasoning")]
+
+ with patch("core.llm_generator.output_parser.structured_output.json_repair.loads") as mock_json_repair:
+ # Mock json_repair to return a list with dict
+ mock_json_repair.return_value = [{"thought": "reasoning process"}, "other content"]
+
+ result = invoke_llm_with_structured_output(
+ provider=testcase_list_with_dict["provider"],
+ model_schema=model_schema,
+ model_instance=model_instance,
+ prompt_messages=prompt_messages,
+ json_schema=testcase_list_with_dict["json_schema"],
+ stream=testcase_list_with_dict["stream"],
+ )
+
+ assert isinstance(result, LLMResultWithStructuredOutput)
+ assert result.structured_output == {"thought": "reasoning process"}
+
+
+def test_model_specific_schema_preparation():
+ """Test schema preparation for different model types"""
+
+ # Test Gemini model
+ gemini_case = {
+ "provider": "google",
+ "model_name": "gemini-pro",
+ "support_structure_output": True,
+ "stream": False,
+ "json_schema": {"type": "object", "properties": {"result": {"type": "boolean"}}, "additionalProperties": False},
+ }
+
+ model_schema = get_model_entity(
+ gemini_case["provider"], gemini_case["model_name"], gemini_case["support_structure_output"]
+ )
+
+ model_instance = get_model_instance()
+ model_instance.invoke_llm.return_value = LLMResult(
+ model="gemini-pro",
+ message=AssistantPromptMessage(content='{"result": "true"}'),
+ usage=create_mock_usage(prompt_tokens=10, completion_tokens=5),
+ )
+
+ prompt_messages = [UserPromptMessage(content="Test")]
+
+ result = invoke_llm_with_structured_output(
+ provider=gemini_case["provider"],
+ model_schema=model_schema,
+ model_instance=model_instance,
+ prompt_messages=prompt_messages,
+ json_schema=gemini_case["json_schema"],
+ stream=gemini_case["stream"],
+ )
+
+ assert isinstance(result, LLMResultWithStructuredOutput)
+
+ # Verify model_instance.invoke_llm was called and check the schema preparation
+ model_instance.invoke_llm.assert_called_once()
+ call_args = model_instance.invoke_llm.call_args
+
+ # For Gemini, the schema should not have additionalProperties and boolean should be converted to string
+ assert "json_schema" in call_args.kwargs["model_parameters"]
diff --git a/api/uv.lock b/api/uv.lock
index c900191ceb..21b6b20f53 100644
--- a/api/uv.lock
+++ b/api/uv.lock
@@ -36,7 +36,7 @@ wheels = [
[[package]]
name = "aiohttp"
-version = "3.12.7"
+version = "3.12.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohappyeyeballs" },
@@ -47,42 +47,42 @@ dependencies = [
{ name = "propcache" },
{ name = "yarl" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/eb/62/95588e933dfea06a3af0332990bd19f6768f8f37fa4c0fe33fe4c55cf9d0/aiohttp-3.12.7.tar.gz", hash = "sha256:08bf55b216c779eddb6e41c1841c17d7ddd12776c7d7b36051c0a292a9ca828e", size = 7806530, upload-time = "2025-06-02T16:34:10.399Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/af/19/37560cc111d6fd95ff6c4bd14445e3c629269fce406c89cc7a69a2865ecf/aiohttp-3.12.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:388b5947aa6931ef4ce3ed4edde6853e84980677886992cfadcf733dd06eed63", size = 707169, upload-time = "2025-06-02T16:31:39.107Z" },
- { url = "https://files.pythonhosted.org/packages/b9/18/29bbefb094f81a687473c1d31391bf8a4c48c7b5b8559c3679fc14e67597/aiohttp-3.12.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9ed5af1cce257cca27a3e920b003b3b397f63418a203064b7d804ea3b45782af", size = 479443, upload-time = "2025-06-02T16:31:41.185Z" },
- { url = "https://files.pythonhosted.org/packages/cf/7d/119f3e012c75a3fe38f86ac1d77f1452779e0e940770d5827d4e62aa5655/aiohttp-3.12.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f466ae8f9c02993b7d167be685bdbeb527cf254a3cfcc757697e0e336399d0a2", size = 467706, upload-time = "2025-06-02T16:31:43.401Z" },
- { url = "https://files.pythonhosted.org/packages/83/f1/f61d8573d648e17347ab9a112063e4363664b5b6100792467fbb26028bde/aiohttp-3.12.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2be095a420a9f9a12eff343d877ae180dd919238b539431af08cef929e874759", size = 1737902, upload-time = "2025-06-02T16:31:45.948Z" },
- { url = "https://files.pythonhosted.org/packages/4b/f8/7a8a000bc63de3c79aaa8f03b0784e29e9982276f4579c5e2e56d560e403/aiohttp-3.12.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b058cf2ba6adba699960d7bc403411c8a99ab5d3e5ea3eb01473638ae7d1a30e", size = 1686569, upload-time = "2025-06-02T16:31:47.749Z" },
- { url = "https://files.pythonhosted.org/packages/5c/4e/29a5b35ca9a598f51dc7deff4e8403bf813988f30a8b250e25a8442641b7/aiohttp-3.12.7-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b6a660163b055686dbb0acc961978fd14537eba5d9da6cbdb4dced7a8d3be1a", size = 1785359, upload-time = "2025-06-02T16:31:49.687Z" },
- { url = "https://files.pythonhosted.org/packages/f9/36/0521398a69c40ac24c659b130597e2544cde1d7dd00291b8a6206bb552d0/aiohttp-3.12.7-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d741923905f267ad5d5c8f86a56f9d2beac9f32a36c217c5d9ef65cd74fd8ca0", size = 1824408, upload-time = "2025-06-02T16:31:51.518Z" },
- { url = "https://files.pythonhosted.org/packages/c1/41/79506d76da96399b6b700acbe10b14291547a3b49a1cc7ed2c5edaa199ce/aiohttp-3.12.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:519f5454b6018158ae0e789b8f6a88726c47dd680982eb318ef3ca4dee727314", size = 1726867, upload-time = "2025-06-02T16:31:53.277Z" },
- { url = "https://files.pythonhosted.org/packages/32/d1/d59ed16962934b46c7569d04af2dc9638a38ae5812680b9d6c7ee42d770e/aiohttp-3.12.7-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e4eebfe470e22cc4b374d7e32c07e96d777a5c0fa51f3824de68e697da037ec", size = 1663943, upload-time = "2025-06-02T16:31:55.094Z" },
- { url = "https://files.pythonhosted.org/packages/15/d5/971d1b277e6a3d5b679f0c9ba076c343a5125ea2eacc51c23ea7d875d43a/aiohttp-3.12.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:74ff39445f94923cf595e9e6dd602ecbe66b12364e2207e61342b8834417f8da", size = 1712217, upload-time = "2025-06-02T16:31:57.336Z" },
- { url = "https://files.pythonhosted.org/packages/e1/9c/c21fd0ba87772f3d1d43cdbfcfd40fe29f37d36a5d73997a8a4d4d1485c3/aiohttp-3.12.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:77cb9dba16486ecfeac8076763600b9714941e0ff696e53a30e8d408d9a196ca", size = 1707375, upload-time = "2025-06-02T16:31:59.12Z" },
- { url = "https://files.pythonhosted.org/packages/85/48/bb97ef3a694df852b70e6f5c1aaf3621a3a26b35f0a0d90481f3fdb1ce8b/aiohttp-3.12.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a7b3b9cbe83e3918a1918b0de274884f17b64224c1c9210a6fb0f7c10d246636", size = 1687561, upload-time = "2025-06-02T16:32:01.385Z" },
- { url = "https://files.pythonhosted.org/packages/d5/75/0b85f30ba9eb1dbdb5d3a53d3a0db29990220f69187acb24d06903686c5d/aiohttp-3.12.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6055f53c70938498884e71ca966abe8e9e7558489e13a7e40b6384dee7230d1d", size = 1781163, upload-time = "2025-06-02T16:32:03.176Z" },
- { url = "https://files.pythonhosted.org/packages/92/51/6350a4c485c7d2fb794101d46085c3830485ec1c37738d8af6c9c5ed8e1a/aiohttp-3.12.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8493a42d5b2a736c6804239b985feebeea1c60f8fcb46a3607d6dce3c1a42b12", size = 1801624, upload-time = "2025-06-02T16:32:05.027Z" },
- { url = "https://files.pythonhosted.org/packages/4f/dd/6a75eaaac93b5552090e42c38a576062028ce4af50f0b50ac550332d332c/aiohttp-3.12.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:372f2237cade45f563d973c2a913895f2699a892c0eb11c55c6880b6f0acf219", size = 1714679, upload-time = "2025-06-02T16:32:06.837Z" },
- { url = "https://files.pythonhosted.org/packages/de/ad/0574964387d8eed7fcd0c2fe6ef20c4affe0b265c710938d5fffdb3776b5/aiohttp-3.12.7-cp311-cp311-win32.whl", hash = "sha256:41f686749a099b507563a5c0cb4fd77367b05448a2c1758784ad506a28e9e579", size = 424709, upload-time = "2025-06-02T16:32:08.671Z" },
- { url = "https://files.pythonhosted.org/packages/4f/ab/6b82b43abb0990e4452aaef509cf1403ab50c04b5c090f92fb1b255fb319/aiohttp-3.12.7-cp311-cp311-win_amd64.whl", hash = "sha256:7a3691583470d4397aca70fbf8e0f0778b63a2c2a6a23263bdeeb68395972f29", size = 449100, upload-time = "2025-06-02T16:32:10.373Z" },
- { url = "https://files.pythonhosted.org/packages/5d/65/0bd8ccbffa33ee69db9f5c43f3f62fb8b600b607388e9a8deab8962d0523/aiohttp-3.12.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9b9345918f5b5156a5712c37d1d331baf320df67547ea032a49a609b773c3606", size = 698263, upload-time = "2025-06-02T16:32:12.107Z" },
- { url = "https://files.pythonhosted.org/packages/99/64/a48a8abc4e684fb447d1f7b61e7adcb19865b91e20b50595f49b2942fbb3/aiohttp-3.12.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3091b4883f405dbabeb9ea821a25dec16d03a51c3e0d2752fc3ab48b652bf196", size = 472877, upload-time = "2025-06-02T16:32:14.386Z" },
- { url = "https://files.pythonhosted.org/packages/7d/e4/994bc56a7d7733e9cd1f45db8b656e78d51d7a61cefff8043ec4f7d4a23f/aiohttp-3.12.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:97fd97abd4cf199eff4041d0346a7dc68b60deab177f01de87283be513ffc3ab", size = 465716, upload-time = "2025-06-02T16:32:16.108Z" },
- { url = "https://files.pythonhosted.org/packages/39/b0/bddc489288a0e3b05fa05387db9caebc38577204a17db0d5428abae524ba/aiohttp-3.12.7-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a5938973105cd5ff17176e8cb36bc19cac7c82ae7c58c0dbd7e023972d0c708", size = 1712513, upload-time = "2025-06-02T16:32:17.898Z" },
- { url = "https://files.pythonhosted.org/packages/4d/4a/c06d3ce0dc5f96338cc8d18da57d74608585a3751234eeef5952e4f48ade/aiohttp-3.12.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e506ae5c4c05d1a1e87edd64b994cea2d49385d41d32e1c6be8764f31cf2245c", size = 1695167, upload-time = "2025-06-02T16:32:20.131Z" },
- { url = "https://files.pythonhosted.org/packages/79/ec/e847fdfe2b1c1f1a2b0ba5343a9b2bd033a0545f8eaf1f7894a6614473ae/aiohttp-3.12.7-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b780b402e6361c4cfcec252580f5ecdd86cb68376520ac34748d3f8b262dd598", size = 1750261, upload-time = "2025-06-02T16:32:22.717Z" },
- { url = "https://files.pythonhosted.org/packages/2c/5e/b832ff59737d99cc5ae51b737c52976d19990ccee922ba6fe811f615e7f9/aiohttp-3.12.7-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf981bbfb7ff2ebc1b3bfae49d2efe2c51ca1cf3d90867f47c310df65398e85e", size = 1796416, upload-time = "2025-06-02T16:32:25.15Z" },
- { url = "https://files.pythonhosted.org/packages/e0/ff/51ae87efce9b53aafd384179f58923bf178f561897cf80054a440fdf8363/aiohttp-3.12.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94f98e0e5a49f89b252e115844f756c04fc8050f38252a32a3dd994ce8121f10", size = 1715855, upload-time = "2025-06-02T16:32:27.236Z" },
- { url = "https://files.pythonhosted.org/packages/b1/54/5a77116498f84d2503f5588e687eccfa43a85aa2450bc195ee6e5bb75695/aiohttp-3.12.7-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:410e96cc6824fc4ced9703fb2ac2d06c6190d21fc6f5b588f62b1918628449c1", size = 1631656, upload-time = "2025-06-02T16:32:29.15Z" },
- { url = "https://files.pythonhosted.org/packages/46/34/554220592f8ade7f3cabebfb9325e95078f842140f293ced3ab977fd13ec/aiohttp-3.12.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:43e93987fe9df4349db8deae7c391695538c35e4ba893133c7e823234f6e4537", size = 1692718, upload-time = "2025-06-02T16:32:31.295Z" },
- { url = "https://files.pythonhosted.org/packages/ff/9d/ae7103bb8c73c3521e38ae8cde301ddc937024b1681ce134bb1ef01be7d0/aiohttp-3.12.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb3f3dcb59f3e16819a1c7d3fa32e7b87255b661c1e139a1b5940bde270704ab", size = 1714171, upload-time = "2025-06-02T16:32:33.767Z" },
- { url = "https://files.pythonhosted.org/packages/5d/4d/9b8b8f362e36392939019340321f7efcc1807bb2c4cdea8eb1019d3398ff/aiohttp-3.12.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4a46fe4a4c66b2712059e48a8384eb93565fbe3251af4844860fed846ef4ca75", size = 1654822, upload-time = "2025-06-02T16:32:36.23Z" },
- { url = "https://files.pythonhosted.org/packages/48/30/0ca82df423ee346206bc167852c825cd210c11d2f1fa0064a2a55d7f60d5/aiohttp-3.12.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ad01793164661af70918490ef8efc2c09df7a3c686b6c84ca90a2d69cdbc3911", size = 1734385, upload-time = "2025-06-02T16:32:38.171Z" },
- { url = "https://files.pythonhosted.org/packages/43/bd/96d12318c0f82ac8323bd4459ee26291ad220f688988077a21e538b0872c/aiohttp-3.12.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e85c6833be3f49cead2e7bc79080e5c18d6dab9af32226ab5a01dc20c523e7d9", size = 1762356, upload-time = "2025-06-02T16:32:40.142Z" },
- { url = "https://files.pythonhosted.org/packages/6c/39/7a9b706bf42f293415584d60cf35e80d0558929ab70e72cb40b747f0dfc7/aiohttp-3.12.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3c9f52149d8249566e72c50c7985c2345521b3b78f84aa86f6f492cd50b14793", size = 1721970, upload-time = "2025-06-02T16:32:42.187Z" },
- { url = "https://files.pythonhosted.org/packages/19/f2/8899367a52dec8100f43036e5a792cfdbae317bf3a80549da90290083ff4/aiohttp-3.12.7-cp312-cp312-win32.whl", hash = "sha256:0e1c33ac0f6a396bcefe9c1d52c9d38a051861885a5c102ca5c8298aba0108fa", size = 419443, upload-time = "2025-06-02T16:32:44.335Z" },
- { url = "https://files.pythonhosted.org/packages/e8/34/ad5225b4edbcc23496537011d67ef1a147c03205c07340f4a50993b219b9/aiohttp-3.12.7-cp312-cp312-win_amd64.whl", hash = "sha256:b4aed5233a9d13e34e8624ecb798533aa2da97e7048cc69671b7a6d7a2efe7e8", size = 445544, upload-time = "2025-06-02T16:32:46.631Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/42/6e/ab88e7cb2a4058bed2f7870276454f85a7c56cd6da79349eb314fc7bbcaa/aiohttp-3.12.13.tar.gz", hash = "sha256:47e2da578528264a12e4e3dd8dd72a7289e5f812758fe086473fab037a10fcce", size = 7819160, upload-time = "2025-06-14T15:15:41.354Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6a/65/5566b49553bf20ffed6041c665a5504fb047cefdef1b701407b8ce1a47c4/aiohttp-3.12.13-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7c229b1437aa2576b99384e4be668af1db84b31a45305d02f61f5497cfa6f60c", size = 709401, upload-time = "2025-06-14T15:13:30.774Z" },
+ { url = "https://files.pythonhosted.org/packages/14/b5/48e4cc61b54850bdfafa8fe0b641ab35ad53d8e5a65ab22b310e0902fa42/aiohttp-3.12.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04076d8c63471e51e3689c93940775dc3d12d855c0c80d18ac5a1c68f0904358", size = 481669, upload-time = "2025-06-14T15:13:32.316Z" },
+ { url = "https://files.pythonhosted.org/packages/04/4f/e3f95c8b2a20a0437d51d41d5ccc4a02970d8ad59352efb43ea2841bd08e/aiohttp-3.12.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55683615813ce3601640cfaa1041174dc956d28ba0511c8cbd75273eb0587014", size = 469933, upload-time = "2025-06-14T15:13:34.104Z" },
+ { url = "https://files.pythonhosted.org/packages/41/c9/c5269f3b6453b1cfbd2cfbb6a777d718c5f086a3727f576c51a468b03ae2/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:921bc91e602d7506d37643e77819cb0b840d4ebb5f8d6408423af3d3bf79a7b7", size = 1740128, upload-time = "2025-06-14T15:13:35.604Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/49/a3f76caa62773d33d0cfaa842bdf5789a78749dbfe697df38ab1badff369/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e72d17fe0974ddeae8ed86db297e23dba39c7ac36d84acdbb53df2e18505a013", size = 1688796, upload-time = "2025-06-14T15:13:37.125Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/e4/556fccc4576dc22bf18554b64cc873b1a3e5429a5bdb7bbef7f5d0bc7664/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0653d15587909a52e024a261943cf1c5bdc69acb71f411b0dd5966d065a51a47", size = 1787589, upload-time = "2025-06-14T15:13:38.745Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/3d/d81b13ed48e1a46734f848e26d55a7391708421a80336e341d2aef3b6db2/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a77b48997c66722c65e157c06c74332cdf9c7ad00494b85ec43f324e5c5a9b9a", size = 1826635, upload-time = "2025-06-14T15:13:40.733Z" },
+ { url = "https://files.pythonhosted.org/packages/75/a5/472e25f347da88459188cdaadd1f108f6292f8a25e62d226e63f860486d1/aiohttp-3.12.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6946bae55fd36cfb8e4092c921075cde029c71c7cb571d72f1079d1e4e013bc", size = 1729095, upload-time = "2025-06-14T15:13:42.312Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/fe/322a78b9ac1725bfc59dfc301a5342e73d817592828e4445bd8f4ff83489/aiohttp-3.12.13-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f95db8c8b219bcf294a53742c7bda49b80ceb9d577c8e7aa075612b7f39ffb7", size = 1666170, upload-time = "2025-06-14T15:13:44.884Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/77/ec80912270e231d5e3839dbd6c065472b9920a159ec8a1895cf868c2708e/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:03d5eb3cfb4949ab4c74822fb3326cd9655c2b9fe22e4257e2100d44215b2e2b", size = 1714444, upload-time = "2025-06-14T15:13:46.401Z" },
+ { url = "https://files.pythonhosted.org/packages/21/b2/fb5aedbcb2b58d4180e58500e7c23ff8593258c27c089abfbcc7db65bd40/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6383dd0ffa15515283c26cbf41ac8e6705aab54b4cbb77bdb8935a713a89bee9", size = 1709604, upload-time = "2025-06-14T15:13:48.377Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/15/a94c05f7c4dc8904f80b6001ad6e07e035c58a8ebfcc15e6b5d58500c858/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6548a411bc8219b45ba2577716493aa63b12803d1e5dc70508c539d0db8dbf5a", size = 1689786, upload-time = "2025-06-14T15:13:50.401Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/fd/0d2e618388f7a7a4441eed578b626bda9ec6b5361cd2954cfc5ab39aa170/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:81b0fcbfe59a4ca41dc8f635c2a4a71e63f75168cc91026c61be665945739e2d", size = 1783389, upload-time = "2025-06-14T15:13:51.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/6b/6986d0c75996ef7e64ff7619b9b7449b1d1cbbe05c6755e65d92f1784fe9/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6a83797a0174e7995e5edce9dcecc517c642eb43bc3cba296d4512edf346eee2", size = 1803853, upload-time = "2025-06-14T15:13:53.533Z" },
+ { url = "https://files.pythonhosted.org/packages/21/65/cd37b38f6655d95dd07d496b6d2f3924f579c43fd64b0e32b547b9c24df5/aiohttp-3.12.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5734d8469a5633a4e9ffdf9983ff7cdb512524645c7a3d4bc8a3de45b935ac3", size = 1716909, upload-time = "2025-06-14T15:13:55.148Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/20/2de7012427dc116714c38ca564467f6143aec3d5eca3768848d62aa43e62/aiohttp-3.12.13-cp311-cp311-win32.whl", hash = "sha256:fef8d50dfa482925bb6b4c208b40d8e9fa54cecba923dc65b825a72eed9a5dbd", size = 427036, upload-time = "2025-06-14T15:13:57.076Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/b6/98518bcc615ef998a64bef371178b9afc98ee25895b4f476c428fade2220/aiohttp-3.12.13-cp311-cp311-win_amd64.whl", hash = "sha256:9a27da9c3b5ed9d04c36ad2df65b38a96a37e9cfba6f1381b842d05d98e6afe9", size = 451427, upload-time = "2025-06-14T15:13:58.505Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/6a/ce40e329788013cd190b1d62bbabb2b6a9673ecb6d836298635b939562ef/aiohttp-3.12.13-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0aa580cf80558557285b49452151b9c69f2fa3ad94c5c9e76e684719a8791b73", size = 700491, upload-time = "2025-06-14T15:14:00.048Z" },
+ { url = "https://files.pythonhosted.org/packages/28/d9/7150d5cf9163e05081f1c5c64a0cdf3c32d2f56e2ac95db2a28fe90eca69/aiohttp-3.12.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b103a7e414b57e6939cc4dece8e282cfb22043efd0c7298044f6594cf83ab347", size = 475104, upload-time = "2025-06-14T15:14:01.691Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/91/d42ba4aed039ce6e449b3e2db694328756c152a79804e64e3da5bc19dffc/aiohttp-3.12.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f64e748e9e741d2eccff9597d09fb3cd962210e5b5716047cbb646dc8fe06f", size = 467948, upload-time = "2025-06-14T15:14:03.561Z" },
+ { url = "https://files.pythonhosted.org/packages/99/3b/06f0a632775946981d7c4e5a865cddb6e8dfdbaed2f56f9ade7bb4a1039b/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c955989bf4c696d2ededc6b0ccb85a73623ae6e112439398935362bacfaaf6", size = 1714742, upload-time = "2025-06-14T15:14:05.558Z" },
+ { url = "https://files.pythonhosted.org/packages/92/a6/2552eebad9ec5e3581a89256276009e6a974dc0793632796af144df8b740/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d640191016763fab76072c87d8854a19e8e65d7a6fcfcbf017926bdbbb30a7e5", size = 1697393, upload-time = "2025-06-14T15:14:07.194Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/9f/bd08fdde114b3fec7a021381b537b21920cdd2aa29ad48c5dffd8ee314f1/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4dc507481266b410dede95dd9f26c8d6f5a14315372cc48a6e43eac652237d9b", size = 1752486, upload-time = "2025-06-14T15:14:08.808Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/e1/affdea8723aec5bd0959171b5490dccd9a91fcc505c8c26c9f1dca73474d/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8a94daa873465d518db073bd95d75f14302e0208a08e8c942b2f3f1c07288a75", size = 1798643, upload-time = "2025-06-14T15:14:10.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/9d/666d856cc3af3a62ae86393baa3074cc1d591a47d89dc3bf16f6eb2c8d32/aiohttp-3.12.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f52420cde4ce0bb9425a375d95577fe082cb5721ecb61da3049b55189e4e6", size = 1718082, upload-time = "2025-06-14T15:14:12.38Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/ce/3c185293843d17be063dada45efd2712bb6bf6370b37104b4eda908ffdbd/aiohttp-3.12.13-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f7df1f620ec40f1a7fbcb99ea17d7326ea6996715e78f71a1c9a021e31b96b8", size = 1633884, upload-time = "2025-06-14T15:14:14.415Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/5b/f3413f4b238113be35dfd6794e65029250d4b93caa0974ca572217745bdb/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3062d4ad53b36e17796dce1c0d6da0ad27a015c321e663657ba1cc7659cfc710", size = 1694943, upload-time = "2025-06-14T15:14:16.48Z" },
+ { url = "https://files.pythonhosted.org/packages/82/c8/0e56e8bf12081faca85d14a6929ad5c1263c146149cd66caa7bc12255b6d/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8605e22d2a86b8e51ffb5253d9045ea73683d92d47c0b1438e11a359bdb94462", size = 1716398, upload-time = "2025-06-14T15:14:18.589Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/f3/33192b4761f7f9b2f7f4281365d925d663629cfaea093a64b658b94fc8e1/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:54fbbe6beafc2820de71ece2198458a711e224e116efefa01b7969f3e2b3ddae", size = 1657051, upload-time = "2025-06-14T15:14:20.223Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/0b/26ddd91ca8f84c48452431cb4c5dd9523b13bc0c9766bda468e072ac9e29/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:050bd277dfc3768b606fd4eae79dd58ceda67d8b0b3c565656a89ae34525d15e", size = 1736611, upload-time = "2025-06-14T15:14:21.988Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/8d/e04569aae853302648e2c138a680a6a2f02e374c5b6711732b29f1e129cc/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2637a60910b58f50f22379b6797466c3aa6ae28a6ab6404e09175ce4955b4e6a", size = 1764586, upload-time = "2025-06-14T15:14:23.979Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/98/c193c1d1198571d988454e4ed75adc21c55af247a9fda08236602921c8c8/aiohttp-3.12.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e986067357550d1aaa21cfe9897fa19e680110551518a5a7cf44e6c5638cb8b5", size = 1724197, upload-time = "2025-06-14T15:14:25.692Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/9e/07bb8aa11eec762c6b1ff61575eeeb2657df11ab3d3abfa528d95f3e9337/aiohttp-3.12.13-cp312-cp312-win32.whl", hash = "sha256:ac941a80aeea2aaae2875c9500861a3ba356f9ff17b9cb2dbfb5cbf91baaf5bf", size = 421771, upload-time = "2025-06-14T15:14:27.364Z" },
+ { url = "https://files.pythonhosted.org/packages/52/66/3ce877e56ec0813069cdc9607cd979575859c597b6fb9b4182c6d5f31886/aiohttp-3.12.13-cp312-cp312-win_amd64.whl", hash = "sha256:671f41e6146a749b6c81cb7fd07f5a8356d46febdaaaf07b0e774ff04830461e", size = 447869, upload-time = "2025-06-14T15:14:29.05Z" },
]
[[package]]
@@ -99,28 +99,29 @@ wheels = [
[[package]]
name = "aiosignal"
-version = "1.3.2"
+version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "frozenlist" },
+ { name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ba/b5/6d55e80f6d8a08ce22b982eafa278d823b541c925f11ee774b0b9c43473d/aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54", size = 19424, upload-time = "2024-12-13T17:10:40.86Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ec/6a/bc7e17a3e87a2985d3e8f4da4cd0f481060eb78fb08596c42be62c90a4d9/aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5", size = 7597, upload-time = "2024-12-13T17:10:38.469Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "alembic"
-version = "1.16.1"
+version = "1.16.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/20/89/bfb4fe86e3fc3972d35431af7bedbc60fa606e8b17196704a1747f7aa4c3/alembic-1.16.1.tar.gz", hash = "sha256:43d37ba24b3d17bc1eb1024fe0f51cd1dc95aeb5464594a02c6bb9ca9864bfa4", size = 1955006, upload-time = "2025-05-21T23:11:05.991Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b9/40/28683414cc8711035a65256ca689e159471aa9ef08e8741ad1605bc01066/alembic-1.16.3.tar.gz", hash = "sha256:18ad13c1f40a5796deee4b2346d1a9c382f44b8af98053897484fa6cf88025e4", size = 1967462, upload-time = "2025-07-08T18:57:50.991Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/31/59/565286efff3692c5716c212202af61466480f6357c4ae3089d4453bff1f3/alembic-1.16.1-py3-none-any.whl", hash = "sha256:0cdd48acada30d93aa1035767d67dff25702f8de74d7c3919f2e8492c8db2e67", size = 242488, upload-time = "2025-05-21T23:11:07.783Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/68/1dea77887af7304528ea944c355d769a7ccc4599d3a23bd39182486deb42/alembic-1.16.3-py3-none-any.whl", hash = "sha256:70a7c7829b792de52d08ca0e3aefaf060687cb8ed6bebfa557e597a1a5e5a481", size = 246933, upload-time = "2025-07-08T18:57:52.793Z" },
]
[[package]]
@@ -143,12 +144,9 @@ sdist = { url = "https://files.pythonhosted.org/packages/a0/87/1d7019d23891897cb
[[package]]
name = "alibabacloud-endpoint-util"
-version = "0.0.3"
+version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "alibabacloud-tea" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/24/54/56736a0f224b3f2b65bb9c9ff9e20fa390351a7587c99f19ca1f8d596ae1/alibabacloud_endpoint_util-0.0.3.tar.gz", hash = "sha256:8c0efb76fdcc3af4ca716ef24bbce770201a3f83f98c0afcf81655f684b9c7d2", size = 2756, upload-time = "2020-09-16T07:27:42.19Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/92/7d/8cc92a95c920e344835b005af6ea45a0db98763ad6ad19299d26892e6c8d/alibabacloud_endpoint_util-0.0.4.tar.gz", hash = "sha256:a593eb8ddd8168d5dc2216cd33111b144f9189fcd6e9ca20e48f358a739bbf90", size = 2813, upload-time = "2025-06-12T07:20:52.572Z" }
[[package]]
name = "alibabacloud-gateway-spi"
@@ -246,7 +244,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/22/8a/ef8ddf5ee0350984c
[[package]]
name = "alibabacloud-tea-openapi"
-version = "0.3.15"
+version = "0.3.16"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-credentials" },
@@ -255,7 +253,7 @@ dependencies = [
{ name = "alibabacloud-tea-util" },
{ name = "alibabacloud-tea-xml" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/be/cb/f1b10b1da37e4c0de2aa9ca1e7153a6960a7f2dc496664e85fdc8b621f84/alibabacloud_tea_openapi-0.3.15.tar.gz", hash = "sha256:56a0aa6d51d8cf18c0cf3d219d861f4697f59d3e17fa6726b1101826d93988a2", size = 13021, upload-time = "2025-05-06T12:56:29.402Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/09/be/f594e79625e5ccfcfe7f12d7d70709a3c59e920878469c998886211c850d/alibabacloud_tea_openapi-0.3.16.tar.gz", hash = "sha256:6bffed8278597592e67860156f424bde4173a6599d7b6039fb640a3612bae292", size = 13087, upload-time = "2025-07-04T09:30:10.689Z" }
[[package]]
name = "alibabacloud-tea-util"
@@ -268,12 +266,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/23/18/35be17103c8f40f9e
[[package]]
name = "alibabacloud-tea-xml"
-version = "0.0.2"
+version = "0.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alibabacloud-tea" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/3b/30/f934051b1f65525d450cb225e4ac81dc3b77d808b0f79d51059d2a7ad3d3/alibabacloud_tea_xml-0.0.2.tar.gz", hash = "sha256:f0135e8148fd7d9c1f029db161863f37f144f837c280cba16c2edeb2f9c549d8", size = 3378, upload-time = "2020-07-02T09:03:55.866Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/32/eb/5e82e419c3061823f3feae9b5681588762929dc4da0176667297c2784c1a/alibabacloud_tea_xml-0.0.3.tar.gz", hash = "sha256:979cb51fadf43de77f41c69fc69c12529728919f849723eb0cd24eb7b048a90c", size = 3466, upload-time = "2025-07-01T08:04:55.144Z" }
[[package]]
name = "aliyun-python-sdk-core"
@@ -353,13 +351,31 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" },
]
+[[package]]
+name = "arize-phoenix-otel"
+version = "0.9.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "openinference-instrumentation" },
+ { name = "openinference-semantic-conventions" },
+ { name = "opentelemetry-exporter-otlp" },
+ { name = "opentelemetry-proto" },
+ { name = "opentelemetry-sdk" },
+ { name = "opentelemetry-semantic-conventions" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/27/b9/8c89191eb46915e9ba7bdb473e2fb1c510b7db3635ae5ede5e65b2176b9d/arize_phoenix_otel-0.9.2.tar.gz", hash = "sha256:a48c7d41f3ac60dc75b037f036bf3306d2af4af371cdb55e247e67957749bc31", size = 11599, upload-time = "2025-04-14T22:05:28.637Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3a/3d/f64136a758c649e883315939f30fe51ad0747024b0db05fd78450801a78d/arize_phoenix_otel-0.9.2-py3-none-any.whl", hash = "sha256:5286b33c58b596ef8edd9a4255ee00fd74f774b1e5dbd9393e77e87870a14d76", size = 12560, upload-time = "2025-04-14T22:05:27.162Z" },
+]
+
[[package]]
name = "asgiref"
-version = "3.8.1"
+version = "3.9.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload-time = "2024-03-22T14:39:36.863Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/90/61/0aa957eec22ff70b830b22ff91f825e70e1ef732c06666a805730f28b36b/asgiref-3.9.1.tar.gz", hash = "sha256:a5ab6582236218e5ef1648f242fd9f10626cfd4de8dc377db215d5d5098e3142", size = 36870, upload-time = "2025-07-08T09:07:43.344Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/3c/0464dcada90d5da0e71018c04a140ad6349558afb30b3051b4264cc5b965/asgiref-3.9.1-py3-none-any.whl", hash = "sha256:f3bba7092a48005b5f5bacd747d36ee4a5a61f4a269a6df590b43144355ebd2c", size = 23790, upload-time = "2025-07-08T09:07:41.548Z" },
]
[[package]]
@@ -394,16 +410,16 @@ wheels = [
[[package]]
name = "azure-core"
-version = "1.34.0"
+version = "1.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
{ name = "six" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/c9/29/ff7a519a315e41c85bab92a7478c6acd1cf0b14353139a08caee4c691f77/azure_core-1.34.0.tar.gz", hash = "sha256:bdb544989f246a0ad1c85d72eeb45f2f835afdcbc5b45e43f0dbde7461c81ece", size = 297999, upload-time = "2025-05-01T23:17:27.59Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/89/f53968635b1b2e53e4aad2dd641488929fef4ca9dfb0b97927fa7697ddf3/azure_core-1.35.0.tar.gz", hash = "sha256:c0be528489485e9ede59b6971eb63c1eaacf83ef53001bfe3904e475e972be5c", size = 339689, upload-time = "2025-07-03T00:55:23.496Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/84/9e/5c87b49f65bb16571599bc789857d0ded2f53014d3392bc88a5d1f3ad779/azure_core-1.34.0-py3-none-any.whl", hash = "sha256:0615d3b756beccdb6624d1c0ae97284f38b78fb59a2a9839bf927c66fbbdddd6", size = 207409, upload-time = "2025-05-01T23:17:29.818Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/78/bf94897361fdd650850f0f2e405b2293e2f12808239046232bdedf554301/azure_core-1.35.0-py3-none-any.whl", hash = "sha256:8db78c72868a58f3de8991eb4d22c4d368fae226dac1002998d6c50437e7dad1", size = 210708, upload-time = "2025-07-03T00:55:25.238Z" },
]
[[package]]
@@ -544,16 +560,16 @@ wheels = [
[[package]]
name = "boto3-stubs"
-version = "1.38.28"
+version = "1.39.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore-stubs" },
{ name = "types-s3transfer" },
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/7b/dc/c4b318bdf66ff4973d3cbf0f218e47bdf558b94d921c8ff27d401e8ca4f9/boto3_stubs-1.38.28.tar.gz", hash = "sha256:fae8e009ea4d810b77e49d88247183b5d8d153bf268ebde8b4abdf58a701802f", size = 99065, upload-time = "2025-06-02T19:42:51.659Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/f0/ea/85b9940d6eedc04d0c6febf24d27311b6ee54f85ccc37192eb4db0dff5d6/boto3_stubs-1.39.3.tar.gz", hash = "sha256:9aad443b1d690951fd9ccb6fa20ad387bd0b1054c704566ff65dd0043a63fc26", size = 99947, upload-time = "2025-07-03T19:28:15.602Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8b/4a/d70ef76080e0d37e45d07d65801a6680f5bc1d156333a4e5d0ca6103d45f/boto3_stubs-1.38.28-py3-none-any.whl", hash = "sha256:accd8c0943a8d960c5c2d4a7aa661fd43e842893e76adf1b5b163c02f4f62537", size = 68668, upload-time = "2025-06-02T19:42:43.426Z" },
+ { url = "https://files.pythonhosted.org/packages/be/b8/0c56297e5f290de17e838c7e4ff338f5b94351c6566aed70ee197a671dc5/boto3_stubs-1.39.3-py3-none-any.whl", hash = "sha256:4daddb19374efa6d1bef7aded9cede0075f380722a9e60ab129ebba14ae66b69", size = 69196, upload-time = "2025-07-03T19:28:09.4Z" },
]
[package.optional-dependencies]
@@ -577,14 +593,14 @@ wheels = [
[[package]]
name = "botocore-stubs"
-version = "1.38.28"
+version = "1.38.46"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "types-awscrt" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/bc/0e/a47d392ee3fbd444a628f1b7257affc0a13c45e8742de4eb31fa4e2758f2/botocore_stubs-1.38.28.tar.gz", hash = "sha256:b9549050b81051bdbb91966323d6a2b6c5c78ca8dcc328e16dfc44b765be39be", size = 42312, upload-time = "2025-06-02T20:18:13.928Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/45/27cabc7c3022dcb12de5098cc646b374065f5e72fae13600ff1756f365ee/botocore_stubs-1.38.46.tar.gz", hash = "sha256:a04e69766ab8bae338911c1897492f88d05cd489cd75f06e6eb4f135f9da8c7b", size = 42299, upload-time = "2025-06-29T22:58:24.765Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6f/3d/26c1a62b379f0dd401798f9f8f9331ac40f3c3917c34b5eed3ed0b85e2b0/botocore_stubs-1.38.28-py3-none-any.whl", hash = "sha256:9151ced682edb44b28202d268f4dc5ef5534be8b592b87e07ce4c99650818bce", size = 65629, upload-time = "2025-06-02T20:18:11.886Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/84/06490071e26bab22ac79a684e98445df118adcf80c58c33ba5af184030f2/botocore_stubs-1.38.46-py3-none-any.whl", hash = "sha256:cc21d9a7dd994bdd90872db4664d817c4719b51cda8004fd507a4bf65b085a75", size = 66083, upload-time = "2025-06-29T22:58:22.234Z" },
]
[[package]]
@@ -727,11 +743,11 @@ wheels = [
[[package]]
name = "certifi"
-version = "2025.4.26"
+version = "2025.6.15"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e8/9e/c05b3920a3b7d20d3d3310465f50348e5b3694f4f88c6daf736eef3024c4/certifi-2025.4.26.tar.gz", hash = "sha256:0a816057ea3cdefcef70270d2c515e4506bbc954f417fa5ade2021213bb8f0c6", size = 160705, upload-time = "2025-04-26T02:12:29.51Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/73/f7/f14b46d4bcd21092d7d3ccef689615220d8a08fb25e564b65d20738e672e/certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b", size = 158753, upload-time = "2025-06-15T02:45:51.329Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4a/7e/3db2bd1b1f9e95f7cddca6d6e75e2f2bd9f51b1246e546d88addca0106bd/certifi-2025.4.26-py3-none-any.whl", hash = "sha256:30350364dfe371162649852c63336a15c70c6510c2ad5015b21c2345311805f3", size = 159618, upload-time = "2025-04-26T02:12:27.662Z" },
+ { url = "https://files.pythonhosted.org/packages/84/ae/320161bd181fc06471eed047ecce67b693fd7515b16d495d8932db763426/certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057", size = 157650, upload-time = "2025-06-15T02:45:49.977Z" },
]
[[package]]
@@ -909,14 +925,14 @@ wheels = [
[[package]]
name = "click-plugins"
-version = "1.1.1"
+version = "1.1.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/5f/1d/45434f64ed749540af821fd7e42b8e4d23ac04b1eda7c26613288d6cd8a8/click-plugins-1.1.1.tar.gz", hash = "sha256:46ab999744a9d831159c3411bb0c79346d94a444df9a3a3742e9ed63645f264b", size = 8164, upload-time = "2019-04-04T04:27:04.82Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e9/da/824b92d9942f4e472702488857914bdd50f73021efea15b4cad9aca8ecef/click_plugins-1.1.1-py2.py3-none-any.whl", hash = "sha256:5d262006d3222f5057fd81e1623d4443e41dcda5dc815c06b442aa3c02889fc8", size = 7497, upload-time = "2019-04-04T04:27:03.36Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" },
]
[[package]]
@@ -1105,36 +1121,43 @@ sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c
[[package]]
name = "cryptography"
-version = "42.0.8"
+version = "45.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/93/a7/1498799a2ea06148463a9a2c10ab2f6a921a74fb19e231b27dc412a748e2/cryptography-42.0.8.tar.gz", hash = "sha256:8d09d05439ce7baa8e9e95b07ec5b6c886f548deb7e0f69ef25f64b3bce842f2", size = 671250, upload-time = "2024-06-04T19:55:08.609Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f9/8b/1b929ba8139430e09e140e6939c2b29c18df1f2fc2149e41bdbdcdaf5d1f/cryptography-42.0.8-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:81d8a521705787afe7a18d5bfb47ea9d9cc068206270aad0b96a725022e18d2e", size = 5899961, upload-time = "2024-06-04T19:53:57.933Z" },
- { url = "https://files.pythonhosted.org/packages/fa/5d/31d833daa800e4fab33209843095df7adb4a78ea536929145534cbc15026/cryptography-42.0.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:961e61cefdcb06e0c6d7e3a1b22ebe8b996eb2bf50614e89384be54c48c6b63d", size = 3114353, upload-time = "2024-06-04T19:54:12.171Z" },
- { url = "https://files.pythonhosted.org/packages/5d/32/f6326c70a9f0f258a201d3b2632bca586ea24d214cec3cf36e374040e273/cryptography-42.0.8-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3ec3672626e1b9e55afd0df6d774ff0e953452886e06e0f1eb7eb0c832e8902", size = 3647773, upload-time = "2024-06-04T19:54:07.051Z" },
- { url = "https://files.pythonhosted.org/packages/35/66/2d87e9ca95c82c7ee5f2c09716fc4c4242c1ae6647b9bd27e55e920e9f10/cryptography-42.0.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e599b53fd95357d92304510fb7bda8523ed1f79ca98dce2f43c115950aa78801", size = 3839763, upload-time = "2024-06-04T19:54:30.383Z" },
- { url = "https://files.pythonhosted.org/packages/c2/de/8083fa2e68d403553a01a9323f4f8b9d7ffed09928ba25635c29fb28c1e7/cryptography-42.0.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5226d5d21ab681f432a9c1cf8b658c0cb02533eece706b155e5fbd8a0cdd3949", size = 3632661, upload-time = "2024-06-04T19:54:32.955Z" },
- { url = "https://files.pythonhosted.org/packages/07/40/d6f6819c62e808ea74639c3c640f7edd636b86cce62cb14943996a15df92/cryptography-42.0.8-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6b7c4f03ce01afd3b76cf69a5455caa9cfa3de8c8f493e0d3ab7d20611c8dae9", size = 3851536, upload-time = "2024-06-04T19:53:53.131Z" },
- { url = "https://files.pythonhosted.org/packages/5c/46/de71d48abf2b6d3c808f4fbb0f4dc44a4e72786be23df0541aa2a3f6fd7e/cryptography-42.0.8-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:2346b911eb349ab547076f47f2e035fc8ff2c02380a7cbbf8d87114fa0f1c583", size = 3754209, upload-time = "2024-06-04T19:54:55.259Z" },
- { url = "https://files.pythonhosted.org/packages/25/c9/86f04e150c5d5d5e4a731a2c1e0e43da84d901f388e3fea3d5de98d689a7/cryptography-42.0.8-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:ad803773e9df0b92e0a817d22fd8a3675493f690b96130a5e24f1b8fabbea9c7", size = 3923551, upload-time = "2024-06-04T19:54:16.46Z" },
- { url = "https://files.pythonhosted.org/packages/53/c2/903014dafb7271fb148887d4355b2e90319cad6e810663be622b0c933fc9/cryptography-42.0.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2f66d9cd9147ee495a8374a45ca445819f8929a3efcd2e3df6428e46c3cbb10b", size = 3739265, upload-time = "2024-06-04T19:54:23.194Z" },
- { url = "https://files.pythonhosted.org/packages/95/26/82d704d988a193cbdc69ac3b41c687c36eaed1642cce52530ad810c35645/cryptography-42.0.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d45b940883a03e19e944456a558b67a41160e367a719833c53de6911cabba2b7", size = 3937371, upload-time = "2024-06-04T19:55:04.303Z" },
- { url = "https://files.pythonhosted.org/packages/cf/71/4e0d05c9acd638a225f57fb6162aa3d03613c11b76893c23ea4675bb28c5/cryptography-42.0.8-cp37-abi3-win32.whl", hash = "sha256:a0c5b2b0585b6af82d7e385f55a8bc568abff8923af147ee3c07bd8b42cda8b2", size = 2438849, upload-time = "2024-06-04T19:54:27.39Z" },
- { url = "https://files.pythonhosted.org/packages/06/0f/78da3cad74f2ba6c45321dc90394d70420ea846730dc042ef527f5a224b5/cryptography-42.0.8-cp37-abi3-win_amd64.whl", hash = "sha256:57080dee41209e556a9a4ce60d229244f7a66ef52750f813bfbe18959770cfba", size = 2889090, upload-time = "2024-06-04T19:54:14.245Z" },
- { url = "https://files.pythonhosted.org/packages/60/12/f064af29190cdb1d38fe07f3db6126091639e1dece7ec77c4ff037d49193/cryptography-42.0.8-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:dea567d1b0e8bc5764b9443858b673b734100c2871dc93163f58c46a97a83d28", size = 5901232, upload-time = "2024-06-04T19:54:52.722Z" },
- { url = "https://files.pythonhosted.org/packages/43/c2/4a3eef67e009a522711ebd8ac89424c3a7fe591ece7035d964419ad52a1d/cryptography-42.0.8-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c4783183f7cb757b73b2ae9aed6599b96338eb957233c58ca8f49a49cc32fd5e", size = 3648711, upload-time = "2024-06-04T19:54:44.323Z" },
- { url = "https://files.pythonhosted.org/packages/49/1c/9f6d13cc8041c05eebff1154e4e71bedd1db8e174fff999054435994187a/cryptography-42.0.8-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0608251135d0e03111152e41f0cc2392d1e74e35703960d4190b2e0f4ca9c70", size = 3841968, upload-time = "2024-06-04T19:54:57.911Z" },
- { url = "https://files.pythonhosted.org/packages/5f/f9/c3d4f19b82bdb25a3d857fe96e7e571c981810e47e3f299cc13ac429066a/cryptography-42.0.8-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dc0fdf6787f37b1c6b08e6dfc892d9d068b5bdb671198c72072828b80bd5fe4c", size = 3633032, upload-time = "2024-06-04T19:54:48.518Z" },
- { url = "https://files.pythonhosted.org/packages/fa/e2/b7e6e8c261536c489d9cf908769880d94bd5d9a187e166b0dc838d2e6a56/cryptography-42.0.8-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9c0c1716c8447ee7dbf08d6db2e5c41c688544c61074b54fc4564196f55c25a7", size = 3852478, upload-time = "2024-06-04T19:54:50.599Z" },
- { url = "https://files.pythonhosted.org/packages/a2/68/e16751f6b859bc120f53fddbf3ebada5c34f0e9689d8af32884d8b2e4b4c/cryptography-42.0.8-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:fff12c88a672ab9c9c1cf7b0c80e3ad9e2ebd9d828d955c126be4fd3e5578c9e", size = 3754102, upload-time = "2024-06-04T19:54:46.231Z" },
- { url = "https://files.pythonhosted.org/packages/0f/38/85c74d0ac4c540780e072b1e6f148ecb718418c1062edcb20d22f3ec5bbb/cryptography-42.0.8-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:cafb92b2bc622cd1aa6a1dce4b93307792633f4c5fe1f46c6b97cf67073ec961", size = 3925042, upload-time = "2024-06-04T19:54:34.767Z" },
- { url = "https://files.pythonhosted.org/packages/89/f4/a8b982e88eb5350407ebdbf4717b55043271d878705329e107f4783555f2/cryptography-42.0.8-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:31f721658a29331f895a5a54e7e82075554ccfb8b163a18719d342f5ffe5ecb1", size = 3738833, upload-time = "2024-06-04T19:54:05.231Z" },
- { url = "https://files.pythonhosted.org/packages/fd/2b/be327b580645927bb1a1f32d5a175b897a9b956bc085b095e15c40bac9ed/cryptography-42.0.8-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b297f90c5723d04bcc8265fc2a0f86d4ea2e0f7ab4b6994459548d3a6b992a14", size = 3938751, upload-time = "2024-06-04T19:54:37.837Z" },
- { url = "https://files.pythonhosted.org/packages/3c/d5/c6a78ffccdbe4516711ebaa9ed2c7eb6ac5dfa3dc920f2c7e920af2418b0/cryptography-42.0.8-cp39-abi3-win32.whl", hash = "sha256:2f88d197e66c65be5e42cd72e5c18afbfae3f741742070e3019ac8f4ac57262c", size = 2439281, upload-time = "2024-06-04T19:53:55.903Z" },
- { url = "https://files.pythonhosted.org/packages/a2/7b/b0d330852dd5953daee6b15f742f15d9f18e9c0154eb4cfcc8718f0436da/cryptography-42.0.8-cp39-abi3-win_amd64.whl", hash = "sha256:fa76fbb7596cc5839320000cdd5d0955313696d9511debab7ee7278fc8b5c84a", size = 2886038, upload-time = "2024-06-04T19:54:18.707Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092, upload-time = "2025-07-02T13:05:01.514Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926, upload-time = "2025-07-02T13:05:04.741Z" },
+ { url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235, upload-time = "2025-07-02T13:05:07.084Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785, upload-time = "2025-07-02T13:05:09.321Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/8b/07eb6bd5acff58406c5e806eff34a124936f41a4fb52909ffa4d00815f8c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0", size = 3893050, upload-time = "2025-07-02T13:05:11.069Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/ef/3333295ed58d900a13c92806b67e62f27876845a9a908c939f040887cca9/cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d", size = 4457379, upload-time = "2025-07-02T13:05:13.32Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/9d/44080674dee514dbb82b21d6fa5d1055368f208304e2ab1828d85c9de8f4/cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9", size = 4209355, upload-time = "2025-07-02T13:05:15.017Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087, upload-time = "2025-07-02T13:05:16.945Z" },
+ { url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873, upload-time = "2025-07-02T13:05:18.743Z" },
+ { url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651, upload-time = "2025-07-02T13:05:21.382Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050, upload-time = "2025-07-02T13:05:23.39Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224, upload-time = "2025-07-02T13:05:25.202Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143, upload-time = "2025-07-02T13:05:27.229Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780, upload-time = "2025-07-02T13:05:29.299Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091, upload-time = "2025-07-02T13:05:31.221Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711, upload-time = "2025-07-02T13:05:33.062Z" },
+ { url = "https://files.pythonhosted.org/packages/04/f7/36d2d69df69c94cbb2473871926daf0f01ad8e00fe3986ac3c1e8c4ca4b3/cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135", size = 3883299, upload-time = "2025-07-02T13:05:34.94Z" },
+ { url = "https://files.pythonhosted.org/packages/82/c7/f0ea40f016de72f81288e9fe8d1f6748036cb5ba6118774317a3ffc6022d/cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7", size = 4450558, upload-time = "2025-07-02T13:05:37.288Z" },
+ { url = "https://files.pythonhosted.org/packages/06/ae/94b504dc1a3cdf642d710407c62e86296f7da9e66f27ab12a1ee6fdf005b/cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42", size = 4198020, upload-time = "2025-07-02T13:05:39.102Z" },
+ { url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759, upload-time = "2025-07-02T13:05:41.398Z" },
+ { url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991, upload-time = "2025-07-02T13:05:43.64Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769, upload-time = "2025-07-02T13:05:48.329Z" },
+ { url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/71/9bdbcfd58d6ff5084687fe722c58ac718ebedbc98b9f8f93781354e6d286/cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e", size = 3587878, upload-time = "2025-07-02T13:06:06.339Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/63/83516cfb87f4a8756eaa4203f93b283fda23d210fc14e1e594bd5f20edb6/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6", size = 4152447, upload-time = "2025-07-02T13:06:08.345Z" },
+ { url = "https://files.pythonhosted.org/packages/22/11/d2823d2a5a0bd5802b3565437add16f5c8ce1f0778bf3822f89ad2740a38/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18", size = 4386778, upload-time = "2025-07-02T13:06:10.263Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/38/6bf177ca6bce4fe14704ab3e93627c5b0ca05242261a2e43ef3168472540/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463", size = 4151627, upload-time = "2025-07-02T13:06:13.097Z" },
+ { url = "https://files.pythonhosted.org/packages/38/6a/69fc67e5266bff68a91bcb81dff8fb0aba4d79a78521a08812048913e16f/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1", size = 4385593, upload-time = "2025-07-02T13:06:15.689Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/34/31a1604c9a9ade0fdab61eb48570e09a796f4d9836121266447b0eaf7feb/cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f", size = 3331106, upload-time = "2025-07-02T13:06:18.058Z" },
]
[[package]]
@@ -1194,8 +1217,10 @@ wheels = [
[[package]]
name = "dify-api"
+version = "1.6.0"
source = { virtual = "." }
dependencies = [
+ { name = "arize-phoenix-otel" },
{ name = "authlib" },
{ name = "azure-identity" },
{ name = "beautifulsoup4" },
@@ -1221,6 +1246,7 @@ dependencies = [
{ name = "googleapis-common-protos" },
{ name = "gunicorn" },
{ name = "httpx", extra = ["socks"] },
+ { name = "httpx-sse" },
{ name = "jieba" },
{ name = "json-repair" },
{ name = "langfuse" },
@@ -1262,8 +1288,10 @@ dependencies = [
{ name = "readabilipy" },
{ name = "redis", extra = ["hiredis"] },
{ name = "resend" },
+ { name = "sendgrid" },
{ name = "sentry-sdk", extra = ["flask"] },
{ name = "sqlalchemy" },
+ { name = "sseclient-py" },
{ name = "starlette" },
{ name = "tiktoken" },
{ name = "transformers" },
@@ -1279,6 +1307,7 @@ dev = [
{ name = "coverage" },
{ name = "dotenv-linter" },
{ name = "faker" },
+ { name = "hypothesis" },
{ name = "lxml-stubs" },
{ name = "mypy" },
{ name = "pandas-stubs" },
@@ -1317,6 +1346,7 @@ dev = [
{ name = "types-pymysql" },
{ name = "types-pyopenssl" },
{ name = "types-python-dateutil" },
+ { name = "types-python-http-client" },
{ name = "types-pywin32" },
{ name = "types-pyyaml" },
{ name = "types-regex" },
@@ -1352,6 +1382,7 @@ vdb = [
{ name = "clickhouse-connect" },
{ name = "couchbase" },
{ name = "elasticsearch" },
+ { name = "mo-vector" },
{ name = "opensearch-py" },
{ name = "oracledb" },
{ name = "pgvecto-rs", extra = ["sqlalchemy"] },
@@ -1371,6 +1402,7 @@ vdb = [
[package.metadata]
requires-dist = [
+ { name = "arize-phoenix-otel", specifier = "~=0.9.2" },
{ name = "authlib", specifier = "==1.3.1" },
{ name = "azure-identity", specifier = "==1.16.1" },
{ name = "beautifulsoup4", specifier = "==4.12.2" },
@@ -1396,6 +1428,7 @@ requires-dist = [
{ name = "googleapis-common-protos", specifier = "==1.63.0" },
{ name = "gunicorn", specifier = "~=23.0.0" },
{ name = "httpx", extras = ["socks"], specifier = "~=0.27.0" },
+ { name = "httpx-sse", specifier = ">=0.4.0" },
{ name = "jieba", specifier = "==0.42.1" },
{ name = "json-repair", specifier = ">=0.41.1" },
{ name = "langfuse", specifier = "~=2.51.3" },
@@ -1437,8 +1470,10 @@ requires-dist = [
{ name = "readabilipy", specifier = "~=0.3.0" },
{ name = "redis", extras = ["hiredis"], specifier = "~=6.1.0" },
{ name = "resend", specifier = "~=2.9.0" },
+ { name = "sendgrid", specifier = "~=6.12.3" },
{ name = "sentry-sdk", extras = ["flask"], specifier = "~=2.28.0" },
{ name = "sqlalchemy", specifier = "~=2.0.29" },
+ { name = "sseclient-py", specifier = ">=1.8.0" },
{ name = "starlette", specifier = "==0.41.0" },
{ name = "tiktoken", specifier = "~=0.9.0" },
{ name = "transformers", specifier = "~=4.51.0" },
@@ -1454,6 +1489,7 @@ dev = [
{ name = "coverage", specifier = "~=7.2.4" },
{ name = "dotenv-linter", specifier = "~=0.5.0" },
{ name = "faker", specifier = "~=32.1.0" },
+ { name = "hypothesis", specifier = ">=6.131.15" },
{ name = "lxml-stubs", specifier = "~=0.5.1" },
{ name = "mypy", specifier = "~=1.16.0" },
{ name = "pandas-stubs", specifier = "~=2.2.3" },
@@ -1462,7 +1498,7 @@ dev = [
{ name = "pytest-cov", specifier = "~=4.1.0" },
{ name = "pytest-env", specifier = "~=1.1.3" },
{ name = "pytest-mock", specifier = "~=3.14.0" },
- { name = "ruff", specifier = "~=0.11.5" },
+ { name = "ruff", specifier = "~=0.12.3" },
{ name = "scipy-stubs", specifier = ">=1.15.3.0" },
{ name = "types-aiofiles", specifier = "~=24.1.0" },
{ name = "types-beautifulsoup4", specifier = "~=4.12.0" },
@@ -1492,6 +1528,7 @@ dev = [
{ name = "types-pymysql", specifier = "~=1.1.0" },
{ name = "types-pyopenssl", specifier = ">=24.1.0" },
{ name = "types-python-dateutil", specifier = "~=2.9.0" },
+ { name = "types-python-http-client", specifier = ">=3.3.7.20240910" },
{ name = "types-pywin32", specifier = "~=310.0.0" },
{ name = "types-pyyaml", specifier = "~=6.0.12" },
{ name = "types-regex", specifier = "~=2024.11.6" },
@@ -1527,6 +1564,7 @@ vdb = [
{ name = "clickhouse-connect", specifier = "~=0.7.16" },
{ name = "couchbase", specifier = "~=4.3.0" },
{ name = "elasticsearch", specifier = "==8.14.0" },
+ { name = "mo-vector", specifier = "~=0.1.13" },
{ name = "opensearch-py", specifier = "==2.4.0" },
{ name = "oracledb", specifier = "==3.0.0" },
{ name = "pgvecto-rs", extras = ["sqlalchemy"], specifier = "~=0.2.1" },
@@ -1535,7 +1573,7 @@ vdb = [
{ name = "pymochow", specifier = "==1.3.1" },
{ name = "pyobvector", specifier = "~=0.1.6" },
{ name = "qdrant-client", specifier = "==1.9.0" },
- { name = "tablestore", specifier = "==6.1.0" },
+ { name = "tablestore", specifier = "==6.2.0" },
{ name = "tcvectordb", specifier = "~=1.6.4" },
{ name = "tidb-vector", specifier = "==0.0.9" },
{ name = "upstash-vector", specifier = "==0.6.0" },
@@ -1562,18 +1600,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
-[[package]]
-name = "docker-pycreds"
-version = "0.4.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "six" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c5/e6/d1f6c00b7221e2d7c4b470132c931325c8b22c51ca62417e300f5ce16009/docker-pycreds-0.4.0.tar.gz", hash = "sha256:6ce3270bcaf404cc4c3e27e4b6c70d3521deae82fb508767870fdbf772d584d4", size = 8754, upload-time = "2018-11-29T03:26:50.996Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f5/e8/f6bd1eee09314e7e6dee49cbe2c5e22314ccdb38db16c9fc72d2fa80d054/docker_pycreds-0.4.0-py2.py3-none-any.whl", hash = "sha256:7266112468627868005106ec19cd0d722702d2b7d5912a28e19b826c3d37af49", size = 8982, upload-time = "2018-11-29T03:26:49.575Z" },
-]
-
[[package]]
name = "docstring-parser"
version = "0.16"
@@ -1608,6 +1634,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/0d/9feae160378a3553fa9a339b0e9c1a048e147a4127210e286ef18b730f03/durationpy-0.10-py3-none-any.whl", hash = "sha256:3b41e1b601234296b4fb368338fdcd3e13e0b4fb5b67345948f4f2bf9868b286", size = 3922, upload-time = "2025-05-17T13:52:36.463Z" },
]
+[[package]]
+name = "ecdsa"
+version = "0.19.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" },
+]
+
[[package]]
name = "elastic-transport"
version = "8.17.1"
@@ -1642,15 +1680,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/db/a0335710caaa6d0aebdaa65ad4df789c15d89b7babd9a30277838a7d9aac/emoji-2.14.1-py3-none-any.whl", hash = "sha256:35a8a486c1460addb1499e3bf7929d3889b2e2841a57401903699fef595e942b", size = 590617, upload-time = "2025-01-16T06:31:23.526Z" },
]
-[[package]]
-name = "enum34"
-version = "1.1.10"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/11/c4/2da1f4952ba476677a42f25cd32ab8aaf0e1c0d0e00b89822b835c7e654c/enum34-1.1.10.tar.gz", hash = "sha256:cce6a7477ed816bd2542d03d53db9f0db935dd013b70f336a95c73979289f248", size = 28187, upload-time = "2020-03-10T17:48:00.865Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/63/f6/ccb1c83687756aeabbf3ca0f213508fcfb03883ff200d201b3a4c60cedcc/enum34-1.1.10-py3-none-any.whl", hash = "sha256:c3858660960c984d6ab0ebad691265180da2b43f07e061c0f8dca9ef3cffd328", size = 11224, upload-time = "2020-03-10T17:48:03.174Z" },
-]
-
[[package]]
name = "esdk-obs-python"
version = "3.24.6.1"
@@ -1684,16 +1713,16 @@ wheels = [
[[package]]
name = "fastapi"
-version = "0.115.12"
+version = "0.116.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload-time = "2025-03-23T22:55:43.822Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/20/38/e1da78736143fd885c36213a3ccc493c384ae8fea6a0f0bc272ef42ebea8/fastapi-0.116.0.tar.gz", hash = "sha256:80dc0794627af0390353a6d1171618276616310d37d24faba6648398e57d687a", size = 296518, upload-time = "2025-07-07T15:09:27.82Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/68/d80347fe2360445b5f58cf290e588a4729746e7501080947e6cdae114b1f/fastapi-0.116.0-py3-none-any.whl", hash = "sha256:fdcc9ed272eaef038952923bef2b735c02372402d1203ee1210af4eea7a78d2b", size = 95625, upload-time = "2025-07-07T15:09:26.348Z" },
]
[[package]]
@@ -1749,15 +1778,15 @@ wheels = [
[[package]]
name = "flask-cors"
-version = "6.0.0"
+version = "6.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "werkzeug" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/20/e7/b3c6afdd984672b55dff07482699c688af6c01bd7fd5dd55f9c9d1a88d1c/flask_cors-6.0.0.tar.gz", hash = "sha256:4592c1570246bf7beee96b74bc0adbbfcb1b0318f6ba05c412e8909eceec3393", size = 11875, upload-time = "2025-05-17T14:35:16.98Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/37/bcfa6c7d5eec777c4c7cf45ce6b27631cebe5230caf88d85eadd63edd37a/flask_cors-6.0.1.tar.gz", hash = "sha256:d81bcb31f07b0985be7f48406247e9243aced229b7747219160a0559edd678db", size = 13463, upload-time = "2025-06-11T01:32:08.518Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ba/f0/0ee29090016345938f016ee98aa8b5de1c500ee93491dc0c76495848fca1/flask_cors-6.0.0-py3-none-any.whl", hash = "sha256:6332073356452343a8ccddbfec7befdc3fdd040141fe776ec9b94c262f058657", size = 11549, upload-time = "2025-05-17T14:35:15.766Z" },
+ { url = "https://files.pythonhosted.org/packages/17/f8/01bf35a3afd734345528f98d0353f2a978a476528ad4d7e78b70c4d149dd/flask_cors-6.0.1-py3-none-any.whl", hash = "sha256:c7b2cbfb1a31aa0d2e5341eea03a6805349f7a61647daee1a15c46bbe981494c", size = 13244, upload-time = "2025-06-11T01:32:07.352Z" },
]
[[package]]
@@ -1826,45 +1855,45 @@ wheels = [
[[package]]
name = "frozenlist"
-version = "1.6.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ee/f4/d744cba2da59b5c1d88823cf9e8a6c74e4659e2b27604ed973be2a0bf5ab/frozenlist-1.6.0.tar.gz", hash = "sha256:b99655c32c1c8e06d111e7f41c06c29a5318cb1835df23a45518e02a47c63b68", size = 42831, upload-time = "2025-04-17T22:38:53.099Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/53/b5/bc883b5296ec902115c00be161da93bf661199c465ec4c483feec6ea4c32/frozenlist-1.6.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ae8337990e7a45683548ffb2fee1af2f1ed08169284cd829cdd9a7fa7470530d", size = 160912, upload-time = "2025-04-17T22:36:17.235Z" },
- { url = "https://files.pythonhosted.org/packages/6f/93/51b058b563d0704b39c56baa222828043aafcac17fd3734bec5dbeb619b1/frozenlist-1.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8c952f69dd524558694818a461855f35d36cc7f5c0adddce37e962c85d06eac0", size = 124315, upload-time = "2025-04-17T22:36:18.735Z" },
- { url = "https://files.pythonhosted.org/packages/c9/e0/46cd35219428d350558b874d595e132d1c17a9471a1bd0d01d518a261e7c/frozenlist-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8f5fef13136c4e2dee91bfb9a44e236fff78fc2cd9f838eddfc470c3d7d90afe", size = 122230, upload-time = "2025-04-17T22:36:20.6Z" },
- { url = "https://files.pythonhosted.org/packages/d1/0f/7ad2ce928ad06d6dd26a61812b959ded573d3e9d0ee6109d96c2be7172e9/frozenlist-1.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:716bbba09611b4663ecbb7cd022f640759af8259e12a6ca939c0a6acd49eedba", size = 314842, upload-time = "2025-04-17T22:36:22.088Z" },
- { url = "https://files.pythonhosted.org/packages/34/76/98cbbd8a20a5c3359a2004ae5e5b216af84a150ccbad67c8f8f30fb2ea91/frozenlist-1.6.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7b8c4dc422c1a3ffc550b465090e53b0bf4839047f3e436a34172ac67c45d595", size = 304919, upload-time = "2025-04-17T22:36:24.247Z" },
- { url = "https://files.pythonhosted.org/packages/9a/fa/258e771ce3a44348c05e6b01dffc2bc67603fba95761458c238cd09a2c77/frozenlist-1.6.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b11534872256e1666116f6587a1592ef395a98b54476addb5e8d352925cb5d4a", size = 324074, upload-time = "2025-04-17T22:36:26.291Z" },
- { url = "https://files.pythonhosted.org/packages/d5/a4/047d861fd8c538210e12b208c0479912273f991356b6bdee7ea8356b07c9/frozenlist-1.6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c6eceb88aaf7221f75be6ab498dc622a151f5f88d536661af3ffc486245a626", size = 321292, upload-time = "2025-04-17T22:36:27.909Z" },
- { url = "https://files.pythonhosted.org/packages/c0/25/cfec8af758b4525676cabd36efcaf7102c1348a776c0d1ad046b8a7cdc65/frozenlist-1.6.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62c828a5b195570eb4b37369fcbbd58e96c905768d53a44d13044355647838ff", size = 301569, upload-time = "2025-04-17T22:36:29.448Z" },
- { url = "https://files.pythonhosted.org/packages/87/2f/0c819372fa9f0c07b153124bf58683b8d0ca7bb73ea5ccde9b9ef1745beb/frozenlist-1.6.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e1c6bd2c6399920c9622362ce95a7d74e7f9af9bfec05fff91b8ce4b9647845a", size = 313625, upload-time = "2025-04-17T22:36:31.55Z" },
- { url = "https://files.pythonhosted.org/packages/50/5f/f0cf8b0fdedffdb76b3745aa13d5dbe404d63493cc211ce8250f2025307f/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49ba23817781e22fcbd45fd9ff2b9b8cdb7b16a42a4851ab8025cae7b22e96d0", size = 312523, upload-time = "2025-04-17T22:36:33.078Z" },
- { url = "https://files.pythonhosted.org/packages/e1/6c/38c49108491272d3e84125bbabf2c2d0b304899b52f49f0539deb26ad18d/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:431ef6937ae0f853143e2ca67d6da76c083e8b1fe3df0e96f3802fd37626e606", size = 322657, upload-time = "2025-04-17T22:36:34.688Z" },
- { url = "https://files.pythonhosted.org/packages/bd/4b/3bd3bad5be06a9d1b04b1c22be80b5fe65b502992d62fab4bdb25d9366ee/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:9d124b38b3c299ca68433597ee26b7819209cb8a3a9ea761dfe9db3a04bba584", size = 303414, upload-time = "2025-04-17T22:36:36.363Z" },
- { url = "https://files.pythonhosted.org/packages/5b/89/7e225a30bef6e85dbfe22622c24afe932e9444de3b40d58b1ea589a14ef8/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:118e97556306402e2b010da1ef21ea70cb6d6122e580da64c056b96f524fbd6a", size = 320321, upload-time = "2025-04-17T22:36:38.16Z" },
- { url = "https://files.pythonhosted.org/packages/22/72/7e3acef4dd9e86366cb8f4d8f28e852c2b7e116927e9722b31a6f71ea4b0/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fb3b309f1d4086b5533cf7bbcf3f956f0ae6469664522f1bde4feed26fba60f1", size = 323975, upload-time = "2025-04-17T22:36:40.289Z" },
- { url = "https://files.pythonhosted.org/packages/d8/85/e5da03d20507e13c66ce612c9792b76811b7a43e3320cce42d95b85ac755/frozenlist-1.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54dece0d21dce4fdb188a1ffc555926adf1d1c516e493c2914d7c370e454bc9e", size = 316553, upload-time = "2025-04-17T22:36:42.045Z" },
- { url = "https://files.pythonhosted.org/packages/ac/8e/6c609cbd0580ae8a0661c408149f196aade7d325b1ae7adc930501b81acb/frozenlist-1.6.0-cp311-cp311-win32.whl", hash = "sha256:654e4ba1d0b2154ca2f096bed27461cf6160bc7f504a7f9a9ef447c293caf860", size = 115511, upload-time = "2025-04-17T22:36:44.067Z" },
- { url = "https://files.pythonhosted.org/packages/f2/13/a84804cfde6de12d44ed48ecbf777ba62b12ff09e761f76cdd1ff9e14bb1/frozenlist-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:3e911391bffdb806001002c1f860787542f45916c3baf764264a52765d5a5603", size = 120863, upload-time = "2025-04-17T22:36:45.465Z" },
- { url = "https://files.pythonhosted.org/packages/9c/8a/289b7d0de2fbac832ea80944d809759976f661557a38bb8e77db5d9f79b7/frozenlist-1.6.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c5b9e42ace7d95bf41e19b87cec8f262c41d3510d8ad7514ab3862ea2197bfb1", size = 160193, upload-time = "2025-04-17T22:36:47.382Z" },
- { url = "https://files.pythonhosted.org/packages/19/80/2fd17d322aec7f430549f0669f599997174f93ee17929ea5b92781ec902c/frozenlist-1.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ca9973735ce9f770d24d5484dcb42f68f135351c2fc81a7a9369e48cf2998a29", size = 123831, upload-time = "2025-04-17T22:36:49.401Z" },
- { url = "https://files.pythonhosted.org/packages/99/06/f5812da431273f78c6543e0b2f7de67dfd65eb0a433978b2c9c63d2205e4/frozenlist-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ac40ec76041c67b928ca8aaffba15c2b2ee3f5ae8d0cb0617b5e63ec119ca25", size = 121862, upload-time = "2025-04-17T22:36:51.899Z" },
- { url = "https://files.pythonhosted.org/packages/d0/31/9e61c6b5fc493cf24d54881731204d27105234d09878be1a5983182cc4a5/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95b7a8a3180dfb280eb044fdec562f9b461614c0ef21669aea6f1d3dac6ee576", size = 316361, upload-time = "2025-04-17T22:36:53.402Z" },
- { url = "https://files.pythonhosted.org/packages/9d/55/22ca9362d4f0222324981470fd50192be200154d51509ee6eb9baa148e96/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c444d824e22da6c9291886d80c7d00c444981a72686e2b59d38b285617cb52c8", size = 307115, upload-time = "2025-04-17T22:36:55.016Z" },
- { url = "https://files.pythonhosted.org/packages/ae/39/4fff42920a57794881e7bb3898dc7f5f539261711ea411b43bba3cde8b79/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb52c8166499a8150bfd38478248572c924c003cbb45fe3bcd348e5ac7c000f9", size = 322505, upload-time = "2025-04-17T22:36:57.12Z" },
- { url = "https://files.pythonhosted.org/packages/55/f2/88c41f374c1e4cf0092a5459e5f3d6a1e17ed274c98087a76487783df90c/frozenlist-1.6.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b35298b2db9c2468106278537ee529719228950a5fdda686582f68f247d1dc6e", size = 322666, upload-time = "2025-04-17T22:36:58.735Z" },
- { url = "https://files.pythonhosted.org/packages/75/51/034eeb75afdf3fd03997856195b500722c0b1a50716664cde64e28299c4b/frozenlist-1.6.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d108e2d070034f9d57210f22fefd22ea0d04609fc97c5f7f5a686b3471028590", size = 302119, upload-time = "2025-04-17T22:37:00.512Z" },
- { url = "https://files.pythonhosted.org/packages/2b/a6/564ecde55ee633270a793999ef4fd1d2c2b32b5a7eec903b1012cb7c5143/frozenlist-1.6.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e1be9111cb6756868ac242b3c2bd1f09d9aea09846e4f5c23715e7afb647103", size = 316226, upload-time = "2025-04-17T22:37:02.102Z" },
- { url = "https://files.pythonhosted.org/packages/f1/c8/6c0682c32377f402b8a6174fb16378b683cf6379ab4d2827c580892ab3c7/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:94bb451c664415f02f07eef4ece976a2c65dcbab9c2f1705b7031a3a75349d8c", size = 312788, upload-time = "2025-04-17T22:37:03.578Z" },
- { url = "https://files.pythonhosted.org/packages/b6/b8/10fbec38f82c5d163ca1750bfff4ede69713badf236a016781cf1f10a0f0/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d1a686d0b0949182b8faddea596f3fc11f44768d1f74d4cad70213b2e139d821", size = 325914, upload-time = "2025-04-17T22:37:05.213Z" },
- { url = "https://files.pythonhosted.org/packages/62/ca/2bf4f3a1bd40cdedd301e6ecfdbb291080d5afc5f9ce350c0739f773d6b9/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ea8e59105d802c5a38bdbe7362822c522230b3faba2aa35c0fa1765239b7dd70", size = 305283, upload-time = "2025-04-17T22:37:06.985Z" },
- { url = "https://files.pythonhosted.org/packages/09/64/20cc13ccf94abc2a1f482f74ad210703dc78a590d0b805af1c9aa67f76f9/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:abc4e880a9b920bc5020bf6a431a6bb40589d9bca3975c980495f63632e8382f", size = 319264, upload-time = "2025-04-17T22:37:08.618Z" },
- { url = "https://files.pythonhosted.org/packages/20/ff/86c6a2bbe98cfc231519f5e6d712a0898488ceac804a917ce014f32e68f6/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9a79713adfe28830f27a3c62f6b5406c37376c892b05ae070906f07ae4487046", size = 326482, upload-time = "2025-04-17T22:37:10.196Z" },
- { url = "https://files.pythonhosted.org/packages/2f/da/8e381f66367d79adca245d1d71527aac774e30e291d41ef161ce2d80c38e/frozenlist-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a0318c2068e217a8f5e3b85e35899f5a19e97141a45bb925bb357cfe1daf770", size = 318248, upload-time = "2025-04-17T22:37:12.284Z" },
- { url = "https://files.pythonhosted.org/packages/39/24/1a1976563fb476ab6f0fa9fefaac7616a4361dbe0461324f9fd7bf425dbe/frozenlist-1.6.0-cp312-cp312-win32.whl", hash = "sha256:853ac025092a24bb3bf09ae87f9127de9fe6e0c345614ac92536577cf956dfcc", size = 115161, upload-time = "2025-04-17T22:37:13.902Z" },
- { url = "https://files.pythonhosted.org/packages/80/2e/fb4ed62a65f8cd66044706b1013f0010930d8cbb0729a2219561ea075434/frozenlist-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:2bdfe2d7e6c9281c6e55523acd6c2bf77963cb422fdc7d142fb0cb6621b66878", size = 120548, upload-time = "2025-04-17T22:37:15.326Z" },
- { url = "https://files.pythonhosted.org/packages/71/3e/b04a0adda73bd52b390d730071c0d577073d3d26740ee1bad25c3ad0f37b/frozenlist-1.6.0-py3-none-any.whl", hash = "sha256:535eec9987adb04701266b92745d6cdcef2e77669299359c3009c3404dd5d191", size = 12404, upload-time = "2025-04-17T22:38:51.668Z" },
+version = "1.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/34/7e/803dde33760128acd393a27eb002f2020ddb8d99d30a44bfbaab31c5f08a/frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a", size = 82251, upload-time = "2025-06-09T23:00:16.279Z" },
+ { url = "https://files.pythonhosted.org/packages/75/a9/9c2c5760b6ba45eae11334db454c189d43d34a4c0b489feb2175e5e64277/frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750", size = 48183, upload-time = "2025-06-09T23:00:17.698Z" },
+ { url = "https://files.pythonhosted.org/packages/47/be/4038e2d869f8a2da165f35a6befb9158c259819be22eeaf9c9a8f6a87771/frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd", size = 47107, upload-time = "2025-06-09T23:00:18.952Z" },
+ { url = "https://files.pythonhosted.org/packages/79/26/85314b8a83187c76a37183ceed886381a5f992975786f883472fcb6dc5f2/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2", size = 237333, upload-time = "2025-06-09T23:00:20.275Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/fd/e5b64f7d2c92a41639ffb2ad44a6a82f347787abc0c7df5f49057cf11770/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f", size = 231724, upload-time = "2025-06-09T23:00:21.705Z" },
+ { url = "https://files.pythonhosted.org/packages/20/fb/03395c0a43a5976af4bf7534759d214405fbbb4c114683f434dfdd3128ef/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30", size = 245842, upload-time = "2025-06-09T23:00:23.148Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/15/c01c8e1dffdac5d9803507d824f27aed2ba76b6ed0026fab4d9866e82f1f/frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98", size = 239767, upload-time = "2025-06-09T23:00:25.103Z" },
+ { url = "https://files.pythonhosted.org/packages/14/99/3f4c6fe882c1f5514b6848aa0a69b20cb5e5d8e8f51a339d48c0e9305ed0/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86", size = 224130, upload-time = "2025-06-09T23:00:27.061Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/83/220a374bd7b2aeba9d0725130665afe11de347d95c3620b9b82cc2fcab97/frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae", size = 235301, upload-time = "2025-06-09T23:00:29.02Z" },
+ { url = "https://files.pythonhosted.org/packages/03/3c/3e3390d75334a063181625343e8daab61b77e1b8214802cc4e8a1bb678fc/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8", size = 234606, upload-time = "2025-06-09T23:00:30.514Z" },
+ { url = "https://files.pythonhosted.org/packages/23/1e/58232c19608b7a549d72d9903005e2d82488f12554a32de2d5fb59b9b1ba/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31", size = 248372, upload-time = "2025-06-09T23:00:31.966Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/a4/e4a567e01702a88a74ce8a324691e62a629bf47d4f8607f24bf1c7216e7f/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7", size = 229860, upload-time = "2025-06-09T23:00:33.375Z" },
+ { url = "https://files.pythonhosted.org/packages/73/a6/63b3374f7d22268b41a9db73d68a8233afa30ed164c46107b33c4d18ecdd/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5", size = 245893, upload-time = "2025-06-09T23:00:35.002Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/eb/d18b3f6e64799a79673c4ba0b45e4cfbe49c240edfd03a68be20002eaeaa/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898", size = 246323, upload-time = "2025-06-09T23:00:36.468Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f5/720f3812e3d06cd89a1d5db9ff6450088b8f5c449dae8ffb2971a44da506/frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56", size = 233149, upload-time = "2025-06-09T23:00:37.963Z" },
+ { url = "https://files.pythonhosted.org/packages/69/68/03efbf545e217d5db8446acfd4c447c15b7c8cf4dbd4a58403111df9322d/frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7", size = 39565, upload-time = "2025-06-09T23:00:39.753Z" },
+ { url = "https://files.pythonhosted.org/packages/58/17/fe61124c5c333ae87f09bb67186d65038834a47d974fc10a5fadb4cc5ae1/frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d", size = 44019, upload-time = "2025-06-09T23:00:40.988Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/a2/c8131383f1e66adad5f6ecfcce383d584ca94055a34d683bbb24ac5f2f1c/frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2", size = 81424, upload-time = "2025-06-09T23:00:42.24Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/9d/02754159955088cb52567337d1113f945b9e444c4960771ea90eb73de8db/frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb", size = 47952, upload-time = "2025-06-09T23:00:43.481Z" },
+ { url = "https://files.pythonhosted.org/packages/01/7a/0046ef1bd6699b40acd2067ed6d6670b4db2f425c56980fa21c982c2a9db/frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478", size = 46688, upload-time = "2025-06-09T23:00:44.793Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/a2/a910bafe29c86997363fb4c02069df4ff0b5bc39d33c5198b4e9dd42d8f8/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8", size = 243084, upload-time = "2025-06-09T23:00:46.125Z" },
+ { url = "https://files.pythonhosted.org/packages/64/3e/5036af9d5031374c64c387469bfcc3af537fc0f5b1187d83a1cf6fab1639/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08", size = 233524, upload-time = "2025-06-09T23:00:47.73Z" },
+ { url = "https://files.pythonhosted.org/packages/06/39/6a17b7c107a2887e781a48ecf20ad20f1c39d94b2a548c83615b5b879f28/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4", size = 248493, upload-time = "2025-06-09T23:00:49.742Z" },
+ { url = "https://files.pythonhosted.org/packages/be/00/711d1337c7327d88c44d91dd0f556a1c47fb99afc060ae0ef66b4d24793d/frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b", size = 244116, upload-time = "2025-06-09T23:00:51.352Z" },
+ { url = "https://files.pythonhosted.org/packages/24/fe/74e6ec0639c115df13d5850e75722750adabdc7de24e37e05a40527ca539/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e", size = 224557, upload-time = "2025-06-09T23:00:52.855Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/db/48421f62a6f77c553575201e89048e97198046b793f4a089c79a6e3268bd/frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca", size = 241820, upload-time = "2025-06-09T23:00:54.43Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/fa/cb4a76bea23047c8462976ea7b7a2bf53997a0ca171302deae9d6dd12096/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df", size = 236542, upload-time = "2025-06-09T23:00:56.409Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/32/476a4b5cfaa0ec94d3f808f193301debff2ea42288a099afe60757ef6282/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5", size = 249350, upload-time = "2025-06-09T23:00:58.468Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/ba/9a28042f84a6bf8ea5dbc81cfff8eaef18d78b2a1ad9d51c7bc5b029ad16/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025", size = 225093, upload-time = "2025-06-09T23:01:00.015Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/29/3a32959e68f9cf000b04e79ba574527c17e8842e38c91d68214a37455786/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01", size = 245482, upload-time = "2025-06-09T23:01:01.474Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e8/edf2f9e00da553f07f5fa165325cfc302dead715cab6ac8336a5f3d0adc2/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08", size = 249590, upload-time = "2025-06-09T23:01:02.961Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/80/9a0eb48b944050f94cc51ee1c413eb14a39543cc4f760ed12657a5a3c45a/frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43", size = 237785, upload-time = "2025-06-09T23:01:05.095Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/74/87601e0fb0369b7a2baf404ea921769c53b7ae00dee7dcfe5162c8c6dbf0/frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3", size = 39487, upload-time = "2025-06-09T23:01:06.54Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/15/c026e9a9fc17585a9d461f65d8593d281fedf55fbf7eb53f16c6df2392f9/frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a", size = 43874, upload-time = "2025-06-09T23:01:07.752Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/45/b82e3c16be2182bff01179db177fe144d58b5dc787a7d4492c6ed8b9317f/frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e", size = 13106, upload-time = "2025-06-09T23:02:34.204Z" },
]
[[package]]
@@ -2221,28 +2250,28 @@ wheels = [
[[package]]
name = "greenlet"
-version = "3.2.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/34/c1/a82edae11d46c0d83481aacaa1e578fea21d94a1ef400afd734d47ad95ad/greenlet-3.2.2.tar.gz", hash = "sha256:ad053d34421a2debba45aa3cc39acf454acbcd025b3fc1a9f8a0dee237abd485", size = 185797, upload-time = "2025-05-09T19:47:35.066Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a3/9f/a47e19261747b562ce88219e5ed8c859d42c6e01e73da6fbfa3f08a7be13/greenlet-3.2.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:dcb9cebbf3f62cb1e5afacae90761ccce0effb3adaa32339a0670fe7805d8068", size = 268635, upload-time = "2025-05-09T14:50:39.007Z" },
- { url = "https://files.pythonhosted.org/packages/11/80/a0042b91b66975f82a914d515e81c1944a3023f2ce1ed7a9b22e10b46919/greenlet-3.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf3fc9145141250907730886b031681dfcc0de1c158f3cc51c092223c0f381ce", size = 628786, upload-time = "2025-05-09T15:24:00.692Z" },
- { url = "https://files.pythonhosted.org/packages/38/a2/8336bf1e691013f72a6ebab55da04db81a11f68e82bb691f434909fa1327/greenlet-3.2.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:efcdfb9df109e8a3b475c016f60438fcd4be68cd13a365d42b35914cdab4bb2b", size = 640866, upload-time = "2025-05-09T15:24:48.153Z" },
- { url = "https://files.pythonhosted.org/packages/f8/7e/f2a3a13e424670a5d08826dab7468fa5e403e0fbe0b5f951ff1bc4425b45/greenlet-3.2.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bd139e4943547ce3a56ef4b8b1b9479f9e40bb47e72cc906f0f66b9d0d5cab3", size = 636752, upload-time = "2025-05-09T15:29:23.182Z" },
- { url = "https://files.pythonhosted.org/packages/fd/5d/ce4a03a36d956dcc29b761283f084eb4a3863401c7cb505f113f73af8774/greenlet-3.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71566302219b17ca354eb274dfd29b8da3c268e41b646f330e324e3967546a74", size = 636028, upload-time = "2025-05-09T14:53:32.854Z" },
- { url = "https://files.pythonhosted.org/packages/4b/29/b130946b57e3ceb039238413790dd3793c5e7b8e14a54968de1fe449a7cf/greenlet-3.2.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3091bc45e6b0c73f225374fefa1536cd91b1e987377b12ef5b19129b07d93ebe", size = 583869, upload-time = "2025-05-09T14:53:43.614Z" },
- { url = "https://files.pythonhosted.org/packages/ac/30/9f538dfe7f87b90ecc75e589d20cbd71635531a617a336c386d775725a8b/greenlet-3.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:44671c29da26539a5f142257eaba5110f71887c24d40df3ac87f1117df589e0e", size = 1112886, upload-time = "2025-05-09T15:27:01.304Z" },
- { url = "https://files.pythonhosted.org/packages/be/92/4b7deeb1a1e9c32c1b59fdca1cac3175731c23311ddca2ea28a8b6ada91c/greenlet-3.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c23ea227847c9dbe0b3910f5c0dd95658b607137614eb821e6cbaecd60d81cc6", size = 1138355, upload-time = "2025-05-09T14:53:58.011Z" },
- { url = "https://files.pythonhosted.org/packages/c5/eb/7551c751a2ea6498907b2fcbe31d7a54b602ba5e8eb9550a9695ca25d25c/greenlet-3.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:0a16fb934fcabfdfacf21d79e6fed81809d8cd97bc1be9d9c89f0e4567143d7b", size = 295437, upload-time = "2025-05-09T15:00:57.733Z" },
- { url = "https://files.pythonhosted.org/packages/2c/a1/88fdc6ce0df6ad361a30ed78d24c86ea32acb2b563f33e39e927b1da9ea0/greenlet-3.2.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:df4d1509efd4977e6a844ac96d8be0b9e5aa5d5c77aa27ca9f4d3f92d3fcf330", size = 270413, upload-time = "2025-05-09T14:51:32.455Z" },
- { url = "https://files.pythonhosted.org/packages/a6/2e/6c1caffd65490c68cd9bcec8cb7feb8ac7b27d38ba1fea121fdc1f2331dc/greenlet-3.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da956d534a6d1b9841f95ad0f18ace637668f680b1339ca4dcfb2c1837880a0b", size = 637242, upload-time = "2025-05-09T15:24:02.63Z" },
- { url = "https://files.pythonhosted.org/packages/98/28/088af2cedf8823b6b7ab029a5626302af4ca1037cf8b998bed3a8d3cb9e2/greenlet-3.2.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c7b15fb9b88d9ee07e076f5a683027bc3befd5bb5d25954bb633c385d8b737e", size = 651444, upload-time = "2025-05-09T15:24:49.856Z" },
- { url = "https://files.pythonhosted.org/packages/4a/9f/0116ab876bb0bc7a81eadc21c3f02cd6100dcd25a1cf2a085a130a63a26a/greenlet-3.2.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:752f0e79785e11180ebd2e726c8a88109ded3e2301d40abced2543aa5d164275", size = 646067, upload-time = "2025-05-09T15:29:24.989Z" },
- { url = "https://files.pythonhosted.org/packages/35/17/bb8f9c9580e28a94a9575da847c257953d5eb6e39ca888239183320c1c28/greenlet-3.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae572c996ae4b5e122331e12bbb971ea49c08cc7c232d1bd43150800a2d6c65", size = 648153, upload-time = "2025-05-09T14:53:34.716Z" },
- { url = "https://files.pythonhosted.org/packages/2c/ee/7f31b6f7021b8df6f7203b53b9cc741b939a2591dcc6d899d8042fcf66f2/greenlet-3.2.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02f5972ff02c9cf615357c17ab713737cccfd0eaf69b951084a9fd43f39833d3", size = 603865, upload-time = "2025-05-09T14:53:45.738Z" },
- { url = "https://files.pythonhosted.org/packages/b5/2d/759fa59323b521c6f223276a4fc3d3719475dc9ae4c44c2fe7fc750f8de0/greenlet-3.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4fefc7aa68b34b9224490dfda2e70ccf2131368493add64b4ef2d372955c207e", size = 1119575, upload-time = "2025-05-09T15:27:04.248Z" },
- { url = "https://files.pythonhosted.org/packages/30/05/356813470060bce0e81c3df63ab8cd1967c1ff6f5189760c1a4734d405ba/greenlet-3.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a31ead8411a027c2c4759113cf2bd473690517494f3d6e4bf67064589afcd3c5", size = 1147460, upload-time = "2025-05-09T14:54:00.315Z" },
- { url = "https://files.pythonhosted.org/packages/07/f4/b2a26a309a04fb844c7406a4501331b9400e1dd7dd64d3450472fd47d2e1/greenlet-3.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b24c7844c0a0afc3ccbeb0b807adeefb7eff2b5599229ecedddcfeb0ef333bec", size = 296239, upload-time = "2025-05-09T14:57:17.633Z" },
+version = "3.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752, upload-time = "2025-06-05T16:16:09.955Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/2e/d4fcb2978f826358b673f779f78fa8a32ee37df11920dc2bb5589cbeecef/greenlet-3.2.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:784ae58bba89fa1fa5733d170d42486580cab9decda3484779f4759345b29822", size = 270219, upload-time = "2025-06-05T16:10:10.414Z" },
+ { url = "https://files.pythonhosted.org/packages/16/24/929f853e0202130e4fe163bc1d05a671ce8dcd604f790e14896adac43a52/greenlet-3.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0921ac4ea42a5315d3446120ad48f90c3a6b9bb93dd9b3cf4e4d84a66e42de83", size = 630383, upload-time = "2025-06-05T16:38:51.785Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/b2/0320715eb61ae70c25ceca2f1d5ae620477d246692d9cc284c13242ec31c/greenlet-3.2.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d2971d93bb99e05f8c2c0c2f4aa9484a18d98c4c3bd3c62b65b7e6ae33dfcfaf", size = 642422, upload-time = "2025-06-05T16:41:35.259Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/49/445fd1a210f4747fedf77615d941444349c6a3a4a1135bba9701337cd966/greenlet-3.2.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c667c0bf9d406b77a15c924ef3285e1e05250948001220368e039b6aa5b5034b", size = 638375, upload-time = "2025-06-05T16:48:18.235Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/c8/ca19760cf6eae75fa8dc32b487e963d863b3ee04a7637da77b616703bc37/greenlet-3.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:592c12fb1165be74592f5de0d70f82bc5ba552ac44800d632214b76089945147", size = 637627, upload-time = "2025-06-05T16:13:02.858Z" },
+ { url = "https://files.pythonhosted.org/packages/65/89/77acf9e3da38e9bcfca881e43b02ed467c1dedc387021fc4d9bd9928afb8/greenlet-3.2.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29e184536ba333003540790ba29829ac14bb645514fbd7e32af331e8202a62a5", size = 585502, upload-time = "2025-06-05T16:12:49.642Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c6/ae244d7c95b23b7130136e07a9cc5aadd60d59b5951180dc7dc7e8edaba7/greenlet-3.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:93c0bb79844a367782ec4f429d07589417052e621aa39a5ac1fb99c5aa308edc", size = 1114498, upload-time = "2025-06-05T16:36:46.598Z" },
+ { url = "https://files.pythonhosted.org/packages/89/5f/b16dec0cbfd3070658e0d744487919740c6d45eb90946f6787689a7efbce/greenlet-3.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:751261fc5ad7b6705f5f76726567375bb2104a059454e0226e1eef6c756748ba", size = 1139977, upload-time = "2025-06-05T16:12:38.262Z" },
+ { url = "https://files.pythonhosted.org/packages/66/77/d48fb441b5a71125bcac042fc5b1494c806ccb9a1432ecaa421e72157f77/greenlet-3.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:83a8761c75312361aa2b5b903b79da97f13f556164a7dd2d5448655425bd4c34", size = 297017, upload-time = "2025-06-05T16:25:05.225Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/94/ad0d435f7c48debe960c53b8f60fb41c2026b1d0fa4a99a1cb17c3461e09/greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d", size = 271992, upload-time = "2025-06-05T16:11:23.467Z" },
+ { url = "https://files.pythonhosted.org/packages/93/5d/7c27cf4d003d6e77749d299c7c8f5fd50b4f251647b5c2e97e1f20da0ab5/greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b", size = 638820, upload-time = "2025-06-05T16:38:52.882Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/7e/807e1e9be07a125bb4c169144937910bf59b9d2f6d931578e57f0bce0ae2/greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d", size = 653046, upload-time = "2025-06-05T16:41:36.343Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/ab/158c1a4ea1068bdbc78dba5a3de57e4c7aeb4e7fa034320ea94c688bfb61/greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264", size = 647701, upload-time = "2025-06-05T16:48:19.604Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/0d/93729068259b550d6a0288da4ff72b86ed05626eaf1eb7c0d3466a2571de/greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688", size = 649747, upload-time = "2025-06-05T16:13:04.628Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/f6/c82ac1851c60851302d8581680573245c8fc300253fc1ff741ae74a6c24d/greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb", size = 605461, upload-time = "2025-06-05T16:12:50.792Z" },
+ { url = "https://files.pythonhosted.org/packages/98/82/d022cf25ca39cf1200650fc58c52af32c90f80479c25d1cbf57980ec3065/greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c", size = 1121190, upload-time = "2025-06-05T16:36:48.59Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/e1/25297f70717abe8104c20ecf7af0a5b82d2f5a980eb1ac79f65654799f9f/greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163", size = 1149055, upload-time = "2025-06-05T16:12:40.457Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/8f/8f9e56c5e82eb2c26e8cde787962e66494312dc8cb261c460e1f3a9c88bc/greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849", size = 297817, upload-time = "2025-06-05T16:29:49.244Z" },
]
[[package]]
@@ -2364,17 +2393,17 @@ wheels = [
[[package]]
name = "hf-xet"
-version = "1.1.2"
+version = "1.1.5"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/95/be/58f20728a5b445f8b064e74f0618897b3439f5ef90934da1916b9dfac76f/hf_xet-1.1.2.tar.gz", hash = "sha256:3712d6d4819d3976a1c18e36db9f503e296283f9363af818f50703506ed63da3", size = 467009, upload-time = "2025-05-16T20:44:34.944Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ed/d4/7685999e85945ed0d7f0762b686ae7015035390de1161dcea9d5276c134c/hf_xet-1.1.5.tar.gz", hash = "sha256:69ebbcfd9ec44fdc2af73441619eeb06b94ee34511bbcf57cd423820090f5694", size = 495969, upload-time = "2025-06-20T21:48:38.007Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/45/ae/f1a63f75d9886f18a80220ba31a1c7b9c4752f03aae452f358f538c6a991/hf_xet-1.1.2-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dfd1873fd648488c70735cb60f7728512bca0e459e61fcd107069143cd798469", size = 2642559, upload-time = "2025-05-16T20:44:30.217Z" },
- { url = "https://files.pythonhosted.org/packages/50/ab/d2c83ae18f1015d926defd5bfbe94c62d15e93f900e6a192e318ee947105/hf_xet-1.1.2-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:29b584983b2d977c44157d9241dcf0fd50acde0b7bff8897fe4386912330090d", size = 2541360, upload-time = "2025-05-16T20:44:29.056Z" },
- { url = "https://files.pythonhosted.org/packages/9f/a7/693dc9f34f979e30a378125e2150a0b2d8d166e6d83ce3950eeb81e560aa/hf_xet-1.1.2-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b29ac84298147fe9164cc55ad994ba47399f90b5d045b0b803b99cf5f06d8ec", size = 5183081, upload-time = "2025-05-16T20:44:27.505Z" },
- { url = "https://files.pythonhosted.org/packages/3d/23/c48607883f692a36c0a7735f47f98bad32dbe459a32d1568c0f21576985d/hf_xet-1.1.2-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d921ba32615676e436a0d15e162331abc9ed43d440916b1d836dc27ce1546173", size = 5356100, upload-time = "2025-05-16T20:44:25.681Z" },
- { url = "https://files.pythonhosted.org/packages/eb/5b/b2316c7f1076da0582b52ea228f68bea95e243c388440d1dc80297c9d813/hf_xet-1.1.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d9b03c34e13c44893ab6e8fea18ee8d2a6878c15328dd3aabedbdd83ee9f2ed3", size = 5647688, upload-time = "2025-05-16T20:44:31.867Z" },
- { url = "https://files.pythonhosted.org/packages/2c/98/e6995f0fa579929da7795c961f403f4ee84af36c625963f52741d56f242c/hf_xet-1.1.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:01b18608955b3d826307d37da8bd38b28a46cd2d9908b3a3655d1363274f941a", size = 5322627, upload-time = "2025-05-16T20:44:33.677Z" },
- { url = "https://files.pythonhosted.org/packages/59/40/8f1d5a44a64d8bf9e3c19576e789f716af54875b46daae65426714e75db1/hf_xet-1.1.2-cp37-abi3-win_amd64.whl", hash = "sha256:3562902c81299b09f3582ddfb324400c6a901a2f3bc854f83556495755f4954c", size = 2739542, upload-time = "2025-05-16T20:44:36.287Z" },
+ { url = "https://files.pythonhosted.org/packages/00/89/a1119eebe2836cb25758e7661d6410d3eae982e2b5e974bcc4d250be9012/hf_xet-1.1.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f52c2fa3635b8c37c7764d8796dfa72706cc4eded19d638331161e82b0792e23", size = 2687929, upload-time = "2025-06-20T21:48:32.284Z" },
+ { url = "https://files.pythonhosted.org/packages/de/5f/2c78e28f309396e71ec8e4e9304a6483dcbc36172b5cea8f291994163425/hf_xet-1.1.5-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:9fa6e3ee5d61912c4a113e0708eaaef987047616465ac7aa30f7121a48fc1af8", size = 2556338, upload-time = "2025-06-20T21:48:30.079Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/2f/6cad7b5fe86b7652579346cb7f85156c11761df26435651cbba89376cd2c/hf_xet-1.1.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc874b5c843e642f45fd85cda1ce599e123308ad2901ead23d3510a47ff506d1", size = 3102894, upload-time = "2025-06-20T21:48:28.114Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/54/0fcf2b619720a26fbb6cc941e89f2472a522cd963a776c089b189559447f/hf_xet-1.1.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dbba1660e5d810bd0ea77c511a99e9242d920790d0e63c0e4673ed36c4022d18", size = 3002134, upload-time = "2025-06-20T21:48:25.906Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/92/1d351ac6cef7c4ba8c85744d37ffbfac2d53d0a6c04d2cabeba614640a78/hf_xet-1.1.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ab34c4c3104133c495785d5d8bba3b1efc99de52c02e759cf711a91fd39d3a14", size = 3171009, upload-time = "2025-06-20T21:48:33.987Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/65/4b2ddb0e3e983f2508528eb4501288ae2f84963586fbdfae596836d5e57a/hf_xet-1.1.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:83088ecea236d5113de478acb2339f92c95b4fb0462acaa30621fac02f5a534a", size = 3279245, upload-time = "2025-06-20T21:48:36.051Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/55/ef77a85ee443ae05a9e9cba1c9f0dd9241eb42da2aeba1dc50f51154c81a/hf_xet-1.1.5-cp37-abi3-win_amd64.whl", hash = "sha256:73e167d9807d166596b4b2f0b585c6d5bd84a26dea32843665a8b58f6edba245", size = 2738931, upload-time = "2025-06-20T21:48:39.482Z" },
]
[[package]]
@@ -2508,9 +2537,18 @@ socks = [
{ name = "socksio" },
]
+[[package]]
+name = "httpx-sse"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" },
+]
+
[[package]]
name = "huggingface-hub"
-version = "0.32.3"
+version = "0.33.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
@@ -2522,9 +2560,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/59/74/c4961b31e0f142a032ea24f477c3a7524dfabfd8126398a968b3cc6bf804/huggingface_hub-0.32.3.tar.gz", hash = "sha256:752c889ebf3a63cbd39803f6d87ccc135a463bbcb36abfa2faff0ccbf1cec087", size = 424525, upload-time = "2025-05-30T08:23:56.042Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fa/42/8a95c5632080ae312c0498744b2b852195e10b05a20b1be11c5141092f4c/huggingface_hub-0.33.2.tar.gz", hash = "sha256:84221defaec8fa09c090390cd68c78b88e3c4c2b7befba68d3dc5aacbc3c2c5f", size = 426637, upload-time = "2025-07-02T06:26:05.156Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/df/dc/4f4d8080cbce7a38c1d0f1ba4932f9134480b9761af8ef4c65d49254b2bd/huggingface_hub-0.32.3-py3-none-any.whl", hash = "sha256:e46f7ea7fe2b5e5f67cc4e37eb201140091946a314d7c2b134a9673dadd80b6a", size = 512094, upload-time = "2025-05-30T08:23:54.091Z" },
+ { url = "https://files.pythonhosted.org/packages/44/f4/5f3f22e762ad1965f01122b42dae5bf0e009286e2dba601ce1d0dba72424/huggingface_hub-0.33.2-py3-none-any.whl", hash = "sha256:3749498bfa91e8cde2ddc2c1db92c79981f40e66434c20133b39e5928ac9bcc5", size = 515373, upload-time = "2025-07-02T06:26:03.072Z" },
]
[[package]]
@@ -2548,6 +2586,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
+[[package]]
+name = "hypothesis"
+version = "6.135.26"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "sortedcontainers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/da/83/15c4e30561a0d8c8d076c88cb159187823d877118f34c851ada3b9b02a7b/hypothesis-6.135.26.tar.gz", hash = "sha256:73af0e46cd5039c6806f514fed6a3c185d91ef88b5a1577477099ddbd1a2e300", size = 454523, upload-time = "2025-07-05T04:59:45.443Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3c/78/db4fdc464219455f8dde90074660c3faf8429101b2d1299cac7d219e3176/hypothesis-6.135.26-py3-none-any.whl", hash = "sha256:fa237cbe2ae2c31d65f7230dcb866139ace635dcfec6c30dddf25974dd8ff4b9", size = 521517, upload-time = "2025-07-05T04:59:42.061Z" },
+]
+
[[package]]
name = "idna"
version = "3.10"
@@ -2675,11 +2726,11 @@ wheels = [
[[package]]
name = "json-repair"
-version = "0.46.0"
+version = "0.47.6"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/79/5a/5c14f14735438eb27fd4eb9bb4eb273c996c0d1d959182382cbc618f99dd/json_repair-0.46.0.tar.gz", hash = "sha256:abc751162baf8e384685558acba978478e833c1207be31468d9babfaf8029ab6", size = 33321, upload-time = "2025-05-22T06:22:07.305Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ae/9e/e8bcda4fd47b16fcd4f545af258d56ba337fa43b847beb213818d7641515/json_repair-0.47.6.tar.gz", hash = "sha256:4af5a14b9291d4d005a11537bae5a6b7912376d7584795f0ac1b23724b999620", size = 34400, upload-time = "2025-07-01T15:42:07.458Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/33/ea/8ca71883f10acf4449720b86dd85cea51d7a71bec2558d5275ae0dcc66e4/json_repair-0.46.0-py3-none-any.whl", hash = "sha256:54d6a9889fba0846b80befb2b1aca619103ad3ed74612fb3fedd965a4a3b1653", size = 22255, upload-time = "2025-05-22T06:22:06.206Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/f8/f464ce2afc4be5decf53d0171c2d399d9ee6cd70d2273b8e85e7c6d00324/json_repair-0.47.6-py3-none-any.whl", hash = "sha256:1c9da58fb6240f99b8405f63534e08f8402793f09074dea25800a0b232d4fb19", size = 25754, upload-time = "2025-07-01T15:42:06.418Z" },
]
[[package]]
@@ -2726,7 +2777,7 @@ wheels = [
[[package]]
name = "kubernetes"
-version = "32.0.1"
+version = "33.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -2741,9 +2792,9 @@ dependencies = [
{ name = "urllib3" },
{ name = "websocket-client" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/b7/e8/0598f0e8b4af37cd9b10d8b87386cf3173cb8045d834ab5f6ec347a758b3/kubernetes-32.0.1.tar.gz", hash = "sha256:42f43d49abd437ada79a79a16bd48a604d3471a117a8347e87db693f2ba0ba28", size = 946691, upload-time = "2025-02-18T21:06:34.148Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ae/52/19ebe8004c243fdfa78268a96727c71e08f00ff6fe69a301d0b7fcbce3c2/kubernetes-33.1.0.tar.gz", hash = "sha256:f64d829843a54c251061a8e7a14523b521f2dc5c896cf6d65ccf348648a88993", size = 1036779, upload-time = "2025-06-09T21:57:58.521Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/08/10/9f8af3e6f569685ce3af7faab51c8dd9d93b9c38eba339ca31c746119447/kubernetes-32.0.1-py2.py3-none-any.whl", hash = "sha256:35282ab8493b938b08ab5526c7ce66588232df00ef5e1dbe88a419107dc10998", size = 1988070, upload-time = "2025-02-18T21:06:31.391Z" },
+ { url = "https://files.pythonhosted.org/packages/89/43/d9bebfc3db7dea6ec80df5cb2aad8d274dd18ec2edd6c4f21f32c237cbbb/kubernetes-33.1.0-py2.py3-none-any.whl", hash = "sha256:544de42b24b64287f7e0aa9513c93cb503f7f40eea39b20f66810011a86eabc5", size = 1941335, upload-time = "2025-06-09T21:57:56.327Z" },
]
[[package]]
@@ -2789,53 +2840,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/de/f0/63b06b99b730b9954f8709f6f7d9b8d076fa0a973e472efe278089bde42b/langsmith-0.1.147-py3-none-any.whl", hash = "sha256:7166fc23b965ccf839d64945a78e9f1157757add228b086141eb03a60d699a15", size = 311812, upload-time = "2024-11-27T17:32:39.569Z" },
]
-[[package]]
-name = "levenshtein"
-version = "0.27.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "rapidfuzz" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/7e/b3/b5f8011483ba9083a0bc74c4d58705e9cf465fbe55c948a1b1357d0a2aa8/levenshtein-0.27.1.tar.gz", hash = "sha256:3e18b73564cfc846eec94dd13fab6cb006b5d2e0cc56bad1fd7d5585881302e3", size = 382571, upload-time = "2025-03-02T19:44:56.148Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/22/84/110136e740655779aceb0da2399977362f21b2dbf3ea3646557f9c2237c4/levenshtein-0.27.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e6f1760108319a108dceb2f02bc7cdb78807ad1f9c673c95eaa1d0fe5dfcaae", size = 174555, upload-time = "2025-03-02T19:42:51.781Z" },
- { url = "https://files.pythonhosted.org/packages/19/5b/176d96959f5c5969f356d8856f8e20d2e72f7e4879f6d1cda8e5c2ac2614/levenshtein-0.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c4ed8400d94ab348099395e050b8ed9dd6a5d6b5b9e75e78b2b3d0b5f5b10f38", size = 156286, upload-time = "2025-03-02T19:42:53.106Z" },
- { url = "https://files.pythonhosted.org/packages/2a/2d/a75abaafc8a46b0dc52ab14dc96708989a31799a02a4914f9210c3415f04/levenshtein-0.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7826efe51be8ff58bc44a633e022fdd4b9fc07396375a6dbc4945a3bffc7bf8f", size = 152413, upload-time = "2025-03-02T19:42:55.129Z" },
- { url = "https://files.pythonhosted.org/packages/9a/5f/533f4adf964b10817a1d0ecca978b3542b3b9915c96172d20162afe18bed/levenshtein-0.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff5afb78719659d353055863c7cb31599fbea6865c0890b2d840ee40214b3ddb", size = 184236, upload-time = "2025-03-02T19:42:56.427Z" },
- { url = "https://files.pythonhosted.org/packages/02/79/e698623795e36e0d166a3aa1eac6fe1e446cac3a5c456664a95c351571d1/levenshtein-0.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:201dafd5c004cd52018560cf3213da799534d130cf0e4db839b51f3f06771de0", size = 185502, upload-time = "2025-03-02T19:42:57.596Z" },
- { url = "https://files.pythonhosted.org/packages/ac/94/76b64762f4af6e20bbab79713c4c48783240e6e502b2f52e5037ddda688a/levenshtein-0.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5ddd59f3cfaec216811ee67544779d9e2d6ed33f79337492a248245d6379e3d", size = 161749, upload-time = "2025-03-02T19:42:59.222Z" },
- { url = "https://files.pythonhosted.org/packages/56/d0/d10eff9224c94a478078a469aaeb43471fdeddad035f443091224c7544b8/levenshtein-0.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6afc241d27ecf5b921063b796812c55b0115423ca6fa4827aa4b1581643d0a65", size = 246686, upload-time = "2025-03-02T19:43:00.454Z" },
- { url = "https://files.pythonhosted.org/packages/b2/8a/ebbeff74461da3230d00e8a8197480a2ea1a9bbb7dbc273214d7ea3896cb/levenshtein-0.27.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee2e766277cceb8ca9e584ea03b8dc064449ba588d3e24c1923e4b07576db574", size = 1116616, upload-time = "2025-03-02T19:43:02.431Z" },
- { url = "https://files.pythonhosted.org/packages/1d/9b/e7323684f833ede13113fba818c3afe665a78b47d720afdeb2e530c1ecb3/levenshtein-0.27.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:920b23d6109453913ce78ec451bc402ff19d020ee8be4722e9d11192ec2fac6f", size = 1401483, upload-time = "2025-03-02T19:43:04.62Z" },
- { url = "https://files.pythonhosted.org/packages/ef/1d/9b6ab30ff086a33492d6f7de86a07050b15862ccf0d9feeccfbe26af52d8/levenshtein-0.27.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:560d7edba126e2eea3ac3f2f12e7bd8bc9c6904089d12b5b23b6dfa98810b209", size = 1225805, upload-time = "2025-03-02T19:43:06.734Z" },
- { url = "https://files.pythonhosted.org/packages/1b/07/ae2f31e87ff65ba4857e25192646f1f3c8cca83c2ac1c27e551215b7e1b6/levenshtein-0.27.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8d5362b6c7aa4896dc0cb1e7470a4ad3c06124e0af055dda30d81d3c5549346b", size = 1419860, upload-time = "2025-03-02T19:43:08.084Z" },
- { url = "https://files.pythonhosted.org/packages/43/d2/dfcc5c22c07bab9be99f3f47a907be583bcd37bfd2eec57a205e59671019/levenshtein-0.27.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:65ba880815b0f80a80a293aeebac0fab8069d03ad2d6f967a886063458f9d7a1", size = 1188823, upload-time = "2025-03-02T19:43:09.592Z" },
- { url = "https://files.pythonhosted.org/packages/8b/96/713335623f8ab50eba0627c8685618dc3a985aedaaea9f492986b9443551/levenshtein-0.27.1-cp311-cp311-win32.whl", hash = "sha256:fcc08effe77fec0bc5b0f6f10ff20b9802b961c4a69047b5499f383119ddbe24", size = 88156, upload-time = "2025-03-02T19:43:11.442Z" },
- { url = "https://files.pythonhosted.org/packages/aa/ae/444d6e8ba9a35379a56926716f18bb2e77c6cf69e5324521fbe6885f14f6/levenshtein-0.27.1-cp311-cp311-win_amd64.whl", hash = "sha256:0ed402d8902be7df212ac598fc189f9b2d520817fdbc6a05e2ce44f7f3ef6857", size = 100399, upload-time = "2025-03-02T19:43:13.066Z" },
- { url = "https://files.pythonhosted.org/packages/80/c0/ff226897a238a2deb2ca2c00d658755a1aa01884b0ddc8f5d406cb5f2b0d/levenshtein-0.27.1-cp311-cp311-win_arm64.whl", hash = "sha256:7fdaab29af81a8eb981043737f42450efca64b9761ca29385487b29c506da5b5", size = 88033, upload-time = "2025-03-02T19:43:14.211Z" },
- { url = "https://files.pythonhosted.org/packages/0d/73/84a7126b9e6441c2547f1fbfd65f3c15c387d1fc04e0dd1d025a12107771/levenshtein-0.27.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25fb540d8c55d1dc7bdc59b7de518ea5ed9df92eb2077e74bcb9bb6de7b06f69", size = 173953, upload-time = "2025-03-02T19:43:16.029Z" },
- { url = "https://files.pythonhosted.org/packages/8f/5c/06c01870c0cf336f9f29397bbfbfbbfd3a59918868716e7bb15828e89367/levenshtein-0.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f09cfab6387e9c908c7b37961c045e8e10eb9b7ec4a700367f8e080ee803a562", size = 156399, upload-time = "2025-03-02T19:43:17.233Z" },
- { url = "https://files.pythonhosted.org/packages/c7/4a/c1d3f27ec8b3fff5a96617251bf3f61c67972869ac0a0419558fc3e2cbe6/levenshtein-0.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dafa29c0e616f322b574e0b2aeb5b1ff2f8d9a1a6550f22321f3bd9bb81036e3", size = 151061, upload-time = "2025-03-02T19:43:18.414Z" },
- { url = "https://files.pythonhosted.org/packages/4d/8f/2521081e9a265891edf46aa30e1b59c1f347a452aed4c33baafbec5216fa/levenshtein-0.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be7a7642ea64392fa1e6ef7968c2e50ef2152c60948f95d0793361ed97cf8a6f", size = 183119, upload-time = "2025-03-02T19:43:19.975Z" },
- { url = "https://files.pythonhosted.org/packages/1f/a0/a63e3bce6376127596d04be7f57e672d2f3d5f540265b1e30b9dd9b3c5a9/levenshtein-0.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:060b48c45ed54bcea9582ce79c6365b20a1a7473767e0b3d6be712fa3a22929c", size = 185352, upload-time = "2025-03-02T19:43:21.424Z" },
- { url = "https://files.pythonhosted.org/packages/17/8c/8352e992063952b38fb61d49bad8d193a4a713e7eeceb3ae74b719d7863d/levenshtein-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:712f562c5e64dd0398d3570fe99f8fbb88acec7cc431f101cb66c9d22d74c542", size = 159879, upload-time = "2025-03-02T19:43:22.792Z" },
- { url = "https://files.pythonhosted.org/packages/69/b4/564866e2038acf47c3de3e9292fc7fc7cc18d2593fedb04f001c22ac6e15/levenshtein-0.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a6141ad65cab49aa4527a3342d76c30c48adb2393b6cdfeca65caae8d25cb4b8", size = 245005, upload-time = "2025-03-02T19:43:24.069Z" },
- { url = "https://files.pythonhosted.org/packages/ba/f9/7367f87e3a6eed282f3654ec61a174b4d1b78a7a73f2cecb91f0ab675153/levenshtein-0.27.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:799b8d73cda3265331116f62932f553804eae16c706ceb35aaf16fc2a704791b", size = 1116865, upload-time = "2025-03-02T19:43:25.4Z" },
- { url = "https://files.pythonhosted.org/packages/f5/02/b5b3bfb4b4cd430e9d110bad2466200d51c6061dae7c5a64e36047c8c831/levenshtein-0.27.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ec99871d98e517e1cc4a15659c62d6ea63ee5a2d72c5ddbebd7bae8b9e2670c8", size = 1401723, upload-time = "2025-03-02T19:43:28.099Z" },
- { url = "https://files.pythonhosted.org/packages/ef/69/b93bccd093b3f06a99e67e11ebd6e100324735dc2834958ba5852a1b9fed/levenshtein-0.27.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8799164e1f83588dbdde07f728ea80796ea72196ea23484d78d891470241b222", size = 1226276, upload-time = "2025-03-02T19:43:30.192Z" },
- { url = "https://files.pythonhosted.org/packages/ab/32/37dd1bc5ce866c136716619e6f7081d7078d7dd1c1da7025603dcfd9cf5f/levenshtein-0.27.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:583943813898326516ab451a83f734c6f07488cda5c361676150d3e3e8b47927", size = 1420132, upload-time = "2025-03-02T19:43:33.322Z" },
- { url = "https://files.pythonhosted.org/packages/4b/08/f3bc828dd9f0f8433b26f37c4fceab303186ad7b9b70819f2ccb493d99fc/levenshtein-0.27.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bb22956af44bb4eade93546bf95be610c8939b9a9d4d28b2dfa94abf454fed7", size = 1189144, upload-time = "2025-03-02T19:43:34.814Z" },
- { url = "https://files.pythonhosted.org/packages/2d/54/5ecd89066cf579223d504abe3ac37ba11f63b01a19fd12591083acc00eb6/levenshtein-0.27.1-cp312-cp312-win32.whl", hash = "sha256:d9099ed1bcfa7ccc5540e8ad27b5dc6f23d16addcbe21fdd82af6440f4ed2b6d", size = 88279, upload-time = "2025-03-02T19:43:38.86Z" },
- { url = "https://files.pythonhosted.org/packages/53/79/4f8fabcc5aca9305b494d1d6c7a98482e90a855e0050ae9ff5d7bf4ab2c6/levenshtein-0.27.1-cp312-cp312-win_amd64.whl", hash = "sha256:7f071ecdb50aa6c15fd8ae5bcb67e9da46ba1df7bba7c6bf6803a54c7a41fd96", size = 100659, upload-time = "2025-03-02T19:43:40.082Z" },
- { url = "https://files.pythonhosted.org/packages/cb/81/f8e4c0f571c2aac2e0c56a6e0e41b679937a2b7013e79415e4aef555cff0/levenshtein-0.27.1-cp312-cp312-win_arm64.whl", hash = "sha256:83b9033a984ccace7703f35b688f3907d55490182fd39b33a8e434d7b2e249e6", size = 88168, upload-time = "2025-03-02T19:43:41.42Z" },
- { url = "https://files.pythonhosted.org/packages/7d/44/c5955d0b6830925559b00617d80c9f6e03a9b00c451835ee4da7010e71cd/levenshtein-0.27.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:909b7b6bce27a4ec90576c9a9bd9af5a41308dfecf364b410e80b58038277bbe", size = 170533, upload-time = "2025-03-02T19:44:38.096Z" },
- { url = "https://files.pythonhosted.org/packages/e7/3f/858572d68b33e13a9c154b99f153317efe68381bf63cc4e986e820935fc3/levenshtein-0.27.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d193a7f97b8c6a350e36ec58e41a627c06fa4157c3ce4b2b11d90cfc3c2ebb8f", size = 153119, upload-time = "2025-03-02T19:44:39.388Z" },
- { url = "https://files.pythonhosted.org/packages/d1/60/2bd8d001ea4eb53ca16faa7a649d56005ba22b1bcc2a4f1617ab27ed7e48/levenshtein-0.27.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:614be316e3c06118705fae1f717f9072d35108e5fd4e66a7dd0e80356135340b", size = 149576, upload-time = "2025-03-02T19:44:40.617Z" },
- { url = "https://files.pythonhosted.org/packages/e4/db/0580797e1e4ac26cf67761a235b29b49f62d2b175dbbc609882f2aecd4e4/levenshtein-0.27.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31fc0a5bb070722bdabb6f7e14955a294a4a968c68202d294699817f21545d22", size = 157445, upload-time = "2025-03-02T19:44:41.901Z" },
- { url = "https://files.pythonhosted.org/packages/f4/de/9c171c96d1f15c900086d7212b5543a85539e767689fc4933d14048ba1ec/levenshtein-0.27.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9415aa5257227af543be65768a80c7a75e266c3c818468ce6914812f88f9c3df", size = 243141, upload-time = "2025-03-02T19:44:43.228Z" },
- { url = "https://files.pythonhosted.org/packages/dc/1e/408fd10217eac0e43aea0604be22b4851a09e03d761d44d4ea12089dd70e/levenshtein-0.27.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7987ef006a3cf56a4532bd4c90c2d3b7b4ca9ad3bf8ae1ee5713c4a3bdfda913", size = 98045, upload-time = "2025-03-02T19:44:44.527Z" },
-]
-
[[package]]
name = "litellm"
version = "1.63.7"
@@ -2878,44 +2882,40 @@ wheels = [
[[package]]
name = "lxml"
-version = "5.4.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479, upload-time = "2025-04-23T01:50:29.322Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/81/2d/67693cc8a605a12e5975380d7ff83020dcc759351b5a066e1cced04f797b/lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9", size = 8083240, upload-time = "2025-04-23T01:45:18.566Z" },
- { url = "https://files.pythonhosted.org/packages/73/53/b5a05ab300a808b72e848efd152fe9c022c0181b0a70b8bca1199f1bed26/lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7", size = 4387685, upload-time = "2025-04-23T01:45:21.387Z" },
- { url = "https://files.pythonhosted.org/packages/d8/cb/1a3879c5f512bdcd32995c301886fe082b2edd83c87d41b6d42d89b4ea4d/lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa", size = 4991164, upload-time = "2025-04-23T01:45:23.849Z" },
- { url = "https://files.pythonhosted.org/packages/f9/94/bbc66e42559f9d04857071e3b3d0c9abd88579367fd2588a4042f641f57e/lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df", size = 4746206, upload-time = "2025-04-23T01:45:26.361Z" },
- { url = "https://files.pythonhosted.org/packages/66/95/34b0679bee435da2d7cae895731700e519a8dfcab499c21662ebe671603e/lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e", size = 5342144, upload-time = "2025-04-23T01:45:28.939Z" },
- { url = "https://files.pythonhosted.org/packages/e0/5d/abfcc6ab2fa0be72b2ba938abdae1f7cad4c632f8d552683ea295d55adfb/lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44", size = 4825124, upload-time = "2025-04-23T01:45:31.361Z" },
- { url = "https://files.pythonhosted.org/packages/5a/78/6bd33186c8863b36e084f294fc0a5e5eefe77af95f0663ef33809cc1c8aa/lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba", size = 4876520, upload-time = "2025-04-23T01:45:34.191Z" },
- { url = "https://files.pythonhosted.org/packages/3b/74/4d7ad4839bd0fc64e3d12da74fc9a193febb0fae0ba6ebd5149d4c23176a/lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba", size = 4765016, upload-time = "2025-04-23T01:45:36.7Z" },
- { url = "https://files.pythonhosted.org/packages/24/0d/0a98ed1f2471911dadfc541003ac6dd6879fc87b15e1143743ca20f3e973/lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c", size = 5362884, upload-time = "2025-04-23T01:45:39.291Z" },
- { url = "https://files.pythonhosted.org/packages/48/de/d4f7e4c39740a6610f0f6959052b547478107967362e8424e1163ec37ae8/lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8", size = 4902690, upload-time = "2025-04-23T01:45:42.386Z" },
- { url = "https://files.pythonhosted.org/packages/07/8c/61763abd242af84f355ca4ef1ee096d3c1b7514819564cce70fd18c22e9a/lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86", size = 4944418, upload-time = "2025-04-23T01:45:46.051Z" },
- { url = "https://files.pythonhosted.org/packages/f9/c5/6d7e3b63e7e282619193961a570c0a4c8a57fe820f07ca3fe2f6bd86608a/lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056", size = 4827092, upload-time = "2025-04-23T01:45:48.943Z" },
- { url = "https://files.pythonhosted.org/packages/71/4a/e60a306df54680b103348545706a98a7514a42c8b4fbfdcaa608567bb065/lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7", size = 5418231, upload-time = "2025-04-23T01:45:51.481Z" },
- { url = "https://files.pythonhosted.org/packages/27/f2/9754aacd6016c930875854f08ac4b192a47fe19565f776a64004aa167521/lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd", size = 5261798, upload-time = "2025-04-23T01:45:54.146Z" },
- { url = "https://files.pythonhosted.org/packages/38/a2/0c49ec6941428b1bd4f280650d7b11a0f91ace9db7de32eb7aa23bcb39ff/lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751", size = 4988195, upload-time = "2025-04-23T01:45:56.685Z" },
- { url = "https://files.pythonhosted.org/packages/7a/75/87a3963a08eafc46a86c1131c6e28a4de103ba30b5ae903114177352a3d7/lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4", size = 3474243, upload-time = "2025-04-23T01:45:58.863Z" },
- { url = "https://files.pythonhosted.org/packages/fa/f9/1f0964c4f6c2be861c50db380c554fb8befbea98c6404744ce243a3c87ef/lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539", size = 3815197, upload-time = "2025-04-23T01:46:01.096Z" },
- { url = "https://files.pythonhosted.org/packages/f8/4c/d101ace719ca6a4ec043eb516fcfcb1b396a9fccc4fcd9ef593df34ba0d5/lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4", size = 8127392, upload-time = "2025-04-23T01:46:04.09Z" },
- { url = "https://files.pythonhosted.org/packages/11/84/beddae0cec4dd9ddf46abf156f0af451c13019a0fa25d7445b655ba5ccb7/lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d", size = 4415103, upload-time = "2025-04-23T01:46:07.227Z" },
- { url = "https://files.pythonhosted.org/packages/d0/25/d0d93a4e763f0462cccd2b8a665bf1e4343dd788c76dcfefa289d46a38a9/lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779", size = 5024224, upload-time = "2025-04-23T01:46:10.237Z" },
- { url = "https://files.pythonhosted.org/packages/31/ce/1df18fb8f7946e7f3388af378b1f34fcf253b94b9feedb2cec5969da8012/lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e", size = 4769913, upload-time = "2025-04-23T01:46:12.757Z" },
- { url = "https://files.pythonhosted.org/packages/4e/62/f4a6c60ae7c40d43657f552f3045df05118636be1165b906d3423790447f/lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9", size = 5290441, upload-time = "2025-04-23T01:46:16.037Z" },
- { url = "https://files.pythonhosted.org/packages/9e/aa/04f00009e1e3a77838c7fc948f161b5d2d5de1136b2b81c712a263829ea4/lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5", size = 4820165, upload-time = "2025-04-23T01:46:19.137Z" },
- { url = "https://files.pythonhosted.org/packages/c9/1f/e0b2f61fa2404bf0f1fdf1898377e5bd1b74cc9b2cf2c6ba8509b8f27990/lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5", size = 4932580, upload-time = "2025-04-23T01:46:21.963Z" },
- { url = "https://files.pythonhosted.org/packages/24/a2/8263f351b4ffe0ed3e32ea7b7830f845c795349034f912f490180d88a877/lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4", size = 4759493, upload-time = "2025-04-23T01:46:24.316Z" },
- { url = "https://files.pythonhosted.org/packages/05/00/41db052f279995c0e35c79d0f0fc9f8122d5b5e9630139c592a0b58c71b4/lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e", size = 5324679, upload-time = "2025-04-23T01:46:27.097Z" },
- { url = "https://files.pythonhosted.org/packages/1d/be/ee99e6314cdef4587617d3b3b745f9356d9b7dd12a9663c5f3b5734b64ba/lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7", size = 4890691, upload-time = "2025-04-23T01:46:30.009Z" },
- { url = "https://files.pythonhosted.org/packages/ad/36/239820114bf1d71f38f12208b9c58dec033cbcf80101cde006b9bde5cffd/lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079", size = 4955075, upload-time = "2025-04-23T01:46:32.33Z" },
- { url = "https://files.pythonhosted.org/packages/d4/e1/1b795cc0b174efc9e13dbd078a9ff79a58728a033142bc6d70a1ee8fc34d/lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20", size = 4838680, upload-time = "2025-04-23T01:46:34.852Z" },
- { url = "https://files.pythonhosted.org/packages/72/48/3c198455ca108cec5ae3662ae8acd7fd99476812fd712bb17f1b39a0b589/lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8", size = 5391253, upload-time = "2025-04-23T01:46:37.608Z" },
- { url = "https://files.pythonhosted.org/packages/d6/10/5bf51858971c51ec96cfc13e800a9951f3fd501686f4c18d7d84fe2d6352/lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f", size = 5261651, upload-time = "2025-04-23T01:46:40.183Z" },
- { url = "https://files.pythonhosted.org/packages/2b/11/06710dd809205377da380546f91d2ac94bad9ff735a72b64ec029f706c85/lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc", size = 5024315, upload-time = "2025-04-23T01:46:43.333Z" },
- { url = "https://files.pythonhosted.org/packages/f5/b0/15b6217834b5e3a59ebf7f53125e08e318030e8cc0d7310355e6edac98ef/lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f", size = 3486149, upload-time = "2025-04-23T01:46:45.684Z" },
- { url = "https://files.pythonhosted.org/packages/91/1e/05ddcb57ad2f3069101611bd5f5084157d90861a2ef460bf42f45cced944/lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2", size = 3817095, upload-time = "2025-04-23T01:46:48.521Z" },
+version = "6.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c5/ed/60eb6fa2923602fba988d9ca7c5cdbd7cf25faa795162ed538b527a35411/lxml-6.0.0.tar.gz", hash = "sha256:032e65120339d44cdc3efc326c9f660f5f7205f3a535c1fdbf898b29ea01fb72", size = 4096938, upload-time = "2025-06-26T16:28:19.373Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7c/23/828d4cc7da96c611ec0ce6147bbcea2fdbde023dc995a165afa512399bbf/lxml-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ee56288d0df919e4aac43b539dd0e34bb55d6a12a6562038e8d6f3ed07f9e36", size = 8438217, upload-time = "2025-06-26T16:25:34.349Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/33/5ac521212c5bcb097d573145d54b2b4a3c9766cda88af5a0e91f66037c6e/lxml-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8dd6dd0e9c1992613ccda2bcb74fc9d49159dbe0f0ca4753f37527749885c25", size = 4590317, upload-time = "2025-06-26T16:25:38.103Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/2e/45b7ca8bee304c07f54933c37afe7dd4d39ff61ba2757f519dcc71bc5d44/lxml-6.0.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:d7ae472f74afcc47320238b5dbfd363aba111a525943c8a34a1b657c6be934c3", size = 5221628, upload-time = "2025-06-26T16:25:40.878Z" },
+ { url = "https://files.pythonhosted.org/packages/32/23/526d19f7eb2b85da1f62cffb2556f647b049ebe2a5aa8d4d41b1fb2c7d36/lxml-6.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5592401cdf3dc682194727c1ddaa8aa0f3ddc57ca64fd03226a430b955eab6f6", size = 4949429, upload-time = "2025-06-28T18:47:20.046Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/cc/f6be27a5c656a43a5344e064d9ae004d4dcb1d3c9d4f323c8189ddfe4d13/lxml-6.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58ffd35bd5425c3c3b9692d078bf7ab851441434531a7e517c4984d5634cd65b", size = 5087909, upload-time = "2025-06-28T18:47:22.834Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/e6/8ec91b5bfbe6972458bc105aeb42088e50e4b23777170404aab5dfb0c62d/lxml-6.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f720a14aa102a38907c6d5030e3d66b3b680c3e6f6bc95473931ea3c00c59967", size = 5031713, upload-time = "2025-06-26T16:25:43.226Z" },
+ { url = "https://files.pythonhosted.org/packages/33/cf/05e78e613840a40e5be3e40d892c48ad3e475804db23d4bad751b8cadb9b/lxml-6.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2a5e8d207311a0170aca0eb6b160af91adc29ec121832e4ac151a57743a1e1e", size = 5232417, upload-time = "2025-06-26T16:25:46.111Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/8c/6b306b3e35c59d5f0b32e3b9b6b3b0739b32c0dc42a295415ba111e76495/lxml-6.0.0-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:2dd1cc3ea7e60bfb31ff32cafe07e24839df573a5e7c2d33304082a5019bcd58", size = 4681443, upload-time = "2025-06-26T16:25:48.837Z" },
+ { url = "https://files.pythonhosted.org/packages/59/43/0bd96bece5f7eea14b7220476835a60d2b27f8e9ca99c175f37c085cb154/lxml-6.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2cfcf84f1defed7e5798ef4f88aa25fcc52d279be731ce904789aa7ccfb7e8d2", size = 5074542, upload-time = "2025-06-26T16:25:51.65Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/3d/32103036287a8ca012d8518071f8852c68f2b3bfe048cef2a0202eb05910/lxml-6.0.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a52a4704811e2623b0324a18d41ad4b9fabf43ce5ff99b14e40a520e2190c851", size = 4729471, upload-time = "2025-06-26T16:25:54.571Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/a8/7be5d17df12d637d81854bd8648cd329f29640a61e9a72a3f77add4a311b/lxml-6.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c16304bba98f48a28ae10e32a8e75c349dd742c45156f297e16eeb1ba9287a1f", size = 5256285, upload-time = "2025-06-26T16:25:56.997Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/d0/6cb96174c25e0d749932557c8d51d60c6e292c877b46fae616afa23ed31a/lxml-6.0.0-cp311-cp311-win32.whl", hash = "sha256:f8d19565ae3eb956d84da3ef367aa7def14a2735d05bd275cd54c0301f0d0d6c", size = 3612004, upload-time = "2025-06-26T16:25:59.11Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/77/6ad43b165dfc6dead001410adeb45e88597b25185f4479b7ca3b16a5808f/lxml-6.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b2d71cdefda9424adff9a3607ba5bbfc60ee972d73c21c7e3c19e71037574816", size = 4003470, upload-time = "2025-06-26T16:26:01.655Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/bc/4c50ec0eb14f932a18efc34fc86ee936a66c0eb5f2fe065744a2da8a68b2/lxml-6.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:8a2e76efbf8772add72d002d67a4c3d0958638696f541734304c7f28217a9cab", size = 3682477, upload-time = "2025-06-26T16:26:03.808Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/d01d735c298d7e0ddcedf6f028bf556577e5ab4f4da45175ecd909c79378/lxml-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78718d8454a6e928470d511bf8ac93f469283a45c354995f7d19e77292f26108", size = 8429515, upload-time = "2025-06-26T16:26:06.776Z" },
+ { url = "https://files.pythonhosted.org/packages/06/37/0e3eae3043d366b73da55a86274a590bae76dc45aa004b7042e6f97803b1/lxml-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:84ef591495ffd3f9dcabffd6391db7bb70d7230b5c35ef5148354a134f56f2be", size = 4601387, upload-time = "2025-06-26T16:26:09.511Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/28/e1a9a881e6d6e29dda13d633885d13acb0058f65e95da67841c8dd02b4a8/lxml-6.0.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:2930aa001a3776c3e2601cb8e0a15d21b8270528d89cc308be4843ade546b9ab", size = 5228928, upload-time = "2025-06-26T16:26:12.337Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/55/2cb24ea48aa30c99f805921c1c7860c1f45c0e811e44ee4e6a155668de06/lxml-6.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:219e0431ea8006e15005767f0351e3f7f9143e793e58519dc97fe9e07fae5563", size = 4952289, upload-time = "2025-06-28T18:47:25.602Z" },
+ { url = "https://files.pythonhosted.org/packages/31/c0/b25d9528df296b9a3306ba21ff982fc5b698c45ab78b94d18c2d6ae71fd9/lxml-6.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bd5913b4972681ffc9718bc2d4c53cde39ef81415e1671ff93e9aa30b46595e7", size = 5111310, upload-time = "2025-06-28T18:47:28.136Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/af/681a8b3e4f668bea6e6514cbcb297beb6de2b641e70f09d3d78655f4f44c/lxml-6.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:390240baeb9f415a82eefc2e13285016f9c8b5ad71ec80574ae8fa9605093cd7", size = 5025457, upload-time = "2025-06-26T16:26:15.068Z" },
+ { url = "https://files.pythonhosted.org/packages/99/b6/3a7971aa05b7be7dfebc7ab57262ec527775c2c3c5b2f43675cac0458cad/lxml-6.0.0-cp312-cp312-manylinux_2_27_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d6e200909a119626744dd81bae409fc44134389e03fbf1d68ed2a55a2fb10991", size = 5657016, upload-time = "2025-07-03T19:19:06.008Z" },
+ { url = "https://files.pythonhosted.org/packages/69/f8/693b1a10a891197143c0673fcce5b75fc69132afa81a36e4568c12c8faba/lxml-6.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca50bd612438258a91b5b3788c6621c1f05c8c478e7951899f492be42defc0da", size = 5257565, upload-time = "2025-06-26T16:26:17.906Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/96/e08ff98f2c6426c98c8964513c5dab8d6eb81dadcd0af6f0c538ada78d33/lxml-6.0.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:c24b8efd9c0f62bad0439283c2c795ef916c5a6b75f03c17799775c7ae3c0c9e", size = 4713390, upload-time = "2025-06-26T16:26:20.292Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/83/6184aba6cc94d7413959f6f8f54807dc318fdcd4985c347fe3ea6937f772/lxml-6.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afd27d8629ae94c5d863e32ab0e1d5590371d296b87dae0a751fb22bf3685741", size = 5066103, upload-time = "2025-06-26T16:26:22.765Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/01/8bf1f4035852d0ff2e36a4d9aacdbcc57e93a6cd35a54e05fa984cdf73ab/lxml-6.0.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:54c4855eabd9fc29707d30141be99e5cd1102e7d2258d2892314cf4c110726c3", size = 4791428, upload-time = "2025-06-26T16:26:26.461Z" },
+ { url = "https://files.pythonhosted.org/packages/29/31/c0267d03b16954a85ed6b065116b621d37f559553d9339c7dcc4943a76f1/lxml-6.0.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c907516d49f77f6cd8ead1322198bdfd902003c3c330c77a1c5f3cc32a0e4d16", size = 5678523, upload-time = "2025-07-03T19:19:09.837Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/f7/5495829a864bc5f8b0798d2b52a807c89966523140f3d6fa3a58ab6720ea/lxml-6.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:36531f81c8214e293097cd2b7873f178997dae33d3667caaae8bdfb9666b76c0", size = 5281290, upload-time = "2025-06-26T16:26:29.406Z" },
+ { url = "https://files.pythonhosted.org/packages/79/56/6b8edb79d9ed294ccc4e881f4db1023af56ba451909b9ce79f2a2cd7c532/lxml-6.0.0-cp312-cp312-win32.whl", hash = "sha256:690b20e3388a7ec98e899fd54c924e50ba6693874aa65ef9cb53de7f7de9d64a", size = 3613495, upload-time = "2025-06-26T16:26:31.588Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/1e/cc32034b40ad6af80b6fd9b66301fc0f180f300002e5c3eb5a6110a93317/lxml-6.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:310b719b695b3dd442cdfbbe64936b2f2e231bb91d998e99e6f0daf991a3eba3", size = 4014711, upload-time = "2025-06-26T16:26:33.723Z" },
+ { url = "https://files.pythonhosted.org/packages/55/10/dc8e5290ae4c94bdc1a4c55865be7e1f31dfd857a88b21cbba68b5fea61b/lxml-6.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:8cb26f51c82d77483cdcd2b4a53cda55bbee29b3c2f3ddeb47182a2a9064e4eb", size = 3674431, upload-time = "2025-06-26T16:26:35.959Z" },
]
[[package]]
@@ -3050,16 +3050,16 @@ wheels = [
[[package]]
name = "milvus-lite"
-version = "2.4.12"
+version = "2.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tqdm" },
]
wheels = [
- { url = "https://files.pythonhosted.org/packages/64/3a/110e46db650ced604f97307e48e353726cfa6d26b1bf72acb81bbf07ecbd/milvus_lite-2.4.12-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:e8d4f7cdd5f731efd6faeee3715d280fd91a5f9b4d89312664d56401f65b1473", size = 19843871, upload-time = "2025-03-21T06:20:26.141Z" },
- { url = "https://files.pythonhosted.org/packages/a5/a7/11c21f2d6f3299ad07af8142b007e4297ff12d4bdc53e1e1ba48f661954b/milvus_lite-2.4.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:20087663e7b4385050b7ad08f1f03404426d4c87b1ff91d5a8723eee7fd49e88", size = 17411635, upload-time = "2025-03-21T06:20:43.548Z" },
- { url = "https://files.pythonhosted.org/packages/a8/cc/b6f465e984439adf24da0a8ff3035d5c9ece30b6ff19f9a53f73f9ef901a/milvus_lite-2.4.12-py3-none-manylinux2014_aarch64.whl", hash = "sha256:a0f3a5ddbfd19f4a6b842b2fd3445693c796cde272b701a1646a94c1ac45d3d7", size = 35693118, upload-time = "2025-03-21T06:21:14.921Z" },
- { url = "https://files.pythonhosted.org/packages/44/43/b3f6e9defd1f3927b972beac7abe3d5b4a3bdb287e3bad69618e2e76cf0a/milvus_lite-2.4.12-py3-none-manylinux2014_x86_64.whl", hash = "sha256:334037ebbab60243b5d8b43d54ca2f835d81d48c3cda0c6a462605e588deb05d", size = 45182549, upload-time = "2025-03-21T06:21:45.425Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/b2/acc5024c8e8b6a0b034670b8e8af306ebd633ede777dcbf557eac4785937/milvus_lite-2.5.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:6b014453200ba977be37ba660cb2d021030375fa6a35bc53c2e1d92980a0c512", size = 27934713, upload-time = "2025-06-30T04:23:37.028Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/2e/746f5bb1d6facd1e73eb4af6dd5efda11125b0f29d7908a097485ca6cad9/milvus_lite-2.5.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a2e031088bf308afe5f8567850412d618cfb05a65238ed1a6117f60decccc95a", size = 24421451, upload-time = "2025-06-30T04:23:51.747Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/cf/3d1fee5c16c7661cf53977067a34820f7269ed8ba99fe9cf35efc1700866/milvus_lite-2.5.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:a13277e9bacc6933dea172e42231f7e6135bd3bdb073dd2688ee180418abd8d9", size = 45337093, upload-time = "2025-06-30T04:24:06.706Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/82/41d9b80f09b82e066894d9b508af07b7b0fa325ce0322980674de49106a0/milvus_lite-2.5.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:25ce13f4b8d46876dd2b7ac8563d7d8306da7ff3999bb0d14b116b30f71d706c", size = 55263911, upload-time = "2025-06-30T04:24:19.434Z" },
]
[[package]]
@@ -3102,6 +3102,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/80/9d/627375bab4c90dd066093fc2c9a26b86f87e26d980dbf71667b44cbee3eb/mmh3-5.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:a4c1a76808dfea47f7407a0b07aaff9087447ef6280716fd0783409b3088bb3c", size = 38888, upload-time = "2025-01-25T08:39:05.174Z" },
]
+[[package]]
+name = "mo-vector"
+version = "0.1.13"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "pymysql" },
+ { name = "sqlalchemy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/01/03/2ef4de1c8d970288f018b6b63439563336c51f26f57706dc51e4c395fdbe/mo_vector-0.1.13.tar.gz", hash = "sha256:8526c37e99157a0c9866bf3868600e877980464eccb212f8ea71971c0630eb69", size = 16926, upload-time = "2025-06-18T09:27:27.906Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0d/e7/514f5cf5909f96adf09b78146a9e5c92f82abcc212bc3f88456bf2640c23/mo_vector-0.1.13-py3-none-any.whl", hash = "sha256:f7d619acc3e92ed59631e6b3a12508240e22cf428c87daf022c0d87fbd5da459", size = 20091, upload-time = "2025-06-18T09:27:26.899Z" },
+]
+
[[package]]
name = "mpmath"
version = "1.3.0"
@@ -3155,83 +3169,85 @@ wheels = [
[[package]]
name = "multidict"
-version = "6.4.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/91/2f/a3470242707058fe856fe59241eee5635d79087100b7042a867368863a27/multidict-6.4.4.tar.gz", hash = "sha256:69ee9e6ba214b5245031b76233dd95408a0fd57fdb019ddcc1ead4790932a8e8", size = 90183, upload-time = "2025-05-19T14:16:37.381Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/19/1b/4c6e638195851524a63972c5773c7737bea7e47b1ba402186a37773acee2/multidict-6.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4f5f29794ac0e73d2a06ac03fd18870adc0135a9d384f4a306a951188ed02f95", size = 65515, upload-time = "2025-05-19T14:14:19.767Z" },
- { url = "https://files.pythonhosted.org/packages/25/d5/10e6bca9a44b8af3c7f920743e5fc0c2bcf8c11bf7a295d4cfe00b08fb46/multidict-6.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c04157266344158ebd57b7120d9b0b35812285d26d0e78193e17ef57bfe2979a", size = 38609, upload-time = "2025-05-19T14:14:21.538Z" },
- { url = "https://files.pythonhosted.org/packages/26/b4/91fead447ccff56247edc7f0535fbf140733ae25187a33621771ee598a18/multidict-6.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bb61ffd3ab8310d93427e460f565322c44ef12769f51f77277b4abad7b6f7223", size = 37871, upload-time = "2025-05-19T14:14:22.666Z" },
- { url = "https://files.pythonhosted.org/packages/3b/37/cbc977cae59277e99d15bbda84cc53b5e0c4929ffd91d958347200a42ad0/multidict-6.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e0ba18a9afd495f17c351d08ebbc4284e9c9f7971d715f196b79636a4d0de44", size = 226661, upload-time = "2025-05-19T14:14:24.124Z" },
- { url = "https://files.pythonhosted.org/packages/15/cd/7e0b57fbd4dc2fc105169c4ecce5be1a63970f23bb4ec8c721b67e11953d/multidict-6.4.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9faf1b1dcaadf9f900d23a0e6d6c8eadd6a95795a0e57fcca73acce0eb912065", size = 223422, upload-time = "2025-05-19T14:14:25.437Z" },
- { url = "https://files.pythonhosted.org/packages/f1/01/1de268da121bac9f93242e30cd3286f6a819e5f0b8896511162d6ed4bf8d/multidict-6.4.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a4d1cb1327c6082c4fce4e2a438483390964c02213bc6b8d782cf782c9b1471f", size = 235447, upload-time = "2025-05-19T14:14:26.793Z" },
- { url = "https://files.pythonhosted.org/packages/d2/8c/8b9a5e4aaaf4f2de14e86181a3a3d7b105077f668b6a06f043ec794f684c/multidict-6.4.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:941f1bec2f5dbd51feeb40aea654c2747f811ab01bdd3422a48a4e4576b7d76a", size = 231455, upload-time = "2025-05-19T14:14:28.149Z" },
- { url = "https://files.pythonhosted.org/packages/35/db/e1817dcbaa10b319c412769cf999b1016890849245d38905b73e9c286862/multidict-6.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5f8a146184da7ea12910a4cec51ef85e44f6268467fb489c3caf0cd512f29c2", size = 223666, upload-time = "2025-05-19T14:14:29.584Z" },
- { url = "https://files.pythonhosted.org/packages/4a/e1/66e8579290ade8a00e0126b3d9a93029033ffd84f0e697d457ed1814d0fc/multidict-6.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:232b7237e57ec3c09be97206bfb83a0aa1c5d7d377faa019c68a210fa35831f1", size = 217392, upload-time = "2025-05-19T14:14:30.961Z" },
- { url = "https://files.pythonhosted.org/packages/7b/6f/f8639326069c24a48c7747c2a5485d37847e142a3f741ff3340c88060a9a/multidict-6.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:55ae0721c1513e5e3210bca4fc98456b980b0c2c016679d3d723119b6b202c42", size = 228969, upload-time = "2025-05-19T14:14:32.672Z" },
- { url = "https://files.pythonhosted.org/packages/d2/c3/3d58182f76b960eeade51c89fcdce450f93379340457a328e132e2f8f9ed/multidict-6.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:51d662c072579f63137919d7bb8fc250655ce79f00c82ecf11cab678f335062e", size = 217433, upload-time = "2025-05-19T14:14:34.016Z" },
- { url = "https://files.pythonhosted.org/packages/e1/4b/f31a562906f3bd375f3d0e83ce314e4a660c01b16c2923e8229b53fba5d7/multidict-6.4.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0e05c39962baa0bb19a6b210e9b1422c35c093b651d64246b6c2e1a7e242d9fd", size = 225418, upload-time = "2025-05-19T14:14:35.376Z" },
- { url = "https://files.pythonhosted.org/packages/99/89/78bb95c89c496d64b5798434a3deee21996114d4d2c28dd65850bf3a691e/multidict-6.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5b1cc3ab8c31d9ebf0faa6e3540fb91257590da330ffe6d2393d4208e638925", size = 235042, upload-time = "2025-05-19T14:14:36.723Z" },
- { url = "https://files.pythonhosted.org/packages/74/91/8780a6e5885a8770442a8f80db86a0887c4becca0e5a2282ba2cae702bc4/multidict-6.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:93ec84488a384cd7b8a29c2c7f467137d8a73f6fe38bb810ecf29d1ade011a7c", size = 230280, upload-time = "2025-05-19T14:14:38.194Z" },
- { url = "https://files.pythonhosted.org/packages/68/c1/fcf69cabd542eb6f4b892469e033567ee6991d361d77abdc55e3a0f48349/multidict-6.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b308402608493638763abc95f9dc0030bbd6ac6aff784512e8ac3da73a88af08", size = 223322, upload-time = "2025-05-19T14:14:40.015Z" },
- { url = "https://files.pythonhosted.org/packages/b8/85/5b80bf4b83d8141bd763e1d99142a9cdfd0db83f0739b4797172a4508014/multidict-6.4.4-cp311-cp311-win32.whl", hash = "sha256:343892a27d1a04d6ae455ecece12904d242d299ada01633d94c4f431d68a8c49", size = 35070, upload-time = "2025-05-19T14:14:41.904Z" },
- { url = "https://files.pythonhosted.org/packages/09/66/0bed198ffd590ab86e001f7fa46b740d58cf8ff98c2f254e4a36bf8861ad/multidict-6.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:73484a94f55359780c0f458bbd3c39cb9cf9c182552177d2136e828269dee529", size = 38667, upload-time = "2025-05-19T14:14:43.534Z" },
- { url = "https://files.pythonhosted.org/packages/d2/b5/5675377da23d60875fe7dae6be841787755878e315e2f517235f22f59e18/multidict-6.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc388f75a1c00000824bf28b7633e40854f4127ede80512b44c3cfeeea1839a2", size = 64293, upload-time = "2025-05-19T14:14:44.724Z" },
- { url = "https://files.pythonhosted.org/packages/34/a7/be384a482754bb8c95d2bbe91717bf7ccce6dc38c18569997a11f95aa554/multidict-6.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:98af87593a666f739d9dba5d0ae86e01b0e1a9cfcd2e30d2d361fbbbd1a9162d", size = 38096, upload-time = "2025-05-19T14:14:45.95Z" },
- { url = "https://files.pythonhosted.org/packages/66/6d/d59854bb4352306145bdfd1704d210731c1bb2c890bfee31fb7bbc1c4c7f/multidict-6.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aff4cafea2d120327d55eadd6b7f1136a8e5a0ecf6fb3b6863e8aca32cd8e50a", size = 37214, upload-time = "2025-05-19T14:14:47.158Z" },
- { url = "https://files.pythonhosted.org/packages/99/e0/c29d9d462d7cfc5fc8f9bf24f9c6843b40e953c0b55e04eba2ad2cf54fba/multidict-6.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:169c4ba7858176b797fe551d6e99040c531c775d2d57b31bcf4de6d7a669847f", size = 224686, upload-time = "2025-05-19T14:14:48.366Z" },
- { url = "https://files.pythonhosted.org/packages/dc/4a/da99398d7fd8210d9de068f9a1b5f96dfaf67d51e3f2521f17cba4ee1012/multidict-6.4.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b9eb4c59c54421a32b3273d4239865cb14ead53a606db066d7130ac80cc8ec93", size = 231061, upload-time = "2025-05-19T14:14:49.952Z" },
- { url = "https://files.pythonhosted.org/packages/21/f5/ac11add39a0f447ac89353e6ca46666847051103649831c08a2800a14455/multidict-6.4.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7cf3bd54c56aa16fdb40028d545eaa8d051402b61533c21e84046e05513d5780", size = 232412, upload-time = "2025-05-19T14:14:51.812Z" },
- { url = "https://files.pythonhosted.org/packages/d9/11/4b551e2110cded705a3c13a1d4b6a11f73891eb5a1c449f1b2b6259e58a6/multidict-6.4.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f682c42003c7264134bfe886376299db4cc0c6cd06a3295b41b347044bcb5482", size = 231563, upload-time = "2025-05-19T14:14:53.262Z" },
- { url = "https://files.pythonhosted.org/packages/4c/02/751530c19e78fe73b24c3da66618eda0aa0d7f6e7aa512e46483de6be210/multidict-6.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a920f9cf2abdf6e493c519492d892c362007f113c94da4c239ae88429835bad1", size = 223811, upload-time = "2025-05-19T14:14:55.232Z" },
- { url = "https://files.pythonhosted.org/packages/c7/cb/2be8a214643056289e51ca356026c7b2ce7225373e7a1f8c8715efee8988/multidict-6.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:530d86827a2df6504526106b4c104ba19044594f8722d3e87714e847c74a0275", size = 216524, upload-time = "2025-05-19T14:14:57.226Z" },
- { url = "https://files.pythonhosted.org/packages/19/f3/6d5011ec375c09081f5250af58de85f172bfcaafebff286d8089243c4bd4/multidict-6.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ecde56ea2439b96ed8a8d826b50c57364612ddac0438c39e473fafad7ae1c23b", size = 229012, upload-time = "2025-05-19T14:14:58.597Z" },
- { url = "https://files.pythonhosted.org/packages/67/9c/ca510785df5cf0eaf5b2a8132d7d04c1ce058dcf2c16233e596ce37a7f8e/multidict-6.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:dc8c9736d8574b560634775ac0def6bdc1661fc63fa27ffdfc7264c565bcb4f2", size = 226765, upload-time = "2025-05-19T14:15:00.048Z" },
- { url = "https://files.pythonhosted.org/packages/36/c8/ca86019994e92a0f11e642bda31265854e6ea7b235642f0477e8c2e25c1f/multidict-6.4.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7f3d3b3c34867579ea47cbd6c1f2ce23fbfd20a273b6f9e3177e256584f1eacc", size = 222888, upload-time = "2025-05-19T14:15:01.568Z" },
- { url = "https://files.pythonhosted.org/packages/c6/67/bc25a8e8bd522935379066950ec4e2277f9b236162a73548a2576d4b9587/multidict-6.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:87a728af265e08f96b6318ebe3c0f68b9335131f461efab2fc64cc84a44aa6ed", size = 234041, upload-time = "2025-05-19T14:15:03.759Z" },
- { url = "https://files.pythonhosted.org/packages/f1/a0/70c4c2d12857fccbe607b334b7ee28b6b5326c322ca8f73ee54e70d76484/multidict-6.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9f193eeda1857f8e8d3079a4abd258f42ef4a4bc87388452ed1e1c4d2b0c8740", size = 231046, upload-time = "2025-05-19T14:15:05.698Z" },
- { url = "https://files.pythonhosted.org/packages/c1/0f/52954601d02d39742aab01d6b92f53c1dd38b2392248154c50797b4df7f1/multidict-6.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be06e73c06415199200e9a2324a11252a3d62030319919cde5e6950ffeccf72e", size = 227106, upload-time = "2025-05-19T14:15:07.124Z" },
- { url = "https://files.pythonhosted.org/packages/af/24/679d83ec4379402d28721790dce818e5d6b9f94ce1323a556fb17fa9996c/multidict-6.4.4-cp312-cp312-win32.whl", hash = "sha256:622f26ea6a7e19b7c48dd9228071f571b2fbbd57a8cd71c061e848f281550e6b", size = 35351, upload-time = "2025-05-19T14:15:08.556Z" },
- { url = "https://files.pythonhosted.org/packages/52/ef/40d98bc5f986f61565f9b345f102409534e29da86a6454eb6b7c00225a13/multidict-6.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:5e2bcda30d5009996ff439e02a9f2b5c3d64a20151d34898c000a6281faa3781", size = 38791, upload-time = "2025-05-19T14:15:09.825Z" },
- { url = "https://files.pythonhosted.org/packages/84/5d/e17845bb0fa76334477d5de38654d27946d5b5d3695443987a094a71b440/multidict-6.4.4-py3-none-any.whl", hash = "sha256:bd4557071b561a8b3b6075c3ce93cf9bfb6182cb241805c3d66ced3b75eff4ac", size = 10481, upload-time = "2025-05-19T14:16:36.024Z" },
+version = "6.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/3d/2c/5dad12e82fbdf7470f29bff2171484bf07cb3b16ada60a6589af8f376440/multidict-6.6.3.tar.gz", hash = "sha256:798a9eb12dab0a6c2e29c1de6f3468af5cb2da6053a20dfa3344907eed0937cc", size = 101006, upload-time = "2025-06-30T15:53:46.929Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/08/f0/1a39863ced51f639c81a5463fbfa9eb4df59c20d1a8769ab9ef4ca57ae04/multidict-6.6.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:18f4eba0cbac3546b8ae31e0bbc55b02c801ae3cbaf80c247fcdd89b456ff58c", size = 76445, upload-time = "2025-06-30T15:51:24.01Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/0e/a7cfa451c7b0365cd844e90b41e21fab32edaa1e42fc0c9f68461ce44ed7/multidict-6.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef43b5dd842382329e4797c46f10748d8c2b6e0614f46b4afe4aee9ac33159df", size = 44610, upload-time = "2025-06-30T15:51:25.158Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/bb/a14a4efc5ee748cc1904b0748be278c31b9295ce5f4d2ef66526f410b94d/multidict-6.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bd1fd5eec01494e0f2e8e446a74a85d5e49afb63d75a9934e4a5423dba21d", size = 44267, upload-time = "2025-06-30T15:51:26.326Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/f8/410677d563c2d55e063ef74fe578f9d53fe6b0a51649597a5861f83ffa15/multidict-6.6.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5bd8d6f793a787153956cd35e24f60485bf0651c238e207b9a54f7458b16d539", size = 230004, upload-time = "2025-06-30T15:51:27.491Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/df/2b787f80059314a98e1ec6a4cc7576244986df3e56b3c755e6fc7c99e038/multidict-6.6.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bf99b4daf908c73856bd87ee0a2499c3c9a3d19bb04b9c6025e66af3fd07462", size = 247196, upload-time = "2025-06-30T15:51:28.762Z" },
+ { url = "https://files.pythonhosted.org/packages/05/f2/f9117089151b9a8ab39f9019620d10d9718eec2ac89e7ca9d30f3ec78e96/multidict-6.6.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b9e59946b49dafaf990fd9c17ceafa62976e8471a14952163d10a7a630413a9", size = 225337, upload-time = "2025-06-30T15:51:30.025Z" },
+ { url = "https://files.pythonhosted.org/packages/93/2d/7115300ec5b699faa152c56799b089a53ed69e399c3c2d528251f0aeda1a/multidict-6.6.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e2db616467070d0533832d204c54eea6836a5e628f2cb1e6dfd8cd6ba7277cb7", size = 257079, upload-time = "2025-06-30T15:51:31.716Z" },
+ { url = "https://files.pythonhosted.org/packages/15/ea/ff4bab367623e39c20d3b07637225c7688d79e4f3cc1f3b9f89867677f9a/multidict-6.6.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7394888236621f61dcdd25189b2768ae5cc280f041029a5bcf1122ac63df79f9", size = 255461, upload-time = "2025-06-30T15:51:33.029Z" },
+ { url = "https://files.pythonhosted.org/packages/74/07/2c9246cda322dfe08be85f1b8739646f2c4c5113a1422d7a407763422ec4/multidict-6.6.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f114d8478733ca7388e7c7e0ab34b72547476b97009d643644ac33d4d3fe1821", size = 246611, upload-time = "2025-06-30T15:51:34.47Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/62/279c13d584207d5697a752a66ffc9bb19355a95f7659140cb1b3cf82180e/multidict-6.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdf22e4db76d323bcdc733514bf732e9fb349707c98d341d40ebcc6e9318ef3d", size = 243102, upload-time = "2025-06-30T15:51:36.525Z" },
+ { url = "https://files.pythonhosted.org/packages/69/cc/e06636f48c6d51e724a8bc8d9e1db5f136fe1df066d7cafe37ef4000f86a/multidict-6.6.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e995a34c3d44ab511bfc11aa26869b9d66c2d8c799fa0e74b28a473a692532d6", size = 238693, upload-time = "2025-06-30T15:51:38.278Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a4/66c9d8fb9acf3b226cdd468ed009537ac65b520aebdc1703dd6908b19d33/multidict-6.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:766a4a5996f54361d8d5a9050140aa5362fe48ce51c755a50c0bc3706460c430", size = 246582, upload-time = "2025-06-30T15:51:39.709Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/01/c69e0317be556e46257826d5449feb4e6aa0d18573e567a48a2c14156f1f/multidict-6.6.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3893a0d7d28a7fe6ca7a1f760593bc13038d1d35daf52199d431b61d2660602b", size = 253355, upload-time = "2025-06-30T15:51:41.013Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/da/9cc1da0299762d20e626fe0042e71b5694f9f72d7d3f9678397cbaa71b2b/multidict-6.6.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:934796c81ea996e61914ba58064920d6cad5d99140ac3167901eb932150e2e56", size = 247774, upload-time = "2025-06-30T15:51:42.291Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/91/b22756afec99cc31105ddd4a52f95ab32b1a4a58f4d417979c570c4a922e/multidict-6.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9ed948328aec2072bc00f05d961ceadfd3e9bfc2966c1319aeaf7b7c21219183", size = 242275, upload-time = "2025-06-30T15:51:43.642Z" },
+ { url = "https://files.pythonhosted.org/packages/be/f1/adcc185b878036a20399d5be5228f3cbe7f823d78985d101d425af35c800/multidict-6.6.3-cp311-cp311-win32.whl", hash = "sha256:9f5b28c074c76afc3e4c610c488e3493976fe0e596dd3db6c8ddfbb0134dcac5", size = 41290, upload-time = "2025-06-30T15:51:45.264Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/d4/27652c1c6526ea6b4f5ddd397e93f4232ff5de42bea71d339bc6a6cc497f/multidict-6.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:bc7f6fbc61b1c16050a389c630da0b32fc6d4a3d191394ab78972bf5edc568c2", size = 45942, upload-time = "2025-06-30T15:51:46.377Z" },
+ { url = "https://files.pythonhosted.org/packages/16/18/23f4932019804e56d3c2413e237f866444b774b0263bcb81df2fdecaf593/multidict-6.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:d4e47d8faffaae822fb5cba20937c048d4f734f43572e7079298a6c39fb172cb", size = 42880, upload-time = "2025-06-30T15:51:47.561Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/a0/6b57988ea102da0623ea814160ed78d45a2645e4bbb499c2896d12833a70/multidict-6.6.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:056bebbeda16b2e38642d75e9e5310c484b7c24e3841dc0fb943206a72ec89d6", size = 76514, upload-time = "2025-06-30T15:51:48.728Z" },
+ { url = "https://files.pythonhosted.org/packages/07/7a/d1e92665b0850c6c0508f101f9cf0410c1afa24973e1115fe9c6a185ebf7/multidict-6.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e5f481cccb3c5c5e5de5d00b5141dc589c1047e60d07e85bbd7dea3d4580d63f", size = 45394, upload-time = "2025-06-30T15:51:49.986Z" },
+ { url = "https://files.pythonhosted.org/packages/52/6f/dd104490e01be6ef8bf9573705d8572f8c2d2c561f06e3826b081d9e6591/multidict-6.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10bea2ee839a759ee368b5a6e47787f399b41e70cf0c20d90dfaf4158dfb4e55", size = 43590, upload-time = "2025-06-30T15:51:51.331Z" },
+ { url = "https://files.pythonhosted.org/packages/44/fe/06e0e01b1b0611e6581b7fd5a85b43dacc08b6cea3034f902f383b0873e5/multidict-6.6.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2334cfb0fa9549d6ce2c21af2bfbcd3ac4ec3646b1b1581c88e3e2b1779ec92b", size = 237292, upload-time = "2025-06-30T15:51:52.584Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/71/4f0e558fb77696b89c233c1ee2d92f3e1d5459070a0e89153c9e9e804186/multidict-6.6.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8fee016722550a2276ca2cb5bb624480e0ed2bd49125b2b73b7010b9090e888", size = 258385, upload-time = "2025-06-30T15:51:53.913Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/25/cca0e68228addad24903801ed1ab42e21307a1b4b6dd2cf63da5d3ae082a/multidict-6.6.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5511cb35f5c50a2db21047c875eb42f308c5583edf96bd8ebf7d770a9d68f6d", size = 242328, upload-time = "2025-06-30T15:51:55.672Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/a3/46f2d420d86bbcb8fe660b26a10a219871a0fbf4d43cb846a4031533f3e0/multidict-6.6.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:712b348f7f449948e0a6c4564a21c7db965af900973a67db432d724619b3c680", size = 268057, upload-time = "2025-06-30T15:51:57.037Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/73/1c743542fe00794a2ec7466abd3f312ccb8fad8dff9f36d42e18fb1ec33e/multidict-6.6.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e15d2138ee2694e038e33b7c3da70e6b0ad8868b9f8094a72e1414aeda9c1a", size = 269341, upload-time = "2025-06-30T15:51:59.111Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/11/6ec9dcbe2264b92778eeb85407d1df18812248bf3506a5a1754bc035db0c/multidict-6.6.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8df25594989aebff8a130f7899fa03cbfcc5d2b5f4a461cf2518236fe6f15961", size = 256081, upload-time = "2025-06-30T15:52:00.533Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/2b/631b1e2afeb5f1696846d747d36cda075bfdc0bc7245d6ba5c319278d6c4/multidict-6.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:159ca68bfd284a8860f8d8112cf0521113bffd9c17568579e4d13d1f1dc76b65", size = 253581, upload-time = "2025-06-30T15:52:02.43Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/0e/7e3b93f79efeb6111d3bf9a1a69e555ba1d07ad1c11bceb56b7310d0d7ee/multidict-6.6.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e098c17856a8c9ade81b4810888c5ad1914099657226283cab3062c0540b0643", size = 250750, upload-time = "2025-06-30T15:52:04.26Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/9e/086846c1d6601948e7de556ee464a2d4c85e33883e749f46b9547d7b0704/multidict-6.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:67c92ed673049dec52d7ed39f8cf9ebbadf5032c774058b4406d18c8f8fe7063", size = 251548, upload-time = "2025-06-30T15:52:06.002Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/7b/86ec260118e522f1a31550e87b23542294880c97cfbf6fb18cc67b044c66/multidict-6.6.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:bd0578596e3a835ef451784053cfd327d607fc39ea1a14812139339a18a0dbc3", size = 262718, upload-time = "2025-06-30T15:52:07.707Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/bd/22ce8f47abb0be04692c9fc4638508b8340987b18691aa7775d927b73f72/multidict-6.6.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:346055630a2df2115cd23ae271910b4cae40f4e336773550dca4889b12916e75", size = 259603, upload-time = "2025-06-30T15:52:09.58Z" },
+ { url = "https://files.pythonhosted.org/packages/07/9c/91b7ac1691be95cd1f4a26e36a74b97cda6aa9820632d31aab4410f46ebd/multidict-6.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:555ff55a359302b79de97e0468e9ee80637b0de1fce77721639f7cd9440b3a10", size = 251351, upload-time = "2025-06-30T15:52:10.947Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/5c/4d7adc739884f7a9fbe00d1eac8c034023ef8bad71f2ebe12823ca2e3649/multidict-6.6.3-cp312-cp312-win32.whl", hash = "sha256:73ab034fb8d58ff85c2bcbadc470efc3fafeea8affcf8722855fb94557f14cc5", size = 41860, upload-time = "2025-06-30T15:52:12.334Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/a3/0fbc7afdf7cb1aa12a086b02959307848eb6bcc8f66fcb66c0cb57e2a2c1/multidict-6.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:04cbcce84f63b9af41bad04a54d4cc4e60e90c35b9e6ccb130be2d75b71f8c17", size = 45982, upload-time = "2025-06-30T15:52:13.6Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/95/8c825bd70ff9b02462dc18d1295dd08d3e9e4eb66856d292ffa62cfe1920/multidict-6.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:0f1130b896ecb52d2a1e615260f3ea2af55fa7dc3d7c3003ba0c3121a759b18b", size = 43210, upload-time = "2025-06-30T15:52:14.893Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/30/9aec301e9772b098c1f5c0ca0279237c9766d94b97802e9888010c64b0ed/multidict-6.6.3-py3-none-any.whl", hash = "sha256:8db10f29c7541fc5da4defd8cd697e1ca429db743fa716325f236079b96f775a", size = 12313, upload-time = "2025-06-30T15:53:45.437Z" },
]
[[package]]
name = "mypy"
-version = "1.16.0"
+version = "1.16.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/d4/38/13c2f1abae94d5ea0354e146b95a1be9b2137a0d506728e0da037c4276f6/mypy-1.16.0.tar.gz", hash = "sha256:84b94283f817e2aa6350a14b4a8fb2a35a53c286f97c9d30f53b63620e7af8ab", size = 3323139, upload-time = "2025-05-29T13:46:12.532Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/81/69/92c7fa98112e4d9eb075a239caa4ef4649ad7d441545ccffbd5e34607cbb/mypy-1.16.1.tar.gz", hash = "sha256:6bd00a0a2094841c5e47e7374bb42b83d64c527a502e3334e1173a0c24437bab", size = 3324747, upload-time = "2025-06-16T16:51:35.145Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/24/c4/ff2f79db7075c274fe85b5fff8797d29c6b61b8854c39e3b7feb556aa377/mypy-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9f826aaa7ff8443bac6a494cf743f591488ea940dd360e7dd330e30dd772a5ab", size = 10884498, upload-time = "2025-05-29T13:18:54.066Z" },
- { url = "https://files.pythonhosted.org/packages/02/07/12198e83006235f10f6a7808917376b5d6240a2fd5dce740fe5d2ebf3247/mypy-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:82d056e6faa508501af333a6af192c700b33e15865bda49611e3d7d8358ebea2", size = 10011755, upload-time = "2025-05-29T13:34:00.851Z" },
- { url = "https://files.pythonhosted.org/packages/f1/9b/5fd5801a72b5d6fb6ec0105ea1d0e01ab2d4971893076e558d4b6d6b5f80/mypy-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:089bedc02307c2548eb51f426e085546db1fa7dd87fbb7c9fa561575cf6eb1ff", size = 11800138, upload-time = "2025-05-29T13:32:55.082Z" },
- { url = "https://files.pythonhosted.org/packages/2e/81/a117441ea5dfc3746431e51d78a4aca569c677aa225bca2cc05a7c239b61/mypy-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6a2322896003ba66bbd1318c10d3afdfe24e78ef12ea10e2acd985e9d684a666", size = 12533156, upload-time = "2025-05-29T13:19:12.963Z" },
- { url = "https://files.pythonhosted.org/packages/3f/38/88ec57c6c86014d3f06251e00f397b5a7daa6888884d0abf187e4f5f587f/mypy-1.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:021a68568082c5b36e977d54e8f1de978baf401a33884ffcea09bd8e88a98f4c", size = 12742426, upload-time = "2025-05-29T13:20:22.72Z" },
- { url = "https://files.pythonhosted.org/packages/bd/53/7e9d528433d56e6f6f77ccf24af6ce570986c2d98a5839e4c2009ef47283/mypy-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:54066fed302d83bf5128632d05b4ec68412e1f03ef2c300434057d66866cea4b", size = 9478319, upload-time = "2025-05-29T13:21:17.582Z" },
- { url = "https://files.pythonhosted.org/packages/70/cf/158e5055e60ca2be23aec54a3010f89dcffd788732634b344fc9cb1e85a0/mypy-1.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c5436d11e89a3ad16ce8afe752f0f373ae9620841c50883dc96f8b8805620b13", size = 11062927, upload-time = "2025-05-29T13:35:52.328Z" },
- { url = "https://files.pythonhosted.org/packages/94/34/cfff7a56be1609f5d10ef386342ce3494158e4d506516890142007e6472c/mypy-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f2622af30bf01d8fc36466231bdd203d120d7a599a6d88fb22bdcb9dbff84090", size = 10083082, upload-time = "2025-05-29T13:35:33.378Z" },
- { url = "https://files.pythonhosted.org/packages/b3/7f/7242062ec6288c33d8ad89574df87c3903d394870e5e6ba1699317a65075/mypy-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d045d33c284e10a038f5e29faca055b90eee87da3fc63b8889085744ebabb5a1", size = 11828306, upload-time = "2025-05-29T13:21:02.164Z" },
- { url = "https://files.pythonhosted.org/packages/6f/5f/b392f7b4f659f5b619ce5994c5c43caab3d80df2296ae54fa888b3d17f5a/mypy-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4968f14f44c62e2ec4a038c8797a87315be8df7740dc3ee8d3bfe1c6bf5dba8", size = 12702764, upload-time = "2025-05-29T13:20:42.826Z" },
- { url = "https://files.pythonhosted.org/packages/9b/c0/7646ef3a00fa39ac9bc0938626d9ff29d19d733011be929cfea59d82d136/mypy-1.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eb14a4a871bb8efb1e4a50360d4e3c8d6c601e7a31028a2c79f9bb659b63d730", size = 12896233, upload-time = "2025-05-29T13:18:37.446Z" },
- { url = "https://files.pythonhosted.org/packages/6d/38/52f4b808b3fef7f0ef840ee8ff6ce5b5d77381e65425758d515cdd4f5bb5/mypy-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd4e1ebe126152a7bbaa4daedd781c90c8f9643c79b9748caa270ad542f12bec", size = 9565547, upload-time = "2025-05-29T13:20:02.836Z" },
- { url = "https://files.pythonhosted.org/packages/99/a3/6ed10530dec8e0fdc890d81361260c9ef1f5e5c217ad8c9b21ecb2b8366b/mypy-1.16.0-py3-none-any.whl", hash = "sha256:29e1499864a3888bca5c1542f2d7232c6e586295183320caa95758fc84034031", size = 2265773, upload-time = "2025-05-29T13:35:18.762Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/61/ec1245aa1c325cb7a6c0f8570a2eee3bfc40fa90d19b1267f8e50b5c8645/mypy-1.16.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:472e4e4c100062488ec643f6162dd0d5208e33e2f34544e1fc931372e806c0cc", size = 10890557, upload-time = "2025-06-16T16:37:21.421Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/bb/6eccc0ba0aa0c7a87df24e73f0ad34170514abd8162eb0c75fd7128171fb/mypy-1.16.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea16e2a7d2714277e349e24d19a782a663a34ed60864006e8585db08f8ad1782", size = 10012921, upload-time = "2025-06-16T16:51:28.659Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/80/b337a12e2006715f99f529e732c5f6a8c143bb58c92bb142d5ab380963a5/mypy-1.16.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08e850ea22adc4d8a4014651575567b0318ede51e8e9fe7a68f25391af699507", size = 11802887, upload-time = "2025-06-16T16:50:53.627Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/59/f7af072d09793d581a745a25737c7c0a945760036b16aeb620f658a017af/mypy-1.16.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22d76a63a42619bfb90122889b903519149879ddbf2ba4251834727944c8baca", size = 12531658, upload-time = "2025-06-16T16:33:55.002Z" },
+ { url = "https://files.pythonhosted.org/packages/82/c4/607672f2d6c0254b94a646cfc45ad589dd71b04aa1f3d642b840f7cce06c/mypy-1.16.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2c7ce0662b6b9dc8f4ed86eb7a5d505ee3298c04b40ec13b30e572c0e5ae17c4", size = 12732486, upload-time = "2025-06-16T16:37:03.301Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/5e/136555ec1d80df877a707cebf9081bd3a9f397dedc1ab9750518d87489ec/mypy-1.16.1-cp311-cp311-win_amd64.whl", hash = "sha256:211287e98e05352a2e1d4e8759c5490925a7c784ddc84207f4714822f8cf99b6", size = 9479482, upload-time = "2025-06-16T16:47:37.48Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/d6/39482e5fcc724c15bf6280ff5806548c7185e0c090712a3736ed4d07e8b7/mypy-1.16.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:af4792433f09575d9eeca5c63d7d90ca4aeceda9d8355e136f80f8967639183d", size = 11066493, upload-time = "2025-06-16T16:47:01.683Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/e5/26c347890efc6b757f4d5bb83f4a0cf5958b8cf49c938ac99b8b72b420a6/mypy-1.16.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66df38405fd8466ce3517eda1f6640611a0b8e70895e2a9462d1d4323c5eb4b9", size = 10081687, upload-time = "2025-06-16T16:48:19.367Z" },
+ { url = "https://files.pythonhosted.org/packages/44/c7/b5cb264c97b86914487d6a24bd8688c0172e37ec0f43e93b9691cae9468b/mypy-1.16.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44e7acddb3c48bd2713994d098729494117803616e116032af192871aed80b79", size = 11839723, upload-time = "2025-06-16T16:49:20.912Z" },
+ { url = "https://files.pythonhosted.org/packages/15/f8/491997a9b8a554204f834ed4816bda813aefda31cf873bb099deee3c9a99/mypy-1.16.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ab5eca37b50188163fa7c1b73c685ac66c4e9bdee4a85c9adac0e91d8895e15", size = 12722980, upload-time = "2025-06-16T16:37:40.929Z" },
+ { url = "https://files.pythonhosted.org/packages/df/f0/2bd41e174b5fd93bc9de9a28e4fb673113633b8a7f3a607fa4a73595e468/mypy-1.16.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb6229b2c9086247e21a83c309754b9058b438704ad2f6807f0d8227f6ebdd", size = 12903328, upload-time = "2025-06-16T16:34:35.099Z" },
+ { url = "https://files.pythonhosted.org/packages/61/81/5572108a7bec2c46b8aff7e9b524f371fe6ab5efb534d38d6b37b5490da8/mypy-1.16.1-cp312-cp312-win_amd64.whl", hash = "sha256:1f0435cf920e287ff68af3d10a118a73f212deb2ce087619eb4e648116d1fe9b", size = 9562321, upload-time = "2025-06-16T16:48:58.823Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/d3/53e684e78e07c1a2bf7105715e5edd09ce951fc3f47cf9ed095ec1b7a037/mypy-1.16.1-py3-none-any.whl", hash = "sha256:5fc2ac4027d0ef28d6ba69a0343737a23c4d1b83672bf38d1fe237bdc0643b37", size = 2265923, upload-time = "2025-06-16T16:48:02.366Z" },
]
[[package]]
name = "mypy-boto3-bedrock-runtime"
-version = "1.38.4"
+version = "1.39.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.12'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/7d/55/56ce6f23d7fb98ce5b8a4261f089890bc94250666ea7089539dab55f6c25/mypy_boto3_bedrock_runtime-1.38.4.tar.gz", hash = "sha256:315a5f84c014c54e5406fdb80b030aba5cc79eb27982aff3d09ef331fb2cdd4d", size = 26169, upload-time = "2025-04-28T19:26:13.437Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c6/6d/65c684441a91cd16f00e442a7ebb34bba5ee335ba8bb9ec5ad8f08e71e27/mypy_boto3_bedrock_runtime-1.39.0.tar.gz", hash = "sha256:f3eb0972bd3801013470cffd9dd094ff93ddcd6fae7ca17ec5bad1e357ab8117", size = 26901, upload-time = "2025-06-30T19:34:15.089Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/43/eb/3015c6504540ca4888789ee14f47590c0340b748a33b059eeb6a48b406bb/mypy_boto3_bedrock_runtime-1.38.4-py3-none-any.whl", hash = "sha256:af14320532e9b798095129a3307f4b186ba80258917bb31410cda7f423592d72", size = 31858, upload-time = "2025-04-28T19:26:09.667Z" },
+ { url = "https://files.pythonhosted.org/packages/05/92/ed01279bf155a1afe78a57d8e34f22604be66f59cb2b7c2f26e73715ced5/mypy_boto3_bedrock_runtime-1.39.0-py3-none-any.whl", hash = "sha256:2925d76b72ec77a7dc2169a0483c36567078de74cf2fcfff084e87b0e2c5ca8b", size = 32623, upload-time = "2025-06-30T19:34:13.663Z" },
]
[[package]]
@@ -3291,27 +3307,25 @@ wheels = [
[[package]]
name = "numexpr"
-version = "2.10.2"
+version = "2.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/21/67/c7415cf04ebe418193cfd6595ae03e3a64d76dac7b9c010098b39cc7992e/numexpr-2.10.2.tar.gz", hash = "sha256:b0aff6b48ebc99d2f54f27b5f73a58cb92fde650aeff1b397c71c8788b4fff1a", size = 106787, upload-time = "2024-11-23T13:34:23.127Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d2/8f/2cc977e91adbfbcdb6b49fdb9147e1d1c7566eb2c0c1e737e9a47020b5ca/numexpr-2.11.0.tar.gz", hash = "sha256:75b2c01a4eda2e7c357bc67a3f5c3dd76506c15b5fd4dc42845ef2e182181bad", size = 108960, upload-time = "2025-06-09T11:05:56.79Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/de/b7/f25d6166f92ef23737c1c90416144492a664f0a56510d90f7c6577c2cd14/numexpr-2.10.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6b360eb8d392483410fe6a3d5a7144afa298c9a0aa3e9fe193e89590b47dd477", size = 145055, upload-time = "2024-11-23T13:33:36.154Z" },
- { url = "https://files.pythonhosted.org/packages/66/64/428361ea6415826332f38ef2dd5c3abf4e7e601f033bfc9be68b680cb765/numexpr-2.10.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9a42f5c24880350d88933c4efee91b857c378aaea7e8b86221fff569069841e", size = 134743, upload-time = "2024-11-23T13:33:37.273Z" },
- { url = "https://files.pythonhosted.org/packages/3f/fb/639ec91d2ea7b4a5d66e26e8ef8e06b020c8e9b9ebaf3bab7b0a9bee472e/numexpr-2.10.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83fcb11988b57cc25b028a36d285287d706d1f536ebf2662ea30bd990e0de8b9", size = 410397, upload-time = "2024-11-23T13:33:38.474Z" },
- { url = "https://files.pythonhosted.org/packages/89/5a/0f5c5b8a3a6d34eeecb30d0e2f722d50b9b38c0e175937e7c6268ffab997/numexpr-2.10.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4213a92efa9770bc28e3792134e27c7e5c7e97068bdfb8ba395baebbd12f991b", size = 398902, upload-time = "2024-11-23T13:33:39.802Z" },
- { url = "https://files.pythonhosted.org/packages/a2/d5/ec734e735eba5a753efed5be3707ee7447ebd371772f8081b65a4153fb97/numexpr-2.10.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebdbef5763ca057eea0c2b5698e4439d084a0505d9d6e94f4804f26e8890c45e", size = 1380354, upload-time = "2024-11-23T13:33:41.68Z" },
- { url = "https://files.pythonhosted.org/packages/30/51/406e572531d817480bd612ee08239a36ee82865fea02fce569f15631f4ee/numexpr-2.10.2-cp311-cp311-win32.whl", hash = "sha256:3bf01ec502d89944e49e9c1b5cc7c7085be8ca2eb9dd46a0eafd218afbdbd5f5", size = 151938, upload-time = "2024-11-23T13:33:43.998Z" },
- { url = "https://files.pythonhosted.org/packages/04/32/5882ed1dbd96234f327a73316a481add151ff827cfaf2ea24fb4d5ad04db/numexpr-2.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:e2d0ae24b0728e4bc3f1d3f33310340d67321d36d6043f7ce26897f4f1042db0", size = 144961, upload-time = "2024-11-23T13:33:45.329Z" },
- { url = "https://files.pythonhosted.org/packages/2b/96/d5053dea06d8298ae8052b4b049cbf8ef74998e28d57166cc27b8ae909e2/numexpr-2.10.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5323a46e75832334f1af86da1ef6ff0add00fbacdd266250be872b438bdf2be", size = 145029, upload-time = "2024-11-23T13:33:46.892Z" },
- { url = "https://files.pythonhosted.org/packages/3e/3c/fcd5a812ed5dda757b2d9ef2764a3e1cca6f6d1f02dbf113dc23a2c7702a/numexpr-2.10.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a42963bd4c62d8afa4f51e7974debfa39a048383f653544ab54f50a2f7ec6c42", size = 134851, upload-time = "2024-11-23T13:33:47.986Z" },
- { url = "https://files.pythonhosted.org/packages/0a/52/0ed3b306d8c9944129bce97fec73a2caff13adbd7e1df148d546d7eb2d4d/numexpr-2.10.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5191ba8f2975cb9703afc04ae845a929e193498c0e8bcd408ecb147b35978470", size = 411837, upload-time = "2024-11-23T13:33:49.223Z" },
- { url = "https://files.pythonhosted.org/packages/7d/9c/6b671dd3fb67d7e7da93cb76b7c5277743f310a216b7856bb18776bb3371/numexpr-2.10.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97298b14f0105a794bea06fd9fbc5c423bd3ff4d88cbc618860b83eb7a436ad6", size = 400577, upload-time = "2024-11-23T13:33:50.559Z" },
- { url = "https://files.pythonhosted.org/packages/ea/4d/a167d1a215fe10ce58c45109f2869fd13aa0eef66f7e8c69af68be45d436/numexpr-2.10.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9d7805ccb6be2d3b0f7f6fad3707a09ac537811e8e9964f4074d28cb35543db", size = 1381735, upload-time = "2024-11-23T13:33:51.918Z" },
- { url = "https://files.pythonhosted.org/packages/c1/d4/17e4434f989e4917d31cbd88a043e1c9c16958149cf43fa622987111392b/numexpr-2.10.2-cp312-cp312-win32.whl", hash = "sha256:cb845b2d4f9f8ef0eb1c9884f2b64780a85d3b5ae4eeb26ae2b0019f489cd35e", size = 152102, upload-time = "2024-11-23T13:33:53.93Z" },
- { url = "https://files.pythonhosted.org/packages/b8/25/9ae599994076ef2a42d35ff6b0430da002647f212567851336a6c7b132d6/numexpr-2.10.2-cp312-cp312-win_amd64.whl", hash = "sha256:57b59cbb5dcce4edf09cd6ce0b57ff60312479930099ca8d944c2fac896a1ead", size = 145061, upload-time = "2024-11-23T13:33:55.161Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/d1/1cf8137990b3f3d445556ed63b9bc347aec39bde8c41146b02d3b35c1adc/numexpr-2.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:450eba3c93c3e3e8070566ad8d70590949d6e574b1c960bf68edd789811e7da8", size = 147535, upload-time = "2025-06-09T11:05:08.929Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/5e/bac7649d043f47c7c14c797efe60dbd19476468a149399cd706fe2e47f8c/numexpr-2.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f0eb88dbac8a7e61ee433006d0ddfd6eb921f5c6c224d1b50855bc98fb304c44", size = 136710, upload-time = "2025-06-09T11:05:10.366Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/9f/c88fc34d82d23c66ea0b78b00a1fb3b64048e0f7ac7791b2cd0d2a4ce14d/numexpr-2.11.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a194e3684b3553ea199c3f4837f422a521c7e2f0cce13527adc3a6b4049f9e7c", size = 411169, upload-time = "2025-06-09T11:05:11.797Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/8d/4d78dad430b41d836146f9e6f545f5c4f7d1972a6aa427d8570ab232bf16/numexpr-2.11.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f677668ab2bb2452fee955af3702fbb3b71919e61e4520762b1e5f54af59c0d8", size = 401671, upload-time = "2025-06-09T11:05:13.127Z" },
+ { url = "https://files.pythonhosted.org/packages/83/1c/414670eb41a82b78bd09769a4f5fb49a934f9b3990957f02c833637a511e/numexpr-2.11.0-cp311-cp311-win32.whl", hash = "sha256:7d9e76a77c9644fbd60da3984e516ead5b84817748c2da92515cd36f1941a04d", size = 153159, upload-time = "2025-06-09T11:05:14.452Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/97/8d00ca9b36f3ac68a8fd85e930ab0c9448d8c9ca7ce195ee75c188dabd45/numexpr-2.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:7163b488bfdcd13c300a8407c309e4cee195ef95d07facf5ac2678d66c988805", size = 146224, upload-time = "2025-06-09T11:05:15.877Z" },
+ { url = "https://files.pythonhosted.org/packages/38/45/7a0e5a0b800d92e73825494ac695fa05a52c7fc7088d69a336880136b437/numexpr-2.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4229060be866813122385c608bbd3ea48fe0b33e91f2756810d28c1cdbfc98f1", size = 147494, upload-time = "2025-06-09T11:05:17.015Z" },
+ { url = "https://files.pythonhosted.org/packages/74/46/3a26b84e44f4739ec98de0ede4b95b4b8096f721e22d0e97517eeb02017e/numexpr-2.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:097aa8835d32d6ac52f2be543384019b4b134d1fb67998cbfc4271155edfe54a", size = 136832, upload-time = "2025-06-09T11:05:18.55Z" },
+ { url = "https://files.pythonhosted.org/packages/75/05/e3076ff25d4a108b47640c169c0a64811748c43b63d9cc052ea56de1631e/numexpr-2.11.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f082321c244ff5d0e252071fb2c4fe02063a45934144a1456a5370ca139bec2", size = 412618, upload-time = "2025-06-09T11:05:20.093Z" },
+ { url = "https://files.pythonhosted.org/packages/70/e8/15e0e077a004db0edd530da96c60c948689c888c464ee5d14b82405ebd86/numexpr-2.11.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7a19435ca3d7dd502b8d8dce643555eb1b6013989e3f7577857289f6db6be16", size = 403363, upload-time = "2025-06-09T11:05:21.217Z" },
+ { url = "https://files.pythonhosted.org/packages/10/14/f22afb3a7ae41d03ba87f62d00fbcfb76389f9cc91b7a82593c39c509318/numexpr-2.11.0-cp312-cp312-win32.whl", hash = "sha256:f326218262c8d8537887cc4bbd613c8409d62f2cac799835c0360e0d9cefaa5c", size = 153307, upload-time = "2025-06-09T11:05:22.855Z" },
+ { url = "https://files.pythonhosted.org/packages/18/70/abc585269424582b3cd6db261e33b2ec96b5d4971da3edb29fc9b62a8926/numexpr-2.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:0a184e5930c77ab91dd9beee4df403b825cd9dfc4e9ba4670d31c9fcb4e2c08e", size = 146337, upload-time = "2025-06-09T11:05:23.976Z" },
]
[[package]]
@@ -3340,11 +3354,11 @@ wheels = [
[[package]]
name = "oauthlib"
-version = "3.2.2"
+version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6d/fa/fbf4001037904031639e6bfbfc02badfc7e12f137a8afa254df6c4c8a670/oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918", size = 177352, upload-time = "2022-10-17T20:04:27.471Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/7e/80/cab10959dc1faead58dc8384a781dfbf93cb4d33d50988f7a69f1b7c9bbe/oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca", size = 151688, upload-time = "2022-10-17T20:04:24.037Z" },
+ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
]
[[package]]
@@ -3423,6 +3437,29 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/74/83/cc7c6de29b0a7585cd445258d174ca204d37729c3874ad08e515b0bf331c/opendal-0.45.20-cp311-abi3-win_amd64.whl", hash = "sha256:145efd56aa33b493d5b652c3e4f5ae5097ab69d38c132d80f108e9f5c1e4d863", size = 14929888, upload-time = "2025-05-26T07:01:46.929Z" },
]
+[[package]]
+name = "openinference-instrumentation"
+version = "0.1.34"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "openinference-semantic-conventions" },
+ { name = "opentelemetry-api" },
+ { name = "opentelemetry-sdk" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2e/18/d074b45b04ba69bd03260d2dc0a034e5d586d8854e957695f40569278136/openinference_instrumentation-0.1.34.tar.gz", hash = "sha256:fa0328e8b92fc3e22e150c46f108794946ce39fe13670aed15f23ba0105f72ab", size = 22373, upload-time = "2025-06-17T16:47:22.641Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/ad/1a0a5c0a755918269f71fbca225fd70759dd79dd5bffc4723e44f0d87240/openinference_instrumentation-0.1.34-py3-none-any.whl", hash = "sha256:0fff1cc6d9b86f3450fc1c88347c51c5467855992b75e7addb85bf09fd048d2d", size = 28137, upload-time = "2025-06-17T16:47:21.658Z" },
+]
+
+[[package]]
+name = "openinference-semantic-conventions"
+version = "0.1.21"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/75/0f/b794eb009846d4b10af50e205a323ca359f284563ef4d1778f35a80522ac/openinference_semantic_conventions-0.1.21.tar.gz", hash = "sha256:328405b9f79ff72a659c7712b8429c0d7ea68c6a4a1679e3eb44372aa228119b", size = 12534, upload-time = "2025-06-13T05:22:18.982Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6e/4d/092766f8e610f2c513e483c4adc892eea1634945022a73371fe01f621165/openinference_semantic_conventions-0.1.21-py3-none-any.whl", hash = "sha256:acde8282c20da1de900cdc0d6258a793ec3eb8031bfc496bd823dae17d32e326", size = 10167, upload-time = "2025-06-13T05:22:18.118Z" },
+]
+
[[package]]
name = "openpyxl"
version = "3.1.5"
@@ -3711,28 +3748,28 @@ wheels = [
[[package]]
name = "opik"
-version = "1.7.29"
+version = "1.7.43"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "boto3-stubs", extra = ["bedrock-runtime"] },
{ name = "click" },
{ name = "httpx" },
{ name = "jinja2" },
- { name = "levenshtein" },
{ name = "litellm" },
{ name = "openai" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pytest" },
+ { name = "rapidfuzz" },
{ name = "rich" },
{ name = "sentry-sdk" },
{ name = "tenacity" },
{ name = "tqdm" },
{ name = "uuid6" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/48/94/52faf9277891bfa77dc4bac80b5d88d78103d8fad7f6a6c9495ae083e4da/opik-1.7.29.tar.gz", hash = "sha256:5a13692b233d90663a32cbab452937bf8b6fdc2d516c671c102c3beb34301b64", size = 300856, upload-time = "2025-06-02T11:11:27.955Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/52/cea0317bc3207bc967b48932781995d9cdb2c490e7e05caa00ff660f7205/opik-1.7.43.tar.gz", hash = "sha256:0b02522b0b74d0a67b141939deda01f8bb69690eda6b04a7cecb1c7f0649ccd0", size = 326886, upload-time = "2025-07-07T10:30:07.715Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2a/43/462b700030c70f525eebf4bc63a226ae46109eca598cd59db081de9a8b21/opik-1.7.29-py3-none-any.whl", hash = "sha256:3eb893e7b612d0b799682060193e4f2eebf55b65811203c4fe46af04cf84fa8c", size = 566635, upload-time = "2025-06-02T11:11:26.225Z" },
+ { url = "https://files.pythonhosted.org/packages/76/ae/f3566bdc3c49a1a8f795b1b6e726ef211c87e31f92d870ca6d63999c9bbf/opik-1.7.43-py3-none-any.whl", hash = "sha256:a66395c8b5ea7c24846f72dafc70c74d5b8f24ffbc4c8a1b3a7f9456e550568d", size = 625356, upload-time = "2025-07-07T10:30:06.389Z" },
]
[[package]]
@@ -3948,39 +3985,39 @@ wheels = [
[[package]]
name = "pillow"
-version = "11.2.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/af/cb/bb5c01fcd2a69335b86c22142b2bccfc3464087efb7fd382eee5ffc7fdf7/pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6", size = 47026707, upload-time = "2025-04-12T17:50:03.289Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/68/08/3fbf4b98924c73037a8e8b4c2c774784805e0fb4ebca6c5bb60795c40125/pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70", size = 3198450, upload-time = "2025-04-12T17:47:37.135Z" },
- { url = "https://files.pythonhosted.org/packages/84/92/6505b1af3d2849d5e714fc75ba9e69b7255c05ee42383a35a4d58f576b16/pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf", size = 3030550, upload-time = "2025-04-12T17:47:39.345Z" },
- { url = "https://files.pythonhosted.org/packages/3c/8c/ac2f99d2a70ff966bc7eb13dacacfaab57c0549b2ffb351b6537c7840b12/pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7", size = 4415018, upload-time = "2025-04-12T17:47:41.128Z" },
- { url = "https://files.pythonhosted.org/packages/1f/e3/0a58b5d838687f40891fff9cbaf8669f90c96b64dc8f91f87894413856c6/pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8", size = 4498006, upload-time = "2025-04-12T17:47:42.912Z" },
- { url = "https://files.pythonhosted.org/packages/21/f5/6ba14718135f08fbfa33308efe027dd02b781d3f1d5c471444a395933aac/pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600", size = 4517773, upload-time = "2025-04-12T17:47:44.611Z" },
- { url = "https://files.pythonhosted.org/packages/20/f2/805ad600fc59ebe4f1ba6129cd3a75fb0da126975c8579b8f57abeb61e80/pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788", size = 4607069, upload-time = "2025-04-12T17:47:46.46Z" },
- { url = "https://files.pythonhosted.org/packages/71/6b/4ef8a288b4bb2e0180cba13ca0a519fa27aa982875882392b65131401099/pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e", size = 4583460, upload-time = "2025-04-12T17:47:49.255Z" },
- { url = "https://files.pythonhosted.org/packages/62/ae/f29c705a09cbc9e2a456590816e5c234382ae5d32584f451c3eb41a62062/pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e", size = 4661304, upload-time = "2025-04-12T17:47:51.067Z" },
- { url = "https://files.pythonhosted.org/packages/6e/1a/c8217b6f2f73794a5e219fbad087701f412337ae6dbb956db37d69a9bc43/pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6", size = 2331809, upload-time = "2025-04-12T17:47:54.425Z" },
- { url = "https://files.pythonhosted.org/packages/e2/72/25a8f40170dc262e86e90f37cb72cb3de5e307f75bf4b02535a61afcd519/pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193", size = 2676338, upload-time = "2025-04-12T17:47:56.535Z" },
- { url = "https://files.pythonhosted.org/packages/06/9e/76825e39efee61efea258b479391ca77d64dbd9e5804e4ad0fa453b4ba55/pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7", size = 2414918, upload-time = "2025-04-12T17:47:58.217Z" },
- { url = "https://files.pythonhosted.org/packages/c7/40/052610b15a1b8961f52537cc8326ca6a881408bc2bdad0d852edeb6ed33b/pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f", size = 3190185, upload-time = "2025-04-12T17:48:00.417Z" },
- { url = "https://files.pythonhosted.org/packages/e5/7e/b86dbd35a5f938632093dc40d1682874c33dcfe832558fc80ca56bfcb774/pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b", size = 3030306, upload-time = "2025-04-12T17:48:02.391Z" },
- { url = "https://files.pythonhosted.org/packages/a4/5c/467a161f9ed53e5eab51a42923c33051bf8d1a2af4626ac04f5166e58e0c/pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d", size = 4416121, upload-time = "2025-04-12T17:48:04.554Z" },
- { url = "https://files.pythonhosted.org/packages/62/73/972b7742e38ae0e2ac76ab137ca6005dcf877480da0d9d61d93b613065b4/pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4", size = 4501707, upload-time = "2025-04-12T17:48:06.831Z" },
- { url = "https://files.pythonhosted.org/packages/e4/3a/427e4cb0b9e177efbc1a84798ed20498c4f233abde003c06d2650a6d60cb/pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d", size = 4522921, upload-time = "2025-04-12T17:48:09.229Z" },
- { url = "https://files.pythonhosted.org/packages/fe/7c/d8b1330458e4d2f3f45d9508796d7caf0c0d3764c00c823d10f6f1a3b76d/pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4", size = 4612523, upload-time = "2025-04-12T17:48:11.631Z" },
- { url = "https://files.pythonhosted.org/packages/b3/2f/65738384e0b1acf451de5a573d8153fe84103772d139e1e0bdf1596be2ea/pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443", size = 4587836, upload-time = "2025-04-12T17:48:13.592Z" },
- { url = "https://files.pythonhosted.org/packages/6a/c5/e795c9f2ddf3debb2dedd0df889f2fe4b053308bb59a3cc02a0cd144d641/pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c", size = 4669390, upload-time = "2025-04-12T17:48:15.938Z" },
- { url = "https://files.pythonhosted.org/packages/96/ae/ca0099a3995976a9fce2f423166f7bff9b12244afdc7520f6ed38911539a/pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3", size = 2332309, upload-time = "2025-04-12T17:48:17.885Z" },
- { url = "https://files.pythonhosted.org/packages/7c/18/24bff2ad716257fc03da964c5e8f05d9790a779a8895d6566e493ccf0189/pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941", size = 2676768, upload-time = "2025-04-12T17:48:19.655Z" },
- { url = "https://files.pythonhosted.org/packages/da/bb/e8d656c9543276517ee40184aaa39dcb41e683bca121022f9323ae11b39d/pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb", size = 2415087, upload-time = "2025-04-12T17:48:21.991Z" },
- { url = "https://files.pythonhosted.org/packages/a4/ad/2613c04633c7257d9481ab21d6b5364b59fc5d75faafd7cb8693523945a3/pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed", size = 3181734, upload-time = "2025-04-12T17:49:46.789Z" },
- { url = "https://files.pythonhosted.org/packages/a4/fd/dcdda4471ed667de57bb5405bb42d751e6cfdd4011a12c248b455c778e03/pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c", size = 2999841, upload-time = "2025-04-12T17:49:48.812Z" },
- { url = "https://files.pythonhosted.org/packages/ac/89/8a2536e95e77432833f0db6fd72a8d310c8e4272a04461fb833eb021bf94/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd", size = 3437470, upload-time = "2025-04-12T17:49:50.831Z" },
- { url = "https://files.pythonhosted.org/packages/9d/8f/abd47b73c60712f88e9eda32baced7bfc3e9bd6a7619bb64b93acff28c3e/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076", size = 3460013, upload-time = "2025-04-12T17:49:53.278Z" },
- { url = "https://files.pythonhosted.org/packages/f6/20/5c0a0aa83b213b7a07ec01e71a3d6ea2cf4ad1d2c686cc0168173b6089e7/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b", size = 3527165, upload-time = "2025-04-12T17:49:55.164Z" },
- { url = "https://files.pythonhosted.org/packages/58/0e/2abab98a72202d91146abc839e10c14f7cf36166f12838ea0c4db3ca6ecb/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f", size = 3571586, upload-time = "2025-04-12T17:49:57.171Z" },
- { url = "https://files.pythonhosted.org/packages/21/2c/5e05f58658cf49b6667762cca03d6e7d85cededde2caf2ab37b81f80e574/pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044", size = 2674751, upload-time = "2025-04-12T17:49:59.628Z" },
+version = "11.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" },
+ { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" },
+ { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" },
+ { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" },
+ { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800, upload-time = "2025-07-01T09:14:17.648Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296, upload-time = "2025-07-01T09:14:19.828Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726, upload-time = "2025-07-03T13:10:04.448Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652, upload-time = "2025-07-03T13:10:10.391Z" },
+ { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787, upload-time = "2025-07-01T09:14:21.63Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236, upload-time = "2025-07-01T09:14:23.321Z" },
+ { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950, upload-time = "2025-07-01T09:14:25.237Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358, upload-time = "2025-07-01T09:14:27.053Z" },
+ { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079, upload-time = "2025-07-01T09:14:30.104Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324, upload-time = "2025-07-01T09:14:31.899Z" },
+ { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067, upload-time = "2025-07-01T09:14:33.709Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" },
+ { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" },
+ { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" },
+ { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" },
]
[[package]]
@@ -4050,7 +4087,7 @@ wheels = [
[[package]]
name = "posthog"
-version = "4.2.0"
+version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backoff" },
@@ -4058,10 +4095,11 @@ dependencies = [
{ name = "python-dateutil" },
{ name = "requests" },
{ name = "six" },
+ { name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/ce/5b/2e9890700b7b55a370edbfbe5948eae780d48af9b46ad06ea2e7970576f4/posthog-4.2.0.tar.gz", hash = "sha256:c4abc95de03294be005b3b7e8735e9d7abab88583da26262112bacce64b0c3b5", size = 80727, upload-time = "2025-05-23T23:23:55.943Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/39/a2/1b68562124b0d0e615fa8431cc88c84b3db6526275c2c19a419579a49277/posthog-6.0.3.tar.gz", hash = "sha256:9005abb341af8fedd9d82ca0359b3d35a9537555cdc9881bfb469f7c0b4b0ec5", size = 91861, upload-time = "2025-07-07T07:14:08.21Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/51/16/7b6c5844acee2d343d463ee0e3143cd8c7c48a6c0d079a2f7daf0c80b95c/posthog-4.2.0-py2.py3-none-any.whl", hash = "sha256:60c7066caac43e43e326e9196d8c1aadeafc8b0be9e5c108446e352711fa456b", size = 96692, upload-time = "2025-05-23T23:23:54.384Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/f1/a8d86245d41c8686f7d828a4959bdf483e8ac331b249b48b8c61fc884a1c/posthog-6.0.3-py3-none-any.whl", hash = "sha256:4b808c907f3623216a9362d91fdafce8e2f57a8387fb3020475c62ec809be56d", size = 108978, upload-time = "2025-07-07T07:14:06.451Z" },
]
[[package]]
@@ -4078,43 +4116,43 @@ wheels = [
[[package]]
name = "propcache"
-version = "0.3.1"
+version = "0.3.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/07/c8/fdc6686a986feae3541ea23dcaa661bd93972d3940460646c6bb96e21c40/propcache-0.3.1.tar.gz", hash = "sha256:40d980c33765359098837527e18eddefc9a24cea5b45e078a7f3bb5b032c6ecf", size = 43651, upload-time = "2025-03-26T03:06:12.05Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/90/0f/5a5319ee83bd651f75311fcb0c492c21322a7fc8f788e4eef23f44243427/propcache-0.3.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7f30241577d2fef2602113b70ef7231bf4c69a97e04693bde08ddab913ba0ce5", size = 80243, upload-time = "2025-03-26T03:04:01.912Z" },
- { url = "https://files.pythonhosted.org/packages/ce/84/3db5537e0879942783e2256616ff15d870a11d7ac26541336fe1b673c818/propcache-0.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:43593c6772aa12abc3af7784bff4a41ffa921608dd38b77cf1dfd7f5c4e71371", size = 46503, upload-time = "2025-03-26T03:04:03.704Z" },
- { url = "https://files.pythonhosted.org/packages/e2/c8/b649ed972433c3f0d827d7f0cf9ea47162f4ef8f4fe98c5f3641a0bc63ff/propcache-0.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a75801768bbe65499495660b777e018cbe90c7980f07f8aa57d6be79ea6f71da", size = 45934, upload-time = "2025-03-26T03:04:05.257Z" },
- { url = "https://files.pythonhosted.org/packages/59/f9/4c0a5cf6974c2c43b1a6810c40d889769cc8f84cea676cbe1e62766a45f8/propcache-0.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6f1324db48f001c2ca26a25fa25af60711e09b9aaf4b28488602776f4f9a744", size = 233633, upload-time = "2025-03-26T03:04:07.044Z" },
- { url = "https://files.pythonhosted.org/packages/e7/64/66f2f4d1b4f0007c6e9078bd95b609b633d3957fe6dd23eac33ebde4b584/propcache-0.3.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cdb0f3e1eb6dfc9965d19734d8f9c481b294b5274337a8cb5cb01b462dcb7e0", size = 241124, upload-time = "2025-03-26T03:04:08.676Z" },
- { url = "https://files.pythonhosted.org/packages/aa/bf/7b8c9fd097d511638fa9b6af3d986adbdf567598a567b46338c925144c1b/propcache-0.3.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1eb34d90aac9bfbced9a58b266f8946cb5935869ff01b164573a7634d39fbcb5", size = 240283, upload-time = "2025-03-26T03:04:10.172Z" },
- { url = "https://files.pythonhosted.org/packages/fa/c9/e85aeeeaae83358e2a1ef32d6ff50a483a5d5248bc38510d030a6f4e2816/propcache-0.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f35c7070eeec2cdaac6fd3fe245226ed2a6292d3ee8c938e5bb645b434c5f256", size = 232498, upload-time = "2025-03-26T03:04:11.616Z" },
- { url = "https://files.pythonhosted.org/packages/8e/66/acb88e1f30ef5536d785c283af2e62931cb934a56a3ecf39105887aa8905/propcache-0.3.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b23c11c2c9e6d4e7300c92e022046ad09b91fd00e36e83c44483df4afa990073", size = 221486, upload-time = "2025-03-26T03:04:13.102Z" },
- { url = "https://files.pythonhosted.org/packages/f5/f9/233ddb05ffdcaee4448508ee1d70aa7deff21bb41469ccdfcc339f871427/propcache-0.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3e19ea4ea0bf46179f8a3652ac1426e6dcbaf577ce4b4f65be581e237340420d", size = 222675, upload-time = "2025-03-26T03:04:14.658Z" },
- { url = "https://files.pythonhosted.org/packages/98/b8/eb977e28138f9e22a5a789daf608d36e05ed93093ef12a12441030da800a/propcache-0.3.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:bd39c92e4c8f6cbf5f08257d6360123af72af9f4da75a690bef50da77362d25f", size = 215727, upload-time = "2025-03-26T03:04:16.207Z" },
- { url = "https://files.pythonhosted.org/packages/89/2d/5f52d9c579f67b8ee1edd9ec073c91b23cc5b7ff7951a1e449e04ed8fdf3/propcache-0.3.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b0313e8b923b3814d1c4a524c93dfecea5f39fa95601f6a9b1ac96cd66f89ea0", size = 217878, upload-time = "2025-03-26T03:04:18.11Z" },
- { url = "https://files.pythonhosted.org/packages/7a/fd/5283e5ed8a82b00c7a989b99bb6ea173db1ad750bf0bf8dff08d3f4a4e28/propcache-0.3.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e861ad82892408487be144906a368ddbe2dc6297074ade2d892341b35c59844a", size = 230558, upload-time = "2025-03-26T03:04:19.562Z" },
- { url = "https://files.pythonhosted.org/packages/90/38/ab17d75938ef7ac87332c588857422ae126b1c76253f0f5b1242032923ca/propcache-0.3.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:61014615c1274df8da5991a1e5da85a3ccb00c2d4701ac6f3383afd3ca47ab0a", size = 233754, upload-time = "2025-03-26T03:04:21.065Z" },
- { url = "https://files.pythonhosted.org/packages/06/5d/3b921b9c60659ae464137508d3b4c2b3f52f592ceb1964aa2533b32fcf0b/propcache-0.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:71ebe3fe42656a2328ab08933d420df5f3ab121772eef78f2dc63624157f0ed9", size = 226088, upload-time = "2025-03-26T03:04:22.718Z" },
- { url = "https://files.pythonhosted.org/packages/54/6e/30a11f4417d9266b5a464ac5a8c5164ddc9dd153dfa77bf57918165eb4ae/propcache-0.3.1-cp311-cp311-win32.whl", hash = "sha256:58aa11f4ca8b60113d4b8e32d37e7e78bd8af4d1a5b5cb4979ed856a45e62005", size = 40859, upload-time = "2025-03-26T03:04:24.039Z" },
- { url = "https://files.pythonhosted.org/packages/1d/3a/8a68dd867da9ca2ee9dfd361093e9cb08cb0f37e5ddb2276f1b5177d7731/propcache-0.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:9532ea0b26a401264b1365146c440a6d78269ed41f83f23818d4b79497aeabe7", size = 45153, upload-time = "2025-03-26T03:04:25.211Z" },
- { url = "https://files.pythonhosted.org/packages/41/aa/ca78d9be314d1e15ff517b992bebbed3bdfef5b8919e85bf4940e57b6137/propcache-0.3.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f78eb8422acc93d7b69964012ad7048764bb45a54ba7a39bb9e146c72ea29723", size = 80430, upload-time = "2025-03-26T03:04:26.436Z" },
- { url = "https://files.pythonhosted.org/packages/1a/d8/f0c17c44d1cda0ad1979af2e593ea290defdde9eaeb89b08abbe02a5e8e1/propcache-0.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:89498dd49c2f9a026ee057965cdf8192e5ae070ce7d7a7bd4b66a8e257d0c976", size = 46637, upload-time = "2025-03-26T03:04:27.932Z" },
- { url = "https://files.pythonhosted.org/packages/ae/bd/c1e37265910752e6e5e8a4c1605d0129e5b7933c3dc3cf1b9b48ed83b364/propcache-0.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:09400e98545c998d57d10035ff623266927cb784d13dd2b31fd33b8a5316b85b", size = 46123, upload-time = "2025-03-26T03:04:30.659Z" },
- { url = "https://files.pythonhosted.org/packages/d4/b0/911eda0865f90c0c7e9f0415d40a5bf681204da5fd7ca089361a64c16b28/propcache-0.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa8efd8c5adc5a2c9d3b952815ff8f7710cefdcaf5f2c36d26aff51aeca2f12f", size = 243031, upload-time = "2025-03-26T03:04:31.977Z" },
- { url = "https://files.pythonhosted.org/packages/0a/06/0da53397c76a74271621807265b6eb61fb011451b1ddebf43213df763669/propcache-0.3.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2fe5c910f6007e716a06d269608d307b4f36e7babee5f36533722660e8c4a70", size = 249100, upload-time = "2025-03-26T03:04:33.45Z" },
- { url = "https://files.pythonhosted.org/packages/f1/eb/13090e05bf6b963fc1653cdc922133ced467cb4b8dab53158db5a37aa21e/propcache-0.3.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a0ab8cf8cdd2194f8ff979a43ab43049b1df0b37aa64ab7eca04ac14429baeb7", size = 250170, upload-time = "2025-03-26T03:04:35.542Z" },
- { url = "https://files.pythonhosted.org/packages/3b/4c/f72c9e1022b3b043ec7dc475a0f405d4c3e10b9b1d378a7330fecf0652da/propcache-0.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:563f9d8c03ad645597b8d010ef4e9eab359faeb11a0a2ac9f7b4bc8c28ebef25", size = 245000, upload-time = "2025-03-26T03:04:37.501Z" },
- { url = "https://files.pythonhosted.org/packages/e8/fd/970ca0e22acc829f1adf5de3724085e778c1ad8a75bec010049502cb3a86/propcache-0.3.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb6e0faf8cb6b4beea5d6ed7b5a578254c6d7df54c36ccd3d8b3eb00d6770277", size = 230262, upload-time = "2025-03-26T03:04:39.532Z" },
- { url = "https://files.pythonhosted.org/packages/c4/42/817289120c6b9194a44f6c3e6b2c3277c5b70bbad39e7df648f177cc3634/propcache-0.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1c5c7ab7f2bb3f573d1cb921993006ba2d39e8621019dffb1c5bc94cdbae81e8", size = 236772, upload-time = "2025-03-26T03:04:41.109Z" },
- { url = "https://files.pythonhosted.org/packages/7c/9c/3b3942b302badd589ad6b672da3ca7b660a6c2f505cafd058133ddc73918/propcache-0.3.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:050b571b2e96ec942898f8eb46ea4bfbb19bd5502424747e83badc2d4a99a44e", size = 231133, upload-time = "2025-03-26T03:04:42.544Z" },
- { url = "https://files.pythonhosted.org/packages/98/a1/75f6355f9ad039108ff000dfc2e19962c8dea0430da9a1428e7975cf24b2/propcache-0.3.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e1c4d24b804b3a87e9350f79e2371a705a188d292fd310e663483af6ee6718ee", size = 230741, upload-time = "2025-03-26T03:04:44.06Z" },
- { url = "https://files.pythonhosted.org/packages/67/0c/3e82563af77d1f8731132166da69fdfd95e71210e31f18edce08a1eb11ea/propcache-0.3.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e4fe2a6d5ce975c117a6bb1e8ccda772d1e7029c1cca1acd209f91d30fa72815", size = 244047, upload-time = "2025-03-26T03:04:45.983Z" },
- { url = "https://files.pythonhosted.org/packages/f7/50/9fb7cca01532a08c4d5186d7bb2da6c4c587825c0ae134b89b47c7d62628/propcache-0.3.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:feccd282de1f6322f56f6845bf1207a537227812f0a9bf5571df52bb418d79d5", size = 246467, upload-time = "2025-03-26T03:04:47.699Z" },
- { url = "https://files.pythonhosted.org/packages/a9/02/ccbcf3e1c604c16cc525309161d57412c23cf2351523aedbb280eb7c9094/propcache-0.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ec314cde7314d2dd0510c6787326bbffcbdc317ecee6b7401ce218b3099075a7", size = 241022, upload-time = "2025-03-26T03:04:49.195Z" },
- { url = "https://files.pythonhosted.org/packages/db/19/e777227545e09ca1e77a6e21274ae9ec45de0f589f0ce3eca2a41f366220/propcache-0.3.1-cp312-cp312-win32.whl", hash = "sha256:7d2d5a0028d920738372630870e7d9644ce437142197f8c827194fca404bf03b", size = 40647, upload-time = "2025-03-26T03:04:50.595Z" },
- { url = "https://files.pythonhosted.org/packages/24/bb/3b1b01da5dd04c77a204c84e538ff11f624e31431cfde7201d9110b092b1/propcache-0.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:88c423efef9d7a59dae0614eaed718449c09a5ac79a5f224a8b9664d603f04a3", size = 44784, upload-time = "2025-03-26T03:04:51.791Z" },
- { url = "https://files.pythonhosted.org/packages/b8/d3/c3cb8f1d6ae3b37f83e1de806713a9b3642c5895f0215a62e1a4bd6e5e34/propcache-0.3.1-py3-none-any.whl", hash = "sha256:9a8ecf38de50a7f518c21568c80f985e776397b902f1ce0b01f799aba1608b40", size = 12376, upload-time = "2025-03-26T03:06:10.5Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/80/8d/e8b436717ab9c2cfc23b116d2c297305aa4cd8339172a456d61ebf5669b8/propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be", size = 74207, upload-time = "2025-06-09T22:54:05.399Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/29/1e34000e9766d112171764b9fa3226fa0153ab565d0c242c70e9945318a7/propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f", size = 43648, upload-time = "2025-06-09T22:54:08.023Z" },
+ { url = "https://files.pythonhosted.org/packages/46/92/1ad5af0df781e76988897da39b5f086c2bf0f028b7f9bd1f409bb05b6874/propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9", size = 43496, upload-time = "2025-06-09T22:54:09.228Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/ce/e96392460f9fb68461fabab3e095cb00c8ddf901205be4eae5ce246e5b7e/propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf", size = 217288, upload-time = "2025-06-09T22:54:10.466Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/2a/866726ea345299f7ceefc861a5e782b045545ae6940851930a6adaf1fca6/propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9", size = 227456, upload-time = "2025-06-09T22:54:11.828Z" },
+ { url = "https://files.pythonhosted.org/packages/de/03/07d992ccb6d930398689187e1b3c718339a1c06b8b145a8d9650e4726166/propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66", size = 225429, upload-time = "2025-06-09T22:54:13.823Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/e6/116ba39448753b1330f48ab8ba927dcd6cf0baea8a0ccbc512dfb49ba670/propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df", size = 213472, upload-time = "2025-06-09T22:54:15.232Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/85/f01f5d97e54e428885a5497ccf7f54404cbb4f906688a1690cd51bf597dc/propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2", size = 204480, upload-time = "2025-06-09T22:54:17.104Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/79/7bf5ab9033b8b8194cc3f7cf1aaa0e9c3256320726f64a3e1f113a812dce/propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7", size = 214530, upload-time = "2025-06-09T22:54:18.512Z" },
+ { url = "https://files.pythonhosted.org/packages/31/0b/bd3e0c00509b609317df4a18e6b05a450ef2d9a963e1d8bc9c9415d86f30/propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95", size = 205230, upload-time = "2025-06-09T22:54:19.947Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/23/fae0ff9b54b0de4e819bbe559508da132d5683c32d84d0dc2ccce3563ed4/propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e", size = 206754, upload-time = "2025-06-09T22:54:21.716Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/7f/ad6a3c22630aaa5f618b4dc3c3598974a72abb4c18e45a50b3cdd091eb2f/propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e", size = 218430, upload-time = "2025-06-09T22:54:23.17Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/2c/ba4f1c0e8a4b4c75910742f0d333759d441f65a1c7f34683b4a74c0ee015/propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf", size = 223884, upload-time = "2025-06-09T22:54:25.539Z" },
+ { url = "https://files.pythonhosted.org/packages/88/e4/ebe30fc399e98572019eee82ad0caf512401661985cbd3da5e3140ffa1b0/propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e", size = 211480, upload-time = "2025-06-09T22:54:26.892Z" },
+ { url = "https://files.pythonhosted.org/packages/96/0a/7d5260b914e01d1d0906f7f38af101f8d8ed0dc47426219eeaf05e8ea7c2/propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897", size = 37757, upload-time = "2025-06-09T22:54:28.241Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/2d/89fe4489a884bc0da0c3278c552bd4ffe06a1ace559db5ef02ef24ab446b/propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39", size = 41500, upload-time = "2025-06-09T22:54:29.4Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/42/9ca01b0a6f48e81615dca4765a8f1dd2c057e0540f6116a27dc5ee01dfb6/propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10", size = 73674, upload-time = "2025-06-09T22:54:30.551Z" },
+ { url = "https://files.pythonhosted.org/packages/af/6e/21293133beb550f9c901bbece755d582bfaf2176bee4774000bd4dd41884/propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154", size = 43570, upload-time = "2025-06-09T22:54:32.296Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/c8/0393a0a3a2b8760eb3bde3c147f62b20044f0ddac81e9d6ed7318ec0d852/propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615", size = 43094, upload-time = "2025-06-09T22:54:33.929Z" },
+ { url = "https://files.pythonhosted.org/packages/37/2c/489afe311a690399d04a3e03b069225670c1d489eb7b044a566511c1c498/propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db", size = 226958, upload-time = "2025-06-09T22:54:35.186Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/ca/63b520d2f3d418c968bf596839ae26cf7f87bead026b6192d4da6a08c467/propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1", size = 234894, upload-time = "2025-06-09T22:54:36.708Z" },
+ { url = "https://files.pythonhosted.org/packages/11/60/1d0ed6fff455a028d678df30cc28dcee7af77fa2b0e6962ce1df95c9a2a9/propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c", size = 233672, upload-time = "2025-06-09T22:54:38.062Z" },
+ { url = "https://files.pythonhosted.org/packages/37/7c/54fd5301ef38505ab235d98827207176a5c9b2aa61939b10a460ca53e123/propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67", size = 224395, upload-time = "2025-06-09T22:54:39.634Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/1a/89a40e0846f5de05fdc6779883bf46ba980e6df4d2ff8fb02643de126592/propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b", size = 212510, upload-time = "2025-06-09T22:54:41.565Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/33/ca98368586c9566a6b8d5ef66e30484f8da84c0aac3f2d9aec6d31a11bd5/propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8", size = 222949, upload-time = "2025-06-09T22:54:43.038Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/11/ace870d0aafe443b33b2f0b7efdb872b7c3abd505bfb4890716ad7865e9d/propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251", size = 217258, upload-time = "2025-06-09T22:54:44.376Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/d2/86fd6f7adffcfc74b42c10a6b7db721d1d9ca1055c45d39a1a8f2a740a21/propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474", size = 213036, upload-time = "2025-06-09T22:54:46.243Z" },
+ { url = "https://files.pythonhosted.org/packages/07/94/2d7d1e328f45ff34a0a284cf5a2847013701e24c2a53117e7c280a4316b3/propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535", size = 227684, upload-time = "2025-06-09T22:54:47.63Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/05/37ae63a0087677e90b1d14710e532ff104d44bc1efa3b3970fff99b891dc/propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06", size = 234562, upload-time = "2025-06-09T22:54:48.982Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/7c/3f539fcae630408d0bd8bf3208b9a647ccad10976eda62402a80adf8fc34/propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1", size = 222142, upload-time = "2025-06-09T22:54:50.424Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/d2/34b9eac8c35f79f8a962546b3e97e9d4b990c420ee66ac8255d5d9611648/propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1", size = 37711, upload-time = "2025-06-09T22:54:52.072Z" },
+ { url = "https://files.pythonhosted.org/packages/19/61/d582be5d226cf79071681d1b46b848d6cb03d7b70af7063e33a2787eaa03/propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c", size = 41479, upload-time = "2025-06-09T22:54:53.234Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/35/cc0aaecf278bb4575b8555f2b137de5ab821595ddae9da9d3cd1da4072c7/propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f", size = 12663, upload-time = "2025-06-09T22:56:04.484Z" },
]
[[package]]
@@ -4264,7 +4302,7 @@ wheels = [
[[package]]
name = "pydantic"
-version = "2.11.5"
+version = "2.11.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
@@ -4272,9 +4310,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/f0/86/8ce9040065e8f924d642c58e4a344e33163a07f6b57f836d0d734e0ad3fb/pydantic-2.11.5.tar.gz", hash = "sha256:7f853db3d0ce78ce8bbb148c401c2cdd6431b3473c0cdff2755c7690952a7b7a", size = 787102, upload-time = "2025-05-22T21:18:08.761Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b5/69/831ed22b38ff9b4b64b66569f0e5b7b97cf3638346eb95a2147fdb49ad5f/pydantic-2.11.5-py3-none-any.whl", hash = "sha256:f9c26ba06f9747749ca1e5c94d6a85cb84254577553c8785576fd38fa64dc0f7", size = 444229, upload-time = "2025-05-22T21:18:06.329Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
]
[[package]]
@@ -4354,11 +4392,11 @@ wheels = [
[[package]]
name = "pygments"
-version = "2.19.1"
+version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581, upload-time = "2025-01-06T17:26:30.443Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
@@ -4377,7 +4415,7 @@ crypto = [
[[package]]
name = "pymilvus"
-version = "2.5.10"
+version = "2.5.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "grpcio" },
@@ -4388,9 +4426,9 @@ dependencies = [
{ name = "setuptools" },
{ name = "ujson" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/da/e2/88f126a08d8eefba7341e3eb323406a227146094aab7137a2b91d882e98d/pymilvus-2.5.10.tar.gz", hash = "sha256:cc44ad776aeab781ee4c4a4d334b73e746066ab2fb6722c5311f02efa6fc54a2", size = 1260364, upload-time = "2025-05-23T06:08:06.992Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fa/53/4af820a37163225a76656222ee43a0eb8f1bd2ceec063315680a585435da/pymilvus-2.5.12.tar.gz", hash = "sha256:79ec7dc0616c2484f77abe98bca8deafb613645b5703c492b51961afd4f985d8", size = 1265893, upload-time = "2025-07-02T15:34:00.385Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b0/4b/847704930ad8ddd0d0975e9a3a5e3fe704f642debe97454135c2b9ee7081/pymilvus-2.5.10-py3-none-any.whl", hash = "sha256:7da540f93068871cda3941602c55227aeaafb66f2f0d9c05e8f9db783716b100", size = 227635, upload-time = "2025-05-23T06:08:05.397Z" },
+ { url = "https://files.pythonhosted.org/packages/68/4f/80a4940f2772d10272c3292444af767a5aa1a5bbb631874568713ca01d54/pymilvus-2.5.12-py3-none-any.whl", hash = "sha256:ef77a4a0076469a30b05f0bb23b5a058acfbdca83d82af9574ca651764017f44", size = 231425, upload-time = "2025-07-02T15:33:58.938Z" },
]
[[package]]
@@ -4418,19 +4456,17 @@ wheels = [
[[package]]
name = "pyobvector"
-version = "0.1.20"
+version = "0.1.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiomysql" },
{ name = "numpy" },
- { name = "pydantic" },
{ name = "pymysql" },
{ name = "sqlalchemy" },
- { name = "sqlglot" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/13/f7/fcc99e41bac6aee0822667809957800b2f512f2d96d1fa52c283cb58db16/pyobvector-0.1.20.tar.gz", hash = "sha256:26e8155d5b933333a2ab21b08e51df84e374d188f5fb26520347450e72f34c6e", size = 35256, upload-time = "2025-03-17T12:32:16.66Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/dc/59/7d762061808948dd6aad165a000b34e22163dc83fb5014184eeacc0fabe5/pyobvector-0.1.14.tar.gz", hash = "sha256:4f85cdd63064d040e94c0a96099a0cd5cda18ce625865382e89429f28422fc02", size = 26780, upload-time = "2024-11-20T11:46:18.017Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e6/7d/00cd33c18814bec0a4f6f4a89becdbe2c6c28d78f87d765d0259a22b117e/pyobvector-0.1.20-py3-none-any.whl", hash = "sha256:1a991f8b9f53e1a749fc396ccb90c7591e64b2c5679930023f00789c62e7af72", size = 46264, upload-time = "2025-03-17T12:32:15.356Z" },
+ { url = "https://files.pythonhosted.org/packages/88/68/ecb21b74c974e7be7f9034e205d08db62d614ff5c221581ae96d37ef853e/pyobvector-0.1.14-py3-none-any.whl", hash = "sha256:828e0bec49a177355b70c7a1270af3b0bf5239200ee0d096e4165b267eeff97c", size = 35526, upload-time = "2024-11-20T11:46:16.809Z" },
]
[[package]]
@@ -4453,11 +4489,11 @@ wheels = [
[[package]]
name = "pypdf"
-version = "5.6.0"
+version = "5.7.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/40/46/67de1d7a65412aa1c896e6b280829b70b57d203fadae6859b690006b8e0a/pypdf-5.6.0.tar.gz", hash = "sha256:a4b6538b77fc796622000db7127e4e58039ec5e6afd292f8e9bf42e2e985a749", size = 5023749, upload-time = "2025-06-01T12:19:40.101Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/7b/42/fbc37af367b20fa6c53da81b1780025f6046a0fac8cbf0663a17e743b033/pypdf-5.7.0.tar.gz", hash = "sha256:68c92f2e1aae878bab1150e74447f31ab3848b1c0a6f8becae9f0b1904460b6f", size = 5026120, upload-time = "2025-06-29T08:49:48.305Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/71/8b/dc3a72d98c22be7a4cbd664ad14c5a3e6295c2dbdf572865ed61e24b5e38/pypdf-5.6.0-py3-none-any.whl", hash = "sha256:ca6bf446bfb0a2d8d71d6d6bb860798d864c36a29b3d9ae8d7fc7958c59f88e7", size = 304208, upload-time = "2025-06-01T12:19:38.003Z" },
+ { url = "https://files.pythonhosted.org/packages/73/9f/78d096ef795a813fa0e1cb9b33fa574b205f2b563d9c1e9366c854cf0364/pypdf-5.7.0-py3-none-any.whl", hash = "sha256:203379453439f5b68b7a1cd43cdf4c5f7a02b84810cefa7f93a47b350aaaba48", size = 305524, upload-time = "2025-06-29T08:49:46.16Z" },
]
[[package]]
@@ -4571,39 +4607,39 @@ wheels = [
[[package]]
name = "python-calamine"
-version = "0.3.2"
+version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/6b/21/387b92059909e741af7837194d84250335d2a057f614752b6364aaaa2f56/python_calamine-0.3.2.tar.gz", hash = "sha256:5cf12f2086373047cdea681711857b672cba77a34a66dd3755d60686fc974e06", size = 117336, upload-time = "2025-04-02T10:06:23.14Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ef/b7/d59863ebe319150739d0c352c6dea2710a2f90254ed32304d52e8349edce/python_calamine-0.3.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5251746816069c38eafdd1e4eb7b83870e1fe0ff6191ce9a809b187ffba8ce93", size = 830854, upload-time = "2025-04-02T10:04:14.673Z" },
- { url = "https://files.pythonhosted.org/packages/d3/01/b48c6f2c2e530a1a031199c5c5bf35f7c2cf7f16f3989263e616e3bc86ce/python_calamine-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9775dbc93bc635d48f45433f8869a546cca28c2a86512581a05333f97a18337b", size = 809411, upload-time = "2025-04-02T10:04:16.067Z" },
- { url = "https://files.pythonhosted.org/packages/fe/6d/69c53ffb11b3ee1bf5bd945cc2514848adea492c879a50f38e2ed4424727/python_calamine-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff4318b72ba78e8a04fb4c45342cfa23eab6f81ecdb85548cdab9f2db8ac9c7", size = 872905, upload-time = "2025-04-02T10:04:17.487Z" },
- { url = "https://files.pythonhosted.org/packages/be/ec/b02c4bc04c426d153af1f5ff07e797dd81ada6f47c170e0207d07c90b53a/python_calamine-0.3.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0cd8eb1ef8644da71788a33d3de602d1c08ff1c4136942d87e25f09580b512ef", size = 876464, upload-time = "2025-04-02T10:04:19.53Z" },
- { url = "https://files.pythonhosted.org/packages/46/ef/8403ee595207de5bd277279b56384b31390987df8a61c280b4176802481a/python_calamine-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9dcfd560d8f88f39d23b829f666ebae4bd8daeec7ed57adfb9313543f3c5fa35", size = 942289, upload-time = "2025-04-02T10:04:20.902Z" },
- { url = "https://files.pythonhosted.org/packages/89/97/b4e5b77c70b36613c10f2dbeece75b5d43727335a33bf5176792ec83c3fc/python_calamine-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5e79b9eae4b30c82d045f9952314137c7089c88274e1802947f9e3adb778a59", size = 978699, upload-time = "2025-04-02T10:04:22.263Z" },
- { url = "https://files.pythonhosted.org/packages/5f/e9/03bbafd6b11cdf70c004f2e856978fc252ec5ea7e77529f14f969134c7a8/python_calamine-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce5e8cc518c8e3e5988c5c658f9dcd8229f5541ca63353175bb15b6ad8c456d0", size = 886008, upload-time = "2025-04-02T10:04:23.754Z" },
- { url = "https://files.pythonhosted.org/packages/7b/20/e18f534e49b403ba0b979a4dfead146001d867f5be846b91f81ed5377972/python_calamine-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2a0e596b1346c28b2de15c9f86186cceefa4accb8882992aa0b7499c593446ed", size = 925104, upload-time = "2025-04-02T10:04:25.255Z" },
- { url = "https://files.pythonhosted.org/packages/54/4c/58933e69a0a7871487d10b958c1f83384bc430d53efbbfbf1dea141a0d85/python_calamine-0.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f521de16a9f3e951ec2e5e35d76752fe004088dbac4cdbf4dd62d0ad2bbf650f", size = 1050448, upload-time = "2025-04-02T10:04:26.649Z" },
- { url = "https://files.pythonhosted.org/packages/83/95/5c96d093eaaa2d15c63b43bcf8c87708eaab8428c72b6ebdcafc2604aa47/python_calamine-0.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:417d6825a36bba526ae17bed1b6ca576fbb54e23dc60c97eeb536c622e77c62f", size = 1056840, upload-time = "2025-04-02T10:04:28.18Z" },
- { url = "https://files.pythonhosted.org/packages/23/e0/b03cc3ad4f40fd3be0ebac0b71d273864ddf2bf0e611ec309328fdedded9/python_calamine-0.3.2-cp311-cp311-win32.whl", hash = "sha256:cd3ea1ca768139753633f9f0b16997648db5919894579f363d71f914f85f7ade", size = 663268, upload-time = "2025-04-02T10:04:29.659Z" },
- { url = "https://files.pythonhosted.org/packages/6b/bd/550da64770257fc70a185482f6353c0654a11f381227e146bb0170db040f/python_calamine-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:4560100412d8727c49048cca102eadeb004f91cfb9c99ae63cd7d4dc0a61333a", size = 692393, upload-time = "2025-04-02T10:04:31.534Z" },
- { url = "https://files.pythonhosted.org/packages/be/2e/0b4b7a146c3bb41116fe8e59a2f616340786db12aed51c7a9e75817cfa03/python_calamine-0.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:a2526e6ba79087b1634f49064800339edb7316780dd7e1e86d10a0ca9de4e90f", size = 667312, upload-time = "2025-04-02T10:04:32.911Z" },
- { url = "https://files.pythonhosted.org/packages/f2/0f/c2e3e3bae774dae47cba6ffa640ff95525bd6a10a13d3cd998f33aeafc7f/python_calamine-0.3.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:7c063b1f783352d6c6792305b2b0123784882e2436b638a9b9a1e97f6d74fa51", size = 825179, upload-time = "2025-04-02T10:04:34.377Z" },
- { url = "https://files.pythonhosted.org/packages/c7/81/a05285f06d71ea38ab99b09f3119f93f575487c9d24d7a1bab65657b258b/python_calamine-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85016728937e8f5d1810ff3c9603ffd2458d66e34d495202d7759fa8219871cd", size = 804036, upload-time = "2025-04-02T10:04:35.938Z" },
- { url = "https://files.pythonhosted.org/packages/24/b5/320f366ffd91ee5d5f0f77817d4fb684f62a5a68e438dcdb90e4f5f35137/python_calamine-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81f243323bf712bb0b2baf0b938a2e6d6c9fa3b9902a44c0654474d04f999fac", size = 871527, upload-time = "2025-04-02T10:04:38.272Z" },
- { url = "https://files.pythonhosted.org/packages/13/19/063afced19620b829697b90329c62ad73274cc38faaa91d9ee41047f5f8c/python_calamine-0.3.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b719dd2b10237b0cfb2062e3eaf199f220918a5623197e8449f37c8de845a7c", size = 875411, upload-time = "2025-04-02T10:04:39.647Z" },
- { url = "https://files.pythonhosted.org/packages/d7/6a/c93c52414ec62cc51c4820aff434f03c4a1c69ced15cec3e4b93885e4012/python_calamine-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5158310b9140e8ee8665c9541a11030901e7275eb036988150c93f01c5133bf", size = 943525, upload-time = "2025-04-02T10:04:41.025Z" },
- { url = "https://files.pythonhosted.org/packages/0a/0a/5bdecee03d235e8d111b1e8ee3ea0c0ed4ae43a402f75cebbe719930cf04/python_calamine-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2c1b248e8bf10194c449cb57e6ccb3f2fe3dc86975a6d746908cf2d37b048cc", size = 976332, upload-time = "2025-04-02T10:04:42.454Z" },
- { url = "https://files.pythonhosted.org/packages/05/ad/43ff92366856ee34f958e9cf4f5b98e63b0dc219e06ccba4ad6f63463756/python_calamine-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a13ad8e5b6843a73933b8d1710bc4df39a9152cb57c11227ad51f47b5838a4", size = 885549, upload-time = "2025-04-02T10:04:43.869Z" },
- { url = "https://files.pythonhosted.org/packages/ff/b9/76afb867e2bb4bfc296446b741cee01ae4ce6a094b43f4ed4eaed5189de4/python_calamine-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe950975a5758423c982ce1e2fdcb5c9c664d1a20b41ea21e619e5003bb4f96b", size = 926005, upload-time = "2025-04-02T10:04:45.884Z" },
- { url = "https://files.pythonhosted.org/packages/23/cf/5252b237b0e70c263f86741aea02e8e57aedb2bce9898468be1d9d55b9da/python_calamine-0.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:8707622ba816d6c26e36f1506ecda66a6a6cf43e55a43a8ef4c3bf8a805d3cfb", size = 1049380, upload-time = "2025-04-02T10:04:49.202Z" },
- { url = "https://files.pythonhosted.org/packages/1a/4d/f151e8923e53457ca49ceeaa3a34cb23afee7d7b46e6546ab2a29adc9125/python_calamine-0.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:e6eac46475c26e162a037f6711b663767f61f8fca3daffeb35aa3fc7ee6267cc", size = 1056720, upload-time = "2025-04-02T10:04:51.002Z" },
- { url = "https://files.pythonhosted.org/packages/f5/cb/1b5db3e4a8bbaaaa7706b270570d4a65133618fa0ca7efafe5ce680f6cee/python_calamine-0.3.2-cp312-cp312-win32.whl", hash = "sha256:0dee82aedef3db27368a388d6741d69334c1d4d7a8087ddd33f1912166e17e37", size = 663502, upload-time = "2025-04-02T10:04:52.402Z" },
- { url = "https://files.pythonhosted.org/packages/5a/53/920fa8e7b570647c08da0f1158d781db2e318918b06cb28fe0363c3398ac/python_calamine-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:ae09b779718809d31ca5d722464be2776b7d79278b1da56e159bbbe11880eecf", size = 692660, upload-time = "2025-04-02T10:04:53.721Z" },
- { url = "https://files.pythonhosted.org/packages/a5/ea/5d0ecf5c345c4d78964a5f97e61848bc912965b276a54fb8ae698a9419a8/python_calamine-0.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:435546e401a5821fa70048b6c03a70db3b27d00037e2c4999c2126d8c40b51df", size = 666205, upload-time = "2025-04-02T10:04:56.377Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/cc/03/269f96535705b2f18c8977fa58e76763b4e4727a9b3ae277a9468c8ffe05/python_calamine-0.4.0.tar.gz", hash = "sha256:94afcbae3fec36d2d7475095a59d4dc6fae45829968c743cb799ebae269d7bbf", size = 127737, upload-time = "2025-07-04T06:05:28.626Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/a5/bcd82326d0ff1ab5889e7a5e13c868b483fc56398e143aae8e93149ba43b/python_calamine-0.4.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d1687f8c4d7852920c7b4e398072f183f88dd273baf5153391edc88b7454b8c0", size = 833019, upload-time = "2025-07-04T06:03:32.214Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/1a/a681f1d2f28164552e91ef47bcde6708098aa64a5f5fe3952f22362d340a/python_calamine-0.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:258d04230bebbbafa370a15838049d912d6a0a2c4da128943d8160ca4b6db58e", size = 812268, upload-time = "2025-07-04T06:03:33.855Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/92/2fc911431733739d4e7a633cefa903fa49a6b7a61e8765bad29a4a7c47b1/python_calamine-0.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c686e491634934f059553d55f77ac67ca4c235452d5b444f98fe79b3579f1ea5", size = 875733, upload-time = "2025-07-04T06:03:35.154Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f0/48bfae6802eb360028ca6c15e9edf42243aadd0006b6ac3e9edb41a57119/python_calamine-0.4.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4480af7babcc2f919c638a554b06b7b145d9ab3da47fd696d68c2fc6f67f9541", size = 878325, upload-time = "2025-07-04T06:03:36.638Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/dc/f8c956e15bac9d5d1e05cd1b907ae780e40522d2fd103c8c6e2f21dff4ed/python_calamine-0.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e405b87a8cd1e90a994e570705898634f105442029f25bab7da658ee9cbaa771", size = 1015038, upload-time = "2025-07-04T06:03:37.971Z" },
+ { url = "https://files.pythonhosted.org/packages/54/3f/e69ab97c7734fb850fba2f506b775912fd59f04e17488582c8fbf52dbc72/python_calamine-0.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a831345ee42615f0dfcb0ed60a3b1601d2f946d4166edae64fd9a6f9bbd57fc1", size = 924969, upload-time = "2025-07-04T06:03:39.253Z" },
+ { url = "https://files.pythonhosted.org/packages/79/03/b4c056b468908d87a3de94389166e0f4dba725a70bc39e03bc039ba96f6b/python_calamine-0.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9951b8e4cafb3e1623bb5dfc31a18d38ef43589275f9657e99dfcbe4c8c4b33e", size = 888020, upload-time = "2025-07-04T06:03:41.099Z" },
+ { url = "https://files.pythonhosted.org/packages/86/4f/b9092f7c970894054083656953184e44cb2dadff8852425e950d4ca419af/python_calamine-0.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a6619fe3b5c9633ed8b178684605f8076c9d8d85b29ade15f7a7713fcfdee2d0", size = 930337, upload-time = "2025-07-04T06:03:42.89Z" },
+ { url = "https://files.pythonhosted.org/packages/64/da/137239027bf253aabe7063450950085ec9abd827d0cbc5170f585f38f464/python_calamine-0.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2cc45b8e76ee331f6ea88ca23677be0b7a05b502cd4423ba2c2bc8dad53af1be", size = 1054568, upload-time = "2025-07-04T06:03:44.153Z" },
+ { url = "https://files.pythonhosted.org/packages/80/96/74c38bcf6b6825d5180c0e147b85be8c52dbfba11848b1e98ba358e32a64/python_calamine-0.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1b2cfb7ced1a7c80befa0cfddfe4aae65663eb4d63c4ae484b9b7a80ebe1b528", size = 1058317, upload-time = "2025-07-04T06:03:45.873Z" },
+ { url = "https://files.pythonhosted.org/packages/33/95/9d7b8fe8b32d99a6c79534df3132cfe40e9df4a0f5204048bf5e66ddbd93/python_calamine-0.4.0-cp311-cp311-win32.whl", hash = "sha256:04f4e32ee16814fc1fafc49300be8eeb280d94878461634768b51497e1444bd6", size = 663934, upload-time = "2025-07-04T06:03:47.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/e3/1c6cd9fd499083bea6ff1c30033ee8215b9f64e862babf5be170cacae190/python_calamine-0.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:a8543f69afac2213c0257bb56215b03dadd11763064a9d6b19786f27d1bef586", size = 692535, upload-time = "2025-07-04T06:03:48.699Z" },
+ { url = "https://files.pythonhosted.org/packages/94/1c/3105d19fbab6b66874ce8831652caedd73b23b72e88ce18addf8ceca8c12/python_calamine-0.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:54622e35ec7c3b6f07d119da49aa821731c185e951918f152c2dbf3bec1e15d6", size = 671751, upload-time = "2025-07-04T06:03:49.979Z" },
+ { url = "https://files.pythonhosted.org/packages/63/60/f951513aaaa470b3a38a87d65eca45e0a02bc329b47864f5a17db563f746/python_calamine-0.4.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:74bca5d44a73acf3dcfa5370820797fcfd225c8c71abcddea987c5b4f5077e98", size = 826603, upload-time = "2025-07-04T06:03:51.245Z" },
+ { url = "https://files.pythonhosted.org/packages/76/3f/789955bbc77831c639890758f945eb2b25d6358065edf00da6751226cf31/python_calamine-0.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cf80178f5d1b0ee2ccfffb8549c50855f6249e930664adc5807f4d0d6c2b269c", size = 805826, upload-time = "2025-07-04T06:03:52.482Z" },
+ { url = "https://files.pythonhosted.org/packages/00/4c/f87d17d996f647030a40bfd124fe45fe893c002bee35ae6aca9910a923ae/python_calamine-0.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65cfef345386ae86f7720f1be93495a40fd7e7feabb8caa1df5025d7fbc58a1f", size = 874989, upload-time = "2025-07-04T06:03:53.794Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d2/3269367303f6c0488cf1bfebded3f9fe968d118a988222e04c9b2636bf2e/python_calamine-0.4.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f23e6214dbf9b29065a5dcfd6a6c674dd0e251407298c9138611c907d53423ff", size = 877504, upload-time = "2025-07-04T06:03:55.095Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/6d/c7ac35f5c7125e8bd07eb36773f300fda20dd2da635eae78a8cebb0b6ab7/python_calamine-0.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d792d304ee232ab01598e1d3ab22e074a32c2511476b5fb4f16f4222d9c2a265", size = 1014171, upload-time = "2025-07-04T06:03:56.777Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/81/5ea8792a2e9ab5e2a05872db3a4d3ed3538ad5af1861282c789e2f13a8cf/python_calamine-0.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf813425918fd68f3e991ef7c4b5015be0a1a95fc4a8ab7e73c016ef1b881bb4", size = 926737, upload-time = "2025-07-04T06:03:58.024Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/6e/989e56e6f073fc0981a74ba7a393881eb351bb143e5486aa629b5e5d6a8b/python_calamine-0.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbe2a0ccb4d003635888eea83a995ff56b0748c8c76fc71923544f5a4a7d4cd7", size = 887032, upload-time = "2025-07-04T06:03:59.298Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/92/2c9bd64277c6fe4be695d7d5a803b38d953ec8565037486be7506642c27c/python_calamine-0.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7b3bb5f0d910b9b03c240987560f843256626fd443279759df4e91b717826d2", size = 929700, upload-time = "2025-07-04T06:04:01.388Z" },
+ { url = "https://files.pythonhosted.org/packages/64/fa/fc758ca37701d354a6bc7d63118699f1c73788a1f2e1b44d720824992764/python_calamine-0.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bd2c0fc2b5eabd08ceac8a2935bffa88dbc6116db971aa8c3f244bad3fd0f644", size = 1053971, upload-time = "2025-07-04T06:04:02.704Z" },
+ { url = "https://files.pythonhosted.org/packages/65/52/40d7e08ae0ddba331cdc9f7fb3e92972f8f38d7afbd00228158ff6d1fceb/python_calamine-0.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:85b547cb1c5b692a0c2406678d666dbc1cec65a714046104683fe4f504a1721d", size = 1057057, upload-time = "2025-07-04T06:04:04.014Z" },
+ { url = "https://files.pythonhosted.org/packages/16/de/e8a071c0adfda73285d891898a24f6e99338328c404f497ff5b0e6bc3d45/python_calamine-0.4.0-cp312-cp312-win32.whl", hash = "sha256:4c2a1e3a0db4d6de4587999a21cc35845648c84fba81c03dd6f3072c690888e4", size = 665540, upload-time = "2025-07-04T06:04:05.679Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/f2/7fdfada13f80db12356853cf08697ff4e38800a1809c2bdd26ee60962e7a/python_calamine-0.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b193c89ffcc146019475cd121c552b23348411e19c04dedf5c766a20db64399a", size = 695366, upload-time = "2025-07-04T06:04:06.977Z" },
+ { url = "https://files.pythonhosted.org/packages/20/66/d37412ad854480ce32f50d9f74f2a2f88b1b8a6fbc32f70aabf3211ae89e/python_calamine-0.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:43a0f15e0b60c75a71b21a012b911d5d6f5fa052afad2a8edbc728af43af0fcf", size = 670740, upload-time = "2025-07-04T06:04:08.656Z" },
]
[[package]]
@@ -4640,6 +4676,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863, upload-time = "2024-01-23T06:32:58.246Z" },
]
+[[package]]
+name = "python-http-client"
+version = "3.3.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/56/fa/284e52a8c6dcbe25671f02d217bf2f85660db940088faf18ae7a05e97313/python_http_client-3.3.7.tar.gz", hash = "sha256:bf841ee45262747e00dec7ee9971dfb8c7d83083f5713596488d67739170cea0", size = 9377, upload-time = "2022-03-09T20:23:56.386Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/29/31/9b360138f4e4035ee9dac4fe1132b6437bd05751aaf1db2a2d83dc45db5f/python_http_client-3.3.7-py3-none-any.whl", hash = "sha256:ad371d2bbedc6ea15c26179c6222a78bc9308d272435ddf1d5c84f068f249a36", size = 8352, upload-time = "2022-03-09T20:23:54.862Z" },
+]
+
[[package]]
name = "python-iso639"
version = "2025.2.18"
@@ -4823,17 +4868,15 @@ wheels = [
[[package]]
name = "realtime"
-version = "2.5.0"
+version = "2.5.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
- { name = "aiohttp" },
- { name = "python-dateutil" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/47/9e/d6e478ccc23869a450a5d0dd9ab0c8a4e37fee7aec43c925d89bb09fcaf5/realtime-2.5.0.tar.gz", hash = "sha256:03d744dedc823de019a7f9917c1a6509fb6e98d357adf7fd7f4015618dac7ecd", size = 18865, upload-time = "2025-05-15T12:40:14.868Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/48/94/3cf962b814303a1688eece56a94b25a7bd423d60705f1124cba0896c9c07/realtime-2.5.3.tar.gz", hash = "sha256:0587594f3bc1c84bf007ff625075b86db6528843e03250dc84f4f2808be3d99a", size = 18527, upload-time = "2025-06-26T22:39:01.59Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/52/09/a9ede9afa4a536ac84ad365bfa26116ab17463c8353f471b2396dc0e44d0/realtime-2.5.0-py3-none-any.whl", hash = "sha256:a54274a6cdc03c3eda61fbfec1d277e4a28e3aa9526d24b5c187385bb8a7e85a", size = 22086, upload-time = "2025-05-15T12:40:13.092Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/2a/f69c156a58d44b7b9ca22dab181b91e4d93d074f99923c75907bf3953d40/realtime-2.5.3-py3-none-any.whl", hash = "sha256:eb0994636946eff04c4c7f044f980c8c633c7eb632994f549f61053a474ac970", size = 21784, upload-time = "2025-06-26T22:38:59.98Z" },
]
[[package]]
@@ -4907,7 +4950,7 @@ wheels = [
[[package]]
name = "requests"
-version = "2.32.3"
+version = "2.32.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -4915,9 +4958,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258, upload-time = "2025-06-09T16:43:07.34Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847, upload-time = "2025-06-09T16:43:05.728Z" },
]
[[package]]
@@ -4986,49 +5029,49 @@ wheels = [
[[package]]
name = "rpds-py"
-version = "0.25.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/8c/a6/60184b7fc00dd3ca80ac635dd5b8577d444c57e8e8742cecabfacb829921/rpds_py-0.25.1.tar.gz", hash = "sha256:8960b6dac09b62dac26e75d7e2c4a22efb835d827a7278c34f72b2b84fa160e3", size = 27304, upload-time = "2025-05-21T12:46:12.502Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/95/e1/df13fe3ddbbea43567e07437f097863b20c99318ae1f58a0fe389f763738/rpds_py-0.25.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:5f048bbf18b1f9120685c6d6bb70cc1a52c8cc11bdd04e643d28d3be0baf666d", size = 373341, upload-time = "2025-05-21T12:43:02.978Z" },
- { url = "https://files.pythonhosted.org/packages/7a/58/deef4d30fcbcbfef3b6d82d17c64490d5c94585a2310544ce8e2d3024f83/rpds_py-0.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fbb0dbba559959fcb5d0735a0f87cdbca9e95dac87982e9b95c0f8f7ad10255", size = 359111, upload-time = "2025-05-21T12:43:05.128Z" },
- { url = "https://files.pythonhosted.org/packages/bb/7e/39f1f4431b03e96ebaf159e29a0f82a77259d8f38b2dd474721eb3a8ac9b/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4ca54b9cf9d80b4016a67a0193ebe0bcf29f6b0a96f09db942087e294d3d4c2", size = 386112, upload-time = "2025-05-21T12:43:07.13Z" },
- { url = "https://files.pythonhosted.org/packages/db/e7/847068a48d63aec2ae695a1646089620b3b03f8ccf9f02c122ebaf778f3c/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ee3e26eb83d39b886d2cb6e06ea701bba82ef30a0de044d34626ede51ec98b0", size = 400362, upload-time = "2025-05-21T12:43:08.693Z" },
- { url = "https://files.pythonhosted.org/packages/3b/3d/9441d5db4343d0cee759a7ab4d67420a476cebb032081763de934719727b/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89706d0683c73a26f76a5315d893c051324d771196ae8b13e6ffa1ffaf5e574f", size = 522214, upload-time = "2025-05-21T12:43:10.694Z" },
- { url = "https://files.pythonhosted.org/packages/a2/ec/2cc5b30d95f9f1a432c79c7a2f65d85e52812a8f6cbf8768724571710786/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2013ee878c76269c7b557a9a9c042335d732e89d482606990b70a839635feb7", size = 411491, upload-time = "2025-05-21T12:43:12.739Z" },
- { url = "https://files.pythonhosted.org/packages/dc/6c/44695c1f035077a017dd472b6a3253553780837af2fac9b6ac25f6a5cb4d/rpds_py-0.25.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45e484db65e5380804afbec784522de84fa95e6bb92ef1bd3325d33d13efaebd", size = 386978, upload-time = "2025-05-21T12:43:14.25Z" },
- { url = "https://files.pythonhosted.org/packages/b1/74/b4357090bb1096db5392157b4e7ed8bb2417dc7799200fcbaee633a032c9/rpds_py-0.25.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:48d64155d02127c249695abb87d39f0faf410733428d499867606be138161d65", size = 420662, upload-time = "2025-05-21T12:43:15.8Z" },
- { url = "https://files.pythonhosted.org/packages/26/dd/8cadbebf47b96e59dfe8b35868e5c38a42272699324e95ed522da09d3a40/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:048893e902132fd6548a2e661fb38bf4896a89eea95ac5816cf443524a85556f", size = 563385, upload-time = "2025-05-21T12:43:17.78Z" },
- { url = "https://files.pythonhosted.org/packages/c3/ea/92960bb7f0e7a57a5ab233662f12152085c7dc0d5468534c65991a3d48c9/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0317177b1e8691ab5879f4f33f4b6dc55ad3b344399e23df2e499de7b10a548d", size = 592047, upload-time = "2025-05-21T12:43:19.457Z" },
- { url = "https://files.pythonhosted.org/packages/61/ad/71aabc93df0d05dabcb4b0c749277881f8e74548582d96aa1bf24379493a/rpds_py-0.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bffcf57826d77a4151962bf1701374e0fc87f536e56ec46f1abdd6a903354042", size = 557863, upload-time = "2025-05-21T12:43:21.69Z" },
- { url = "https://files.pythonhosted.org/packages/93/0f/89df0067c41f122b90b76f3660028a466eb287cbe38efec3ea70e637ca78/rpds_py-0.25.1-cp311-cp311-win32.whl", hash = "sha256:cda776f1967cb304816173b30994faaf2fd5bcb37e73118a47964a02c348e1bc", size = 219627, upload-time = "2025-05-21T12:43:23.311Z" },
- { url = "https://files.pythonhosted.org/packages/7c/8d/93b1a4c1baa903d0229374d9e7aa3466d751f1d65e268c52e6039c6e338e/rpds_py-0.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:dc3c1ff0abc91444cd20ec643d0f805df9a3661fcacf9c95000329f3ddf268a4", size = 231603, upload-time = "2025-05-21T12:43:25.145Z" },
- { url = "https://files.pythonhosted.org/packages/cb/11/392605e5247bead2f23e6888e77229fbd714ac241ebbebb39a1e822c8815/rpds_py-0.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:5a3ddb74b0985c4387719fc536faced33cadf2172769540c62e2a94b7b9be1c4", size = 223967, upload-time = "2025-05-21T12:43:26.566Z" },
- { url = "https://files.pythonhosted.org/packages/7f/81/28ab0408391b1dc57393653b6a0cf2014cc282cc2909e4615e63e58262be/rpds_py-0.25.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5ffe453cde61f73fea9430223c81d29e2fbf412a6073951102146c84e19e34c", size = 364647, upload-time = "2025-05-21T12:43:28.559Z" },
- { url = "https://files.pythonhosted.org/packages/2c/9a/7797f04cad0d5e56310e1238434f71fc6939d0bc517192a18bb99a72a95f/rpds_py-0.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:115874ae5e2fdcfc16b2aedc95b5eef4aebe91b28e7e21951eda8a5dc0d3461b", size = 350454, upload-time = "2025-05-21T12:43:30.615Z" },
- { url = "https://files.pythonhosted.org/packages/69/3c/93d2ef941b04898011e5d6eaa56a1acf46a3b4c9f4b3ad1bbcbafa0bee1f/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a714bf6e5e81b0e570d01f56e0c89c6375101b8463999ead3a93a5d2a4af91fa", size = 389665, upload-time = "2025-05-21T12:43:32.629Z" },
- { url = "https://files.pythonhosted.org/packages/c1/57/ad0e31e928751dde8903a11102559628d24173428a0f85e25e187defb2c1/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:35634369325906bcd01577da4c19e3b9541a15e99f31e91a02d010816b49bfda", size = 403873, upload-time = "2025-05-21T12:43:34.576Z" },
- { url = "https://files.pythonhosted.org/packages/16/ad/c0c652fa9bba778b4f54980a02962748479dc09632e1fd34e5282cf2556c/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4cb2b3ddc16710548801c6fcc0cfcdeeff9dafbc983f77265877793f2660309", size = 525866, upload-time = "2025-05-21T12:43:36.123Z" },
- { url = "https://files.pythonhosted.org/packages/2a/39/3e1839bc527e6fcf48d5fec4770070f872cdee6c6fbc9b259932f4e88a38/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9ceca1cf097ed77e1a51f1dbc8d174d10cb5931c188a4505ff9f3e119dfe519b", size = 416886, upload-time = "2025-05-21T12:43:38.034Z" },
- { url = "https://files.pythonhosted.org/packages/7a/95/dd6b91cd4560da41df9d7030a038298a67d24f8ca38e150562644c829c48/rpds_py-0.25.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c2cd1a4b0c2b8c5e31ffff50d09f39906fe351389ba143c195566056c13a7ea", size = 390666, upload-time = "2025-05-21T12:43:40.065Z" },
- { url = "https://files.pythonhosted.org/packages/64/48/1be88a820e7494ce0a15c2d390ccb7c52212370badabf128e6a7bb4cb802/rpds_py-0.25.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1de336a4b164c9188cb23f3703adb74a7623ab32d20090d0e9bf499a2203ad65", size = 425109, upload-time = "2025-05-21T12:43:42.263Z" },
- { url = "https://files.pythonhosted.org/packages/cf/07/3e2a17927ef6d7720b9949ec1b37d1e963b829ad0387f7af18d923d5cfa5/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9fca84a15333e925dd59ce01da0ffe2ffe0d6e5d29a9eeba2148916d1824948c", size = 567244, upload-time = "2025-05-21T12:43:43.846Z" },
- { url = "https://files.pythonhosted.org/packages/d2/e5/76cf010998deccc4f95305d827847e2eae9c568099c06b405cf96384762b/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:88ec04afe0c59fa64e2f6ea0dd9657e04fc83e38de90f6de201954b4d4eb59bd", size = 596023, upload-time = "2025-05-21T12:43:45.932Z" },
- { url = "https://files.pythonhosted.org/packages/52/9a/df55efd84403736ba37a5a6377b70aad0fd1cb469a9109ee8a1e21299a1c/rpds_py-0.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8bd2f19e312ce3e1d2c635618e8a8d8132892bb746a7cf74780a489f0f6cdcb", size = 561634, upload-time = "2025-05-21T12:43:48.263Z" },
- { url = "https://files.pythonhosted.org/packages/ab/aa/dc3620dd8db84454aaf9374bd318f1aa02578bba5e567f5bf6b79492aca4/rpds_py-0.25.1-cp312-cp312-win32.whl", hash = "sha256:e5e2f7280d8d0d3ef06f3ec1b4fd598d386cc6f0721e54f09109a8132182fbfe", size = 222713, upload-time = "2025-05-21T12:43:49.897Z" },
- { url = "https://files.pythonhosted.org/packages/a3/7f/7cef485269a50ed5b4e9bae145f512d2a111ca638ae70cc101f661b4defd/rpds_py-0.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:db58483f71c5db67d643857404da360dce3573031586034b7d59f245144cc192", size = 235280, upload-time = "2025-05-21T12:43:51.893Z" },
- { url = "https://files.pythonhosted.org/packages/99/f2/c2d64f6564f32af913bf5f3f7ae41c7c263c5ae4c4e8f1a17af8af66cd46/rpds_py-0.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:6d50841c425d16faf3206ddbba44c21aa3310a0cebc3c1cdfc3e3f4f9f6f5728", size = 225399, upload-time = "2025-05-21T12:43:53.351Z" },
- { url = "https://files.pythonhosted.org/packages/49/74/48f3df0715a585cbf5d34919c9c757a4c92c1a9eba059f2d334e72471f70/rpds_py-0.25.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ee86d81551ec68a5c25373c5643d343150cc54672b5e9a0cafc93c1870a53954", size = 374208, upload-time = "2025-05-21T12:45:26.306Z" },
- { url = "https://files.pythonhosted.org/packages/55/b0/9b01bb11ce01ec03d05e627249cc2c06039d6aa24ea5a22a39c312167c10/rpds_py-0.25.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89c24300cd4a8e4a51e55c31a8ff3918e6651b241ee8876a42cc2b2a078533ba", size = 359262, upload-time = "2025-05-21T12:45:28.322Z" },
- { url = "https://files.pythonhosted.org/packages/a9/eb/5395621618f723ebd5116c53282052943a726dba111b49cd2071f785b665/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:771c16060ff4e79584dc48902a91ba79fd93eade3aa3a12d6d2a4aadaf7d542b", size = 387366, upload-time = "2025-05-21T12:45:30.42Z" },
- { url = "https://files.pythonhosted.org/packages/68/73/3d51442bdb246db619d75039a50ea1cf8b5b4ee250c3e5cd5c3af5981cd4/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:785ffacd0ee61c3e60bdfde93baa6d7c10d86f15655bd706c89da08068dc5038", size = 400759, upload-time = "2025-05-21T12:45:32.516Z" },
- { url = "https://files.pythonhosted.org/packages/b7/4c/3a32d5955d7e6cb117314597bc0f2224efc798428318b13073efe306512a/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a40046a529cc15cef88ac5ab589f83f739e2d332cb4d7399072242400ed68c9", size = 523128, upload-time = "2025-05-21T12:45:34.396Z" },
- { url = "https://files.pythonhosted.org/packages/be/95/1ffccd3b0bb901ae60b1dd4b1be2ab98bb4eb834cd9b15199888f5702f7b/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:85fc223d9c76cabe5d0bff82214459189720dc135db45f9f66aa7cffbf9ff6c1", size = 411597, upload-time = "2025-05-21T12:45:36.164Z" },
- { url = "https://files.pythonhosted.org/packages/ef/6d/6e6cd310180689db8b0d2de7f7d1eabf3fb013f239e156ae0d5a1a85c27f/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0be9965f93c222fb9b4cc254235b3b2b215796c03ef5ee64f995b1b69af0762", size = 388053, upload-time = "2025-05-21T12:45:38.45Z" },
- { url = "https://files.pythonhosted.org/packages/4a/87/ec4186b1fe6365ced6fa470960e68fc7804bafbe7c0cf5a36237aa240efa/rpds_py-0.25.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8378fa4a940f3fb509c081e06cb7f7f2adae8cf46ef258b0e0ed7519facd573e", size = 421821, upload-time = "2025-05-21T12:45:40.732Z" },
- { url = "https://files.pythonhosted.org/packages/7a/60/84f821f6bf4e0e710acc5039d91f8f594fae0d93fc368704920d8971680d/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:33358883a4490287e67a2c391dfaea4d9359860281db3292b6886bf0be3d8692", size = 564534, upload-time = "2025-05-21T12:45:42.672Z" },
- { url = "https://files.pythonhosted.org/packages/41/3a/bc654eb15d3b38f9330fe0f545016ba154d89cdabc6177b0295910cd0ebe/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1d1fadd539298e70cac2f2cb36f5b8a65f742b9b9f1014dd4ea1f7785e2470bf", size = 592674, upload-time = "2025-05-21T12:45:44.533Z" },
- { url = "https://files.pythonhosted.org/packages/2e/ba/31239736f29e4dfc7a58a45955c5db852864c306131fd6320aea214d5437/rpds_py-0.25.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:9a46c2fb2545e21181445515960006e85d22025bd2fe6db23e76daec6eb689fe", size = 558781, upload-time = "2025-05-21T12:45:46.281Z" },
+version = "0.26.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a5/aa/4456d84bbb54adc6a916fb10c9b374f78ac840337644e4a5eda229c81275/rpds_py-0.26.0.tar.gz", hash = "sha256:20dae58a859b0906f0685642e591056f1e787f3a8b39c8e8749a45dc7d26bdb0", size = 27385, upload-time = "2025-07-01T15:57:13.958Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/09/4c/4ee8f7e512030ff79fda1df3243c88d70fc874634e2dbe5df13ba4210078/rpds_py-0.26.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9e8cb77286025bdb21be2941d64ac6ca016130bfdcd228739e8ab137eb4406ed", size = 372610, upload-time = "2025-07-01T15:53:58.844Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/9d/3dc16be00f14fc1f03c71b1d67c8df98263ab2710a2fbd65a6193214a527/rpds_py-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5e09330b21d98adc8ccb2dbb9fc6cb434e8908d4c119aeaa772cb1caab5440a0", size = 358032, upload-time = "2025-07-01T15:53:59.985Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/5a/7f1bf8f045da2866324a08ae80af63e64e7bfaf83bd31f865a7b91a58601/rpds_py-0.26.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c9c1b92b774b2e68d11193dc39620d62fd8ab33f0a3c77ecdabe19c179cdbc1", size = 381525, upload-time = "2025-07-01T15:54:01.162Z" },
+ { url = "https://files.pythonhosted.org/packages/45/8a/04479398c755a066ace10e3d158866beb600867cacae194c50ffa783abd0/rpds_py-0.26.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:824e6d3503ab990d7090768e4dfd9e840837bae057f212ff9f4f05ec6d1975e7", size = 397089, upload-time = "2025-07-01T15:54:02.319Z" },
+ { url = "https://files.pythonhosted.org/packages/72/88/9203f47268db488a1b6d469d69c12201ede776bb728b9d9f29dbfd7df406/rpds_py-0.26.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ad7fd2258228bf288f2331f0a6148ad0186b2e3643055ed0db30990e59817a6", size = 514255, upload-time = "2025-07-01T15:54:03.38Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/b4/01ce5d1e853ddf81fbbd4311ab1eff0b3cf162d559288d10fd127e2588b5/rpds_py-0.26.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0dc23bbb3e06ec1ea72d515fb572c1fea59695aefbffb106501138762e1e915e", size = 402283, upload-time = "2025-07-01T15:54:04.923Z" },
+ { url = "https://files.pythonhosted.org/packages/34/a2/004c99936997bfc644d590a9defd9e9c93f8286568f9c16cdaf3e14429a7/rpds_py-0.26.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d80bf832ac7b1920ee29a426cdca335f96a2b5caa839811803e999b41ba9030d", size = 383881, upload-time = "2025-07-01T15:54:06.482Z" },
+ { url = "https://files.pythonhosted.org/packages/05/1b/ef5fba4a8f81ce04c427bfd96223f92f05e6cd72291ce9d7523db3b03a6c/rpds_py-0.26.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0919f38f5542c0a87e7b4afcafab6fd2c15386632d249e9a087498571250abe3", size = 415822, upload-time = "2025-07-01T15:54:07.605Z" },
+ { url = "https://files.pythonhosted.org/packages/16/80/5c54195aec456b292f7bd8aa61741c8232964063fd8a75fdde9c1e982328/rpds_py-0.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d422b945683e409000c888e384546dbab9009bb92f7c0b456e217988cf316107", size = 558347, upload-time = "2025-07-01T15:54:08.591Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/1c/1845c1b1fd6d827187c43afe1841d91678d7241cbdb5420a4c6de180a538/rpds_py-0.26.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:77a7711fa562ba2da1aa757e11024ad6d93bad6ad7ede5afb9af144623e5f76a", size = 587956, upload-time = "2025-07-01T15:54:09.963Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/ff/9e979329dd131aa73a438c077252ddabd7df6d1a7ad7b9aacf6261f10faa/rpds_py-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238e8c8610cb7c29460e37184f6799547f7e09e6a9bdbdab4e8edb90986a2318", size = 554363, upload-time = "2025-07-01T15:54:11.073Z" },
+ { url = "https://files.pythonhosted.org/packages/00/8b/d78cfe034b71ffbe72873a136e71acc7a831a03e37771cfe59f33f6de8a2/rpds_py-0.26.0-cp311-cp311-win32.whl", hash = "sha256:893b022bfbdf26d7bedb083efeea624e8550ca6eb98bf7fea30211ce95b9201a", size = 220123, upload-time = "2025-07-01T15:54:12.382Z" },
+ { url = "https://files.pythonhosted.org/packages/94/c1/3c8c94c7dd3905dbfde768381ce98778500a80db9924731d87ddcdb117e9/rpds_py-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:87a5531de9f71aceb8af041d72fc4cab4943648d91875ed56d2e629bef6d4c03", size = 231732, upload-time = "2025-07-01T15:54:13.434Z" },
+ { url = "https://files.pythonhosted.org/packages/67/93/e936fbed1b734eabf36ccb5d93c6a2e9246fbb13c1da011624b7286fae3e/rpds_py-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:de2713f48c1ad57f89ac25b3cb7daed2156d8e822cf0eca9b96a6f990718cc41", size = 221917, upload-time = "2025-07-01T15:54:14.559Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/86/90eb87c6f87085868bd077c7a9938006eb1ce19ed4d06944a90d3560fce2/rpds_py-0.26.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:894514d47e012e794f1350f076c427d2347ebf82f9b958d554d12819849a369d", size = 363933, upload-time = "2025-07-01T15:54:15.734Z" },
+ { url = "https://files.pythonhosted.org/packages/63/78/4469f24d34636242c924626082b9586f064ada0b5dbb1e9d096ee7a8e0c6/rpds_py-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc921b96fa95a097add244da36a1d9e4f3039160d1d30f1b35837bf108c21136", size = 350447, upload-time = "2025-07-01T15:54:16.922Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/91/c448ed45efdfdade82348d5e7995e15612754826ea640afc20915119734f/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e1157659470aa42a75448b6e943c895be8c70531c43cb78b9ba990778955582", size = 384711, upload-time = "2025-07-01T15:54:18.101Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/43/e5c86fef4be7f49828bdd4ecc8931f0287b1152c0bb0163049b3218740e7/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:521ccf56f45bb3a791182dc6b88ae5f8fa079dd705ee42138c76deb1238e554e", size = 400865, upload-time = "2025-07-01T15:54:19.295Z" },
+ { url = "https://files.pythonhosted.org/packages/55/34/e00f726a4d44f22d5c5fe2e5ddd3ac3d7fd3f74a175607781fbdd06fe375/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9def736773fd56b305c0eef698be5192c77bfa30d55a0e5885f80126c4831a15", size = 517763, upload-time = "2025-07-01T15:54:20.858Z" },
+ { url = "https://files.pythonhosted.org/packages/52/1c/52dc20c31b147af724b16104500fba13e60123ea0334beba7b40e33354b4/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdad4ea3b4513b475e027be79e5a0ceac8ee1c113a1a11e5edc3c30c29f964d8", size = 406651, upload-time = "2025-07-01T15:54:22.508Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/77/87d7bfabfc4e821caa35481a2ff6ae0b73e6a391bb6b343db2c91c2b9844/rpds_py-0.26.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b165b07f416bdccf5c84546a484cc8f15137ca38325403864bfdf2b5b72f6a", size = 386079, upload-time = "2025-07-01T15:54:23.987Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/d4/7f2200c2d3ee145b65b3cddc4310d51f7da6a26634f3ac87125fd789152a/rpds_py-0.26.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d04cab0a54b9dba4d278fe955a1390da3cf71f57feb78ddc7cb67cbe0bd30323", size = 421379, upload-time = "2025-07-01T15:54:25.073Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/13/9fdd428b9c820869924ab62236b8688b122baa22d23efdd1c566938a39ba/rpds_py-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:79061ba1a11b6a12743a2b0f72a46aa2758613d454aa6ba4f5a265cc48850158", size = 562033, upload-time = "2025-07-01T15:54:26.225Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/e1/b69686c3bcbe775abac3a4c1c30a164a2076d28df7926041f6c0eb5e8d28/rpds_py-0.26.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f405c93675d8d4c5ac87364bb38d06c988e11028a64b52a47158a355079661f3", size = 591639, upload-time = "2025-07-01T15:54:27.424Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/c9/1e3d8c8863c84a90197ac577bbc3d796a92502124c27092413426f670990/rpds_py-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dafd4c44b74aa4bed4b250f1aed165b8ef5de743bcca3b88fc9619b6087093d2", size = 557105, upload-time = "2025-07-01T15:54:29.93Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/c5/90c569649057622959f6dcc40f7b516539608a414dfd54b8d77e3b201ac0/rpds_py-0.26.0-cp312-cp312-win32.whl", hash = "sha256:3da5852aad63fa0c6f836f3359647870e21ea96cf433eb393ffa45263a170d44", size = 223272, upload-time = "2025-07-01T15:54:31.128Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/16/19f5d9f2a556cfed454eebe4d354c38d51c20f3db69e7b4ce6cff904905d/rpds_py-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf47cfdabc2194a669dcf7a8dbba62e37a04c5041d2125fae0233b720da6f05c", size = 234995, upload-time = "2025-07-01T15:54:32.195Z" },
+ { url = "https://files.pythonhosted.org/packages/83/f0/7935e40b529c0e752dfaa7880224771b51175fce08b41ab4a92eb2fbdc7f/rpds_py-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:20ab1ae4fa534f73647aad289003f1104092890849e0266271351922ed5574f8", size = 223198, upload-time = "2025-07-01T15:54:33.271Z" },
+ { url = "https://files.pythonhosted.org/packages/51/f2/b5c85b758a00c513bb0389f8fc8e61eb5423050c91c958cdd21843faa3e6/rpds_py-0.26.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f61a9326f80ca59214d1cceb0a09bb2ece5b2563d4e0cd37bfd5515c28510674", size = 373505, upload-time = "2025-07-01T15:56:34.716Z" },
+ { url = "https://files.pythonhosted.org/packages/23/e0/25db45e391251118e915e541995bb5f5ac5691a3b98fb233020ba53afc9b/rpds_py-0.26.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:183f857a53bcf4b1b42ef0f57ca553ab56bdd170e49d8091e96c51c3d69ca696", size = 359468, upload-time = "2025-07-01T15:56:36.219Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/73/dd5ee6075bb6491be3a646b301dfd814f9486d924137a5098e61f0487e16/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:941c1cfdf4799d623cf3aa1d326a6b4fdb7a5799ee2687f3516738216d2262fb", size = 382680, upload-time = "2025-07-01T15:56:37.644Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/10/84b522ff58763a5c443f5bcedc1820240e454ce4e620e88520f04589e2ea/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72a8d9564a717ee291f554eeb4bfeafe2309d5ec0aa6c475170bdab0f9ee8e88", size = 397035, upload-time = "2025-07-01T15:56:39.241Z" },
+ { url = "https://files.pythonhosted.org/packages/06/ea/8667604229a10a520fcbf78b30ccc278977dcc0627beb7ea2c96b3becef0/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:511d15193cbe013619dd05414c35a7dedf2088fcee93c6bbb7c77859765bd4e8", size = 514922, upload-time = "2025-07-01T15:56:40.645Z" },
+ { url = "https://files.pythonhosted.org/packages/24/e6/9ed5b625c0661c4882fc8cdf302bf8e96c73c40de99c31e0b95ed37d508c/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aea1f9741b603a8d8fedb0ed5502c2bc0accbc51f43e2ad1337fe7259c2b77a5", size = 402822, upload-time = "2025-07-01T15:56:42.137Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/58/212c7b6fd51946047fb45d3733da27e2fa8f7384a13457c874186af691b1/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4019a9d473c708cf2f16415688ef0b4639e07abaa569d72f74745bbeffafa2c7", size = 384336, upload-time = "2025-07-01T15:56:44.239Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/f5/a40ba78748ae8ebf4934d4b88e77b98497378bc2c24ba55ebe87a4e87057/rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:093d63b4b0f52d98ebae33b8c50900d3d67e0666094b1be7a12fffd7f65de74b", size = 416871, upload-time = "2025-07-01T15:56:46.284Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/a6/33b1fc0c9f7dcfcfc4a4353daa6308b3ece22496ceece348b3e7a7559a09/rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2abe21d8ba64cded53a2a677e149ceb76dcf44284202d737178afe7ba540c1eb", size = 559439, upload-time = "2025-07-01T15:56:48.549Z" },
+ { url = "https://files.pythonhosted.org/packages/71/2d/ceb3f9c12f8cfa56d34995097f6cd99da1325642c60d1b6680dd9df03ed8/rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:4feb7511c29f8442cbbc28149a92093d32e815a28aa2c50d333826ad2a20fdf0", size = 588380, upload-time = "2025-07-01T15:56:50.086Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/ed/9de62c2150ca8e2e5858acf3f4f4d0d180a38feef9fdab4078bea63d8dba/rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e99685fc95d386da368013e7fb4269dd39c30d99f812a8372d62f244f662709c", size = 555334, upload-time = "2025-07-01T15:56:51.703Z" },
]
[[package]]
@@ -5045,27 +5088,27 @@ wheels = [
[[package]]
name = "ruff"
-version = "0.11.12"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/15/0a/92416b159ec00cdf11e5882a9d80d29bf84bba3dbebc51c4898bfbca1da6/ruff-0.11.12.tar.gz", hash = "sha256:43cf7f69c7d7c7d7513b9d59c5d8cafd704e05944f978614aa9faff6ac202603", size = 4202289, upload-time = "2025-05-29T13:31:40.037Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/60/cc/53eb79f012d15e136d40a8e8fc519ba8f55a057f60b29c2df34efd47c6e3/ruff-0.11.12-py3-none-linux_armv6l.whl", hash = "sha256:c7680aa2f0d4c4f43353d1e72123955c7a2159b8646cd43402de6d4a3a25d7cc", size = 10285597, upload-time = "2025-05-29T13:30:57.539Z" },
- { url = "https://files.pythonhosted.org/packages/e7/d7/73386e9fb0232b015a23f62fea7503f96e29c29e6c45461d4a73bac74df9/ruff-0.11.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2cad64843da9f134565c20bcc430642de897b8ea02e2e79e6e02a76b8dcad7c3", size = 11053154, upload-time = "2025-05-29T13:31:00.865Z" },
- { url = "https://files.pythonhosted.org/packages/4e/eb/3eae144c5114e92deb65a0cb2c72326c8469e14991e9bc3ec0349da1331c/ruff-0.11.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9b6886b524a1c659cee1758140138455d3c029783d1b9e643f3624a5ee0cb0aa", size = 10403048, upload-time = "2025-05-29T13:31:03.413Z" },
- { url = "https://files.pythonhosted.org/packages/29/64/20c54b20e58b1058db6689e94731f2a22e9f7abab74e1a758dfba058b6ca/ruff-0.11.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc3a3690aad6e86c1958d3ec3c38c4594b6ecec75c1f531e84160bd827b2012", size = 10597062, upload-time = "2025-05-29T13:31:05.539Z" },
- { url = "https://files.pythonhosted.org/packages/29/3a/79fa6a9a39422a400564ca7233a689a151f1039110f0bbbabcb38106883a/ruff-0.11.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f97fdbc2549f456c65b3b0048560d44ddd540db1f27c778a938371424b49fe4a", size = 10155152, upload-time = "2025-05-29T13:31:07.986Z" },
- { url = "https://files.pythonhosted.org/packages/e5/a4/22c2c97b2340aa968af3a39bc38045e78d36abd4ed3fa2bde91c31e712e3/ruff-0.11.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74adf84960236961090e2d1348c1a67d940fd12e811a33fb3d107df61eef8fc7", size = 11723067, upload-time = "2025-05-29T13:31:10.57Z" },
- { url = "https://files.pythonhosted.org/packages/bc/cf/3e452fbd9597bcd8058856ecd42b22751749d07935793a1856d988154151/ruff-0.11.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b56697e5b8bcf1d61293ccfe63873aba08fdbcbbba839fc046ec5926bdb25a3a", size = 12460807, upload-time = "2025-05-29T13:31:12.88Z" },
- { url = "https://files.pythonhosted.org/packages/2f/ec/8f170381a15e1eb7d93cb4feef8d17334d5a1eb33fee273aee5d1f8241a3/ruff-0.11.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d47afa45e7b0eaf5e5969c6b39cbd108be83910b5c74626247e366fd7a36a13", size = 12063261, upload-time = "2025-05-29T13:31:15.236Z" },
- { url = "https://files.pythonhosted.org/packages/0d/bf/57208f8c0a8153a14652a85f4116c0002148e83770d7a41f2e90b52d2b4e/ruff-0.11.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bf9603fe1bf949de8b09a2da896f05c01ed7a187f4a386cdba6760e7f61be", size = 11329601, upload-time = "2025-05-29T13:31:18.68Z" },
- { url = "https://files.pythonhosted.org/packages/c3/56/edf942f7fdac5888094d9ffa303f12096f1a93eb46570bcf5f14c0c70880/ruff-0.11.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08033320e979df3b20dba567c62f69c45e01df708b0f9c83912d7abd3e0801cd", size = 11522186, upload-time = "2025-05-29T13:31:21.216Z" },
- { url = "https://files.pythonhosted.org/packages/ed/63/79ffef65246911ed7e2290aeece48739d9603b3a35f9529fec0fc6c26400/ruff-0.11.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:929b7706584f5bfd61d67d5070f399057d07c70585fa8c4491d78ada452d3bef", size = 10449032, upload-time = "2025-05-29T13:31:23.417Z" },
- { url = "https://files.pythonhosted.org/packages/88/19/8c9d4d8a1c2a3f5a1ea45a64b42593d50e28b8e038f1aafd65d6b43647f3/ruff-0.11.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7de4a73205dc5756b8e09ee3ed67c38312dce1aa28972b93150f5751199981b5", size = 10129370, upload-time = "2025-05-29T13:31:25.777Z" },
- { url = "https://files.pythonhosted.org/packages/bc/0f/2d15533eaa18f460530a857e1778900cd867ded67f16c85723569d54e410/ruff-0.11.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2635c2a90ac1b8ca9e93b70af59dfd1dd2026a40e2d6eebaa3efb0465dd9cf02", size = 11123529, upload-time = "2025-05-29T13:31:28.396Z" },
- { url = "https://files.pythonhosted.org/packages/4f/e2/4c2ac669534bdded835356813f48ea33cfb3a947dc47f270038364587088/ruff-0.11.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d05d6a78a89166f03f03a198ecc9d18779076ad0eec476819467acb401028c0c", size = 11577642, upload-time = "2025-05-29T13:31:30.647Z" },
- { url = "https://files.pythonhosted.org/packages/a7/9b/c9ddf7f924d5617a1c94a93ba595f4b24cb5bc50e98b94433ab3f7ad27e5/ruff-0.11.12-py3-none-win32.whl", hash = "sha256:f5a07f49767c4be4772d161bfc049c1f242db0cfe1bd976e0f0886732a4765d6", size = 10475511, upload-time = "2025-05-29T13:31:32.917Z" },
- { url = "https://files.pythonhosted.org/packages/fd/d6/74fb6d3470c1aada019ffff33c0f9210af746cca0a4de19a1f10ce54968a/ruff-0.11.12-py3-none-win_amd64.whl", hash = "sha256:5a4d9f8030d8c3a45df201d7fb3ed38d0219bccd7955268e863ee4a115fa0832", size = 11523573, upload-time = "2025-05-29T13:31:35.782Z" },
- { url = "https://files.pythonhosted.org/packages/44/42/d58086ec20f52d2b0140752ae54b355ea2be2ed46f914231136dd1effcc7/ruff-0.11.12-py3-none-win_arm64.whl", hash = "sha256:65194e37853158d368e333ba282217941029a28ea90913c67e558c611d04daa5", size = 10697770, upload-time = "2025-05-29T13:31:38.009Z" },
+version = "0.12.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/2a/43955b530c49684d3c38fcda18c43caf91e99204c2a065552528e0552d4f/ruff-0.12.3.tar.gz", hash = "sha256:f1b5a4b6668fd7b7ea3697d8d98857390b40c1320a63a178eee6be0899ea2d77", size = 4459341, upload-time = "2025-07-11T13:21:16.086Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e2/fd/b44c5115539de0d598d75232a1cc7201430b6891808df111b8b0506aae43/ruff-0.12.3-py3-none-linux_armv6l.whl", hash = "sha256:47552138f7206454eaf0c4fe827e546e9ddac62c2a3d2585ca54d29a890137a2", size = 10430499, upload-time = "2025-07-11T13:20:26.321Z" },
+ { url = "https://files.pythonhosted.org/packages/43/c5/9eba4f337970d7f639a37077be067e4ec80a2ad359e4cc6c5b56805cbc66/ruff-0.12.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a9153b000c6fe169bb307f5bd1b691221c4286c133407b8827c406a55282041", size = 11213413, upload-time = "2025-07-11T13:20:30.017Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/2c/fac3016236cf1fe0bdc8e5de4f24c76ce53c6dd9b5f350d902549b7719b2/ruff-0.12.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa6b24600cf3b750e48ddb6057e901dd5b9aa426e316addb2a1af185a7509882", size = 10586941, upload-time = "2025-07-11T13:20:33.046Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/0f/41fec224e9dfa49a139f0b402ad6f5d53696ba1800e0f77b279d55210ca9/ruff-0.12.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2506961bf6ead54887ba3562604d69cb430f59b42133d36976421bc8bd45901", size = 10783001, upload-time = "2025-07-11T13:20:35.534Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/ca/dd64a9ce56d9ed6cad109606ac014860b1c217c883e93bf61536400ba107/ruff-0.12.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4faaff1f90cea9d3033cbbcdf1acf5d7fb11d8180758feb31337391691f3df0", size = 10269641, upload-time = "2025-07-11T13:20:38.459Z" },
+ { url = "https://files.pythonhosted.org/packages/63/5c/2be545034c6bd5ce5bb740ced3e7014d7916f4c445974be11d2a406d5088/ruff-0.12.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40dced4a79d7c264389de1c59467d5d5cefd79e7e06d1dfa2c75497b5269a5a6", size = 11875059, upload-time = "2025-07-11T13:20:41.517Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/d4/a74ef1e801ceb5855e9527dae105eaff136afcb9cc4d2056d44feb0e4792/ruff-0.12.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0262d50ba2767ed0fe212aa7e62112a1dcbfd46b858c5bf7bbd11f326998bafc", size = 12658890, upload-time = "2025-07-11T13:20:44.442Z" },
+ { url = "https://files.pythonhosted.org/packages/13/c8/1057916416de02e6d7c9bcd550868a49b72df94e3cca0aeb77457dcd9644/ruff-0.12.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12371aec33e1a3758597c5c631bae9a5286f3c963bdfb4d17acdd2d395406687", size = 12232008, upload-time = "2025-07-11T13:20:47.374Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/59/4f7c130cc25220392051fadfe15f63ed70001487eca21d1796db46cbcc04/ruff-0.12.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:560f13b6baa49785665276c963edc363f8ad4b4fc910a883e2625bdb14a83a9e", size = 11499096, upload-time = "2025-07-11T13:20:50.348Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/01/a0ad24a5d2ed6be03a312e30d32d4e3904bfdbc1cdbe63c47be9d0e82c79/ruff-0.12.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023040a3499f6f974ae9091bcdd0385dd9e9eb4942f231c23c57708147b06311", size = 11688307, upload-time = "2025-07-11T13:20:52.945Z" },
+ { url = "https://files.pythonhosted.org/packages/93/72/08f9e826085b1f57c9a0226e48acb27643ff19b61516a34c6cab9d6ff3fa/ruff-0.12.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:883d844967bffff5ab28bba1a4d246c1a1b2933f48cb9840f3fdc5111c603b07", size = 10661020, upload-time = "2025-07-11T13:20:55.799Z" },
+ { url = "https://files.pythonhosted.org/packages/80/a0/68da1250d12893466c78e54b4a0ff381370a33d848804bb51279367fc688/ruff-0.12.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2120d3aa855ff385e0e562fdee14d564c9675edbe41625c87eeab744a7830d12", size = 10246300, upload-time = "2025-07-11T13:20:58.222Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/22/5f0093d556403e04b6fd0984fc0fb32fbb6f6ce116828fd54306a946f444/ruff-0.12.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6b16647cbb470eaf4750d27dddc6ebf7758b918887b56d39e9c22cce2049082b", size = 11263119, upload-time = "2025-07-11T13:21:01.503Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c9/f4c0b69bdaffb9968ba40dd5fa7df354ae0c73d01f988601d8fac0c639b1/ruff-0.12.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e1417051edb436230023575b149e8ff843a324557fe0a265863b7602df86722f", size = 11746990, upload-time = "2025-07-11T13:21:04.524Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/84/7cc7bd73924ee6be4724be0db5414a4a2ed82d06b30827342315a1be9e9c/ruff-0.12.3-py3-none-win32.whl", hash = "sha256:dfd45e6e926deb6409d0616078a666ebce93e55e07f0fb0228d4b2608b2c248d", size = 10589263, upload-time = "2025-07-11T13:21:07.148Z" },
+ { url = "https://files.pythonhosted.org/packages/07/87/c070f5f027bd81f3efee7d14cb4d84067ecf67a3a8efb43aadfc72aa79a6/ruff-0.12.3-py3-none-win_amd64.whl", hash = "sha256:a946cf1e7ba3209bdef039eb97647f1c77f6f540e5845ec9c114d3af8df873e7", size = 11695072, upload-time = "2025-07-11T13:21:11.004Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/30/f3eaf6563c637b6e66238ed6535f6775480db973c836336e4122161986fc/ruff-0.12.3-py3-none-win_arm64.whl", hash = "sha256:5f9c7c9c8f84c2d7f27e93674d27136fbf489720251544c4da7fb3d742e011b1", size = 10805855, upload-time = "2025-07-11T13:21:13.547Z" },
]
[[package]]
@@ -5104,14 +5147,28 @@ wheels = [
[[package]]
name = "scipy-stubs"
-version = "1.15.3.0"
+version = "1.16.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "optype" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/35c43bd7d412add4adcd68475702571b2489b50c40b6564f808b2355e452/scipy_stubs-1.15.3.0.tar.gz", hash = "sha256:e8f76c9887461cf9424c1e2ad78ea5dac71dd4cbb383dc85f91adfe8f74d1e17", size = 275699, upload-time = "2025-05-08T16:58:35.139Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4b/19/a8461383f7328300e83c34f58bf38ccc05f57c2289c0e54e2bea757de83c/scipy_stubs-1.16.0.2.tar.gz", hash = "sha256:f83aacaf2e899d044de6483e6112bf7a1942d683304077bc9e78cf6f21353acd", size = 306747, upload-time = "2025-07-01T23:19:04.513Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8f/30/b73418e6d3d8209fef684841d9a0e5b439d3528fa341a23b632fe47918dd/scipy_stubs-1.16.0.2-py3-none-any.whl", hash = "sha256:dc364d24a3accd1663e7576480bdb720533f94de8a05590354ff6d4a83d765c7", size = 491346, upload-time = "2025-07-01T23:19:03.222Z" },
+]
+
+[[package]]
+name = "sendgrid"
+version = "6.12.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "ecdsa" },
+ { name = "python-http-client" },
+ { name = "werkzeug" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/11/31/62e00433878dccf33edf07f8efa417b9030a2464eb3b04bbd797a11b4447/sendgrid-6.12.4.tar.gz", hash = "sha256:9e88b849daf0fa4bdf256c3b5da9f5a3272402c0c2fd6b1928c9de440db0a03d", size = 50271, upload-time = "2025-06-12T10:29:37.213Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6c/42/cd8dc81f8060de1f14960885ad5b2d2651f41de8b93d09f3f919d6567a5a/scipy_stubs-1.15.3.0-py3-none-any.whl", hash = "sha256:a251254cf4fd6e7fb87c55c1feee92d32ddbc1f542ecdf6a0159cdb81c2fb62d", size = 459062, upload-time = "2025-05-08T16:58:33.356Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/9c/45d068fd831a65e6ed1e2ab3233de58784842afdc62fdcdd0a01bbb6b39d/sendgrid-6.12.4-py3-none-any.whl", hash = "sha256:9a211b96241e63bd5b9ed9afcc8608f4bcac426e4a319b3920ab877c8426e92c", size = 102122, upload-time = "2025-06-12T10:29:35.457Z" },
]
[[package]]
@@ -5134,38 +5191,6 @@ flask = [
{ name = "markupsafe" },
]
-[[package]]
-name = "setproctitle"
-version = "1.3.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9e/af/56efe21c53ac81ac87e000b15e60b3d8104224b4313b6eacac3597bd183d/setproctitle-1.3.6.tar.gz", hash = "sha256:c9f32b96c700bb384f33f7cf07954bb609d35dd82752cef57fb2ee0968409169", size = 26889, upload-time = "2025-04-29T13:35:00.184Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/27/3b/8288d0cd969a63500dd62fc2c99ce6980f9909ccef0770ab1f86c361e0bf/setproctitle-1.3.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a1d856b0f4e4a33e31cdab5f50d0a14998f3a2d726a3fd5cb7c4d45a57b28d1b", size = 17412, upload-time = "2025-04-29T13:32:58.135Z" },
- { url = "https://files.pythonhosted.org/packages/39/37/43a5a3e25ca1048dbbf4db0d88d346226f5f1acd131bb8e660f4bfe2799f/setproctitle-1.3.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:50706b9c0eda55f7de18695bfeead5f28b58aa42fd5219b3b1692d554ecbc9ec", size = 11963, upload-time = "2025-04-29T13:32:59.17Z" },
- { url = "https://files.pythonhosted.org/packages/5b/47/f103c40e133154783c91a10ab08ac9fc410ed835aa85bcf7107cb882f505/setproctitle-1.3.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af188f3305f0a65c3217c30c6d4c06891e79144076a91e8b454f14256acc7279", size = 31718, upload-time = "2025-04-29T13:33:00.36Z" },
- { url = "https://files.pythonhosted.org/packages/1f/13/7325dd1c008dd6c0ebd370ddb7505977054a87e406f142318e395031a792/setproctitle-1.3.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cce0ed8b3f64c71c140f0ec244e5fdf8ecf78ddf8d2e591d4a8b6aa1c1214235", size = 33027, upload-time = "2025-04-29T13:33:01.499Z" },
- { url = "https://files.pythonhosted.org/packages/0c/0a/6075bfea05a71379d77af98a9ac61163e8b6e5ef1ae58cd2b05871b2079c/setproctitle-1.3.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:70100e2087fe05359f249a0b5f393127b3a1819bf34dec3a3e0d4941138650c9", size = 30223, upload-time = "2025-04-29T13:33:03.259Z" },
- { url = "https://files.pythonhosted.org/packages/cc/41/fbf57ec52f4f0776193bd94334a841f0bc9d17e745f89c7790f336420c65/setproctitle-1.3.6-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1065ed36bd03a3fd4186d6c6de5f19846650b015789f72e2dea2d77be99bdca1", size = 31204, upload-time = "2025-04-29T13:33:04.455Z" },
- { url = "https://files.pythonhosted.org/packages/97/b5/f799fb7a00de29fb0ac1dfd015528dea425b9e31a8f1068a0b3df52d317f/setproctitle-1.3.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4adf6a0013fe4e0844e3ba7583ec203ca518b9394c6cc0d3354df2bf31d1c034", size = 31181, upload-time = "2025-04-29T13:33:05.697Z" },
- { url = "https://files.pythonhosted.org/packages/b5/b7/81f101b612014ec61723436022c31146178813d6ca6b947f7b9c84e9daf4/setproctitle-1.3.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb7452849f6615871eabed6560ffedfe56bc8af31a823b6be4ce1e6ff0ab72c5", size = 30101, upload-time = "2025-04-29T13:33:07.223Z" },
- { url = "https://files.pythonhosted.org/packages/67/23/681232eed7640eab96719daa8647cc99b639e3daff5c287bd270ef179a73/setproctitle-1.3.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a094b7ce455ca341b59a0f6ce6be2e11411ba6e2860b9aa3dbb37468f23338f4", size = 32438, upload-time = "2025-04-29T13:33:08.538Z" },
- { url = "https://files.pythonhosted.org/packages/19/f8/4d075a7bdc3609ac71535b849775812455e4c40aedfbf0778a6f123b1774/setproctitle-1.3.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ad1c2c2baaba62823a7f348f469a967ece0062140ca39e7a48e4bbb1f20d54c4", size = 30625, upload-time = "2025-04-29T13:33:09.707Z" },
- { url = "https://files.pythonhosted.org/packages/5f/73/a2a8259ebee166aee1ca53eead75de0e190b3ddca4f716e5c7470ebb7ef6/setproctitle-1.3.6-cp311-cp311-win32.whl", hash = "sha256:8050c01331135f77ec99d99307bfbc6519ea24d2f92964b06f3222a804a3ff1f", size = 11488, upload-time = "2025-04-29T13:33:10.953Z" },
- { url = "https://files.pythonhosted.org/packages/c9/15/52cf5e1ff0727d53704cfdde2858eaf237ce523b0b04db65faa84ff83e13/setproctitle-1.3.6-cp311-cp311-win_amd64.whl", hash = "sha256:9b73cf0fe28009a04a35bb2522e4c5b5176cc148919431dcb73fdbdfaab15781", size = 12201, upload-time = "2025-04-29T13:33:12.389Z" },
- { url = "https://files.pythonhosted.org/packages/8f/fb/99456fd94d4207c5f6c40746a048a33a52b4239cd7d9c8d4889e2210ec82/setproctitle-1.3.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:af44bb7a1af163806bbb679eb8432fa7b4fb6d83a5d403b541b675dcd3798638", size = 17399, upload-time = "2025-04-29T13:33:13.406Z" },
- { url = "https://files.pythonhosted.org/packages/d5/48/9699191fe6062827683c43bfa9caac33a2c89f8781dd8c7253fa3dba85fd/setproctitle-1.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3cca16fd055316a48f0debfcbfb6af7cea715429fc31515ab3fcac05abd527d8", size = 11966, upload-time = "2025-04-29T13:33:14.976Z" },
- { url = "https://files.pythonhosted.org/packages/33/03/b085d192b9ecb9c7ce6ad6ef30ecf4110b7f39430b58a56245569827fcf4/setproctitle-1.3.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea002088d5554fd75e619742cefc78b84a212ba21632e59931b3501f0cfc8f67", size = 32017, upload-time = "2025-04-29T13:33:16.163Z" },
- { url = "https://files.pythonhosted.org/packages/ae/68/c53162e645816f97212002111420d1b2f75bf6d02632e37e961dc2cd6d8b/setproctitle-1.3.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb465dd5825356c1191a038a86ee1b8166e3562d6e8add95eec04ab484cfb8a2", size = 33419, upload-time = "2025-04-29T13:33:18.239Z" },
- { url = "https://files.pythonhosted.org/packages/ac/0d/119a45d15a816a6cf5ccc61b19729f82620095b27a47e0a6838216a95fae/setproctitle-1.3.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d2c8e20487b3b73c1fa72c56f5c89430617296cd380373e7af3a538a82d4cd6d", size = 30711, upload-time = "2025-04-29T13:33:19.571Z" },
- { url = "https://files.pythonhosted.org/packages/e3/fb/5e9b5068df9e9f31a722a775a5e8322a29a638eaaa3eac5ea7f0b35e6314/setproctitle-1.3.6-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0d6252098e98129a1decb59b46920d4eca17b0395f3d71b0d327d086fefe77d", size = 31742, upload-time = "2025-04-29T13:33:21.172Z" },
- { url = "https://files.pythonhosted.org/packages/35/88/54de1e73e8fce87d587889c7eedb48fc4ee2bbe4e4ca6331690d03024f86/setproctitle-1.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cf355fbf0d4275d86f9f57be705d8e5eaa7f8ddb12b24ced2ea6cbd68fdb14dc", size = 31925, upload-time = "2025-04-29T13:33:22.427Z" },
- { url = "https://files.pythonhosted.org/packages/f3/01/65948d7badd66e63e3db247b923143da142790fa293830fdecf832712c2d/setproctitle-1.3.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e288f8a162d663916060beb5e8165a8551312b08efee9cf68302687471a6545d", size = 30981, upload-time = "2025-04-29T13:33:23.739Z" },
- { url = "https://files.pythonhosted.org/packages/22/20/c495e61786f1d38d5dc340b9d9077fee9be3dfc7e89f515afe12e1526dbc/setproctitle-1.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b2e54f4a2dc6edf0f5ea5b1d0a608d2af3dcb5aa8c8eeab9c8841b23e1b054fe", size = 33209, upload-time = "2025-04-29T13:33:24.915Z" },
- { url = "https://files.pythonhosted.org/packages/98/3f/a457b8550fbd34d5b482fe20b8376b529e76bf1fbf9a474a6d9a641ab4ad/setproctitle-1.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b6f4abde9a2946f57e8daaf1160b2351bcf64274ef539e6675c1d945dbd75e2a", size = 31587, upload-time = "2025-04-29T13:33:26.123Z" },
- { url = "https://files.pythonhosted.org/packages/44/fe/743517340e5a635e3f1c4310baea20c16c66202f96a6f4cead222ffd6d84/setproctitle-1.3.6-cp312-cp312-win32.whl", hash = "sha256:db608db98ccc21248370d30044a60843b3f0f3d34781ceeea67067c508cd5a28", size = 11487, upload-time = "2025-04-29T13:33:27.403Z" },
- { url = "https://files.pythonhosted.org/packages/60/9a/d88f1c1f0f4efff1bd29d9233583ee341114dda7d9613941453984849674/setproctitle-1.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:082413db8a96b1f021088e8ec23f0a61fec352e649aba20881895815388b66d3", size = 12208, upload-time = "2025-04-29T13:33:28.852Z" },
-]
-
[[package]]
name = "setuptools"
version = "80.9.0"
@@ -5247,6 +5272,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" },
]
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
+]
+
[[package]]
name = "soupsieve"
version = "2.7"
@@ -5258,40 +5292,40 @@ wheels = [
[[package]]
name = "sqlalchemy"
-version = "2.0.35"
+version = "2.0.41"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/36/48/4f190a83525f5cefefa44f6adc9e6386c4de5218d686c27eda92eb1f5424/sqlalchemy-2.0.35.tar.gz", hash = "sha256:e11d7ea4d24f0a262bccf9a7cd6284c976c5369dac21db237cff59586045ab9f", size = 9562798, upload-time = "2024-09-16T20:30:05.964Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c3/46/9215a35bf98c3a2528e987791e6180eb51624d2c7d5cb8e2d96a6450b657/SQLAlchemy-2.0.35-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e21f66748ab725ade40fa7af8ec8b5019c68ab00b929f6643e1b1af461eddb60", size = 2091274, upload-time = "2024-09-16T21:07:13.344Z" },
- { url = "https://files.pythonhosted.org/packages/1e/69/919673c5101a0c633658d58b11b454b251ca82300941fba801201434755d/SQLAlchemy-2.0.35-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8a6219108a15fc6d24de499d0d515c7235c617b2540d97116b663dade1a54d62", size = 2081672, upload-time = "2024-09-16T21:07:14.807Z" },
- { url = "https://files.pythonhosted.org/packages/67/ea/a6b0597cbda12796be2302153369dbbe90573fdab3bc4885f8efac499247/SQLAlchemy-2.0.35-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:042622a5306c23b972192283f4e22372da3b8ddf5f7aac1cc5d9c9b222ab3ff6", size = 3200083, upload-time = "2024-09-16T22:45:15.766Z" },
- { url = "https://files.pythonhosted.org/packages/8c/d6/97bdc8d714fb21762f2092511f380f18cdb2d985d516071fa925bb433a90/SQLAlchemy-2.0.35-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:627dee0c280eea91aed87b20a1f849e9ae2fe719d52cbf847c0e0ea34464b3f7", size = 3200080, upload-time = "2024-09-16T21:18:19.033Z" },
- { url = "https://files.pythonhosted.org/packages/87/d2/8c2adaf2ade4f6f1b725acd0b0be9210bb6a2df41024729a8eec6a86fe5a/SQLAlchemy-2.0.35-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4fdcd72a789c1c31ed242fd8c1bcd9ea186a98ee8e5408a50e610edfef980d71", size = 3137108, upload-time = "2024-09-16T22:45:19.167Z" },
- { url = "https://files.pythonhosted.org/packages/7e/ae/ea05d0bfa8f2b25ae34591895147152854fc950f491c4ce362ae06035db8/SQLAlchemy-2.0.35-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89b64cd8898a3a6f642db4eb7b26d1b28a497d4022eccd7717ca066823e9fb01", size = 3157437, upload-time = "2024-09-16T21:18:21.988Z" },
- { url = "https://files.pythonhosted.org/packages/fe/5d/8ad6df01398388a766163d27960b3365f1bbd8bb7b05b5cad321a8b69b25/SQLAlchemy-2.0.35-cp311-cp311-win32.whl", hash = "sha256:6a93c5a0dfe8d34951e8a6f499a9479ffb9258123551fa007fc708ae2ac2bc5e", size = 2061935, upload-time = "2024-09-16T20:54:10.564Z" },
- { url = "https://files.pythonhosted.org/packages/ff/68/8557efc0c32c8e2c147cb6512237448b8ed594a57cd015fda67f8e56bb3f/SQLAlchemy-2.0.35-cp311-cp311-win_amd64.whl", hash = "sha256:c68fe3fcde03920c46697585620135b4ecfdfc1ed23e75cc2c2ae9f8502c10b8", size = 2087281, upload-time = "2024-09-16T20:54:13.429Z" },
- { url = "https://files.pythonhosted.org/packages/2f/2b/fff87e6db0da31212c98bbc445f83fb608ea92b96bda3f3f10e373bac76c/SQLAlchemy-2.0.35-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:eb60b026d8ad0c97917cb81d3662d0b39b8ff1335e3fabb24984c6acd0c900a2", size = 2089790, upload-time = "2024-09-16T21:07:16.161Z" },
- { url = "https://files.pythonhosted.org/packages/68/92/4bb761bd82764d5827bf6b6095168c40fb5dbbd23670203aef2f96ba6bc6/SQLAlchemy-2.0.35-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6921ee01caf375363be5e9ae70d08ce7ca9d7e0e8983183080211a062d299468", size = 2080266, upload-time = "2024-09-16T21:07:18.277Z" },
- { url = "https://files.pythonhosted.org/packages/22/46/068a65db6dc253c6f25a7598d99e0a1d60b14f661f9d09ef6c73c718fa4e/SQLAlchemy-2.0.35-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8cdf1a0dbe5ced887a9b127da4ffd7354e9c1a3b9bb330dce84df6b70ccb3a8d", size = 3229760, upload-time = "2024-09-16T22:45:20.863Z" },
- { url = "https://files.pythonhosted.org/packages/6e/36/59830dafe40dda592304debd4cd86e583f63472f3a62c9e2695a5795e786/SQLAlchemy-2.0.35-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93a71c8601e823236ac0e5d087e4f397874a421017b3318fd92c0b14acf2b6db", size = 3240649, upload-time = "2024-09-16T21:18:23.996Z" },
- { url = "https://files.pythonhosted.org/packages/00/50/844c50c6996f9c7f000c959dd1a7436a6c94e449ee113046a1d19e470089/SQLAlchemy-2.0.35-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e04b622bb8a88f10e439084486f2f6349bf4d50605ac3e445869c7ea5cf0fa8c", size = 3176138, upload-time = "2024-09-16T22:45:22.518Z" },
- { url = "https://files.pythonhosted.org/packages/df/d2/336b18cac68eecb67de474fc15c85f13be4e615c6f5bae87ea38c6734ce0/SQLAlchemy-2.0.35-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1b56961e2d31389aaadf4906d453859f35302b4eb818d34a26fab72596076bb8", size = 3202753, upload-time = "2024-09-16T21:18:25.966Z" },
- { url = "https://files.pythonhosted.org/packages/f0/f3/ee1e62fabdc10910b5ef720ae08e59bc785f26652876af3a50b89b97b412/SQLAlchemy-2.0.35-cp312-cp312-win32.whl", hash = "sha256:0f9f3f9a3763b9c4deb8c5d09c4cc52ffe49f9876af41cc1b2ad0138878453cf", size = 2060113, upload-time = "2024-09-16T20:54:15.16Z" },
- { url = "https://files.pythonhosted.org/packages/60/63/a3cef44a52979169d884f3583d0640e64b3c28122c096474a1d7cfcaf1f3/SQLAlchemy-2.0.35-cp312-cp312-win_amd64.whl", hash = "sha256:25b0f63e7fcc2a6290cb5f7f5b4fc4047843504983a28856ce9b35d8f7de03cc", size = 2085839, upload-time = "2024-09-16T20:54:17.11Z" },
- { url = "https://files.pythonhosted.org/packages/0e/c6/33c706449cdd92b1b6d756b247761e27d32230fd6b2de5f44c4c3e5632b2/SQLAlchemy-2.0.35-py3-none-any.whl", hash = "sha256:2ab3f0336c0387662ce6221ad30ab3a5e6499aab01b9790879b6578fd9b8faa1", size = 1881276, upload-time = "2024-09-16T23:14:28.324Z" },
-]
-
-[[package]]
-name = "sqlglot"
-version = "26.24.0"
+sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424, upload-time = "2025-05-14T17:10:32.339Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/37/4e/b00e3ffae32b74b5180e15d2ab4040531ee1bef4c19755fe7926622dc958/sqlalchemy-2.0.41-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6375cd674fe82d7aa9816d1cb96ec592bac1726c11e0cafbf40eeee9a4516b5f", size = 2121232, upload-time = "2025-05-14T17:48:20.444Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/30/6547ebb10875302074a37e1970a5dce7985240665778cfdee2323709f749/sqlalchemy-2.0.41-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f8c9fdd15a55d9465e590a402f42082705d66b05afc3ffd2d2eb3c6ba919560", size = 2110897, upload-time = "2025-05-14T17:48:21.634Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/21/59df2b41b0f6c62da55cd64798232d7349a9378befa7f1bb18cf1dfd510a/sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32f9dc8c44acdee06c8fc6440db9eae8b4af8b01e4b1aee7bdd7241c22edff4f", size = 3273313, upload-time = "2025-05-14T17:51:56.205Z" },
+ { url = "https://files.pythonhosted.org/packages/62/e4/b9a7a0e5c6f79d49bcd6efb6e90d7536dc604dab64582a9dec220dab54b6/sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c11ceb9a1f482c752a71f203a81858625d8df5746d787a4786bca4ffdf71c6", size = 3273807, upload-time = "2025-05-14T17:55:26.928Z" },
+ { url = "https://files.pythonhosted.org/packages/39/d8/79f2427251b44ddee18676c04eab038d043cff0e764d2d8bb08261d6135d/sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:911cc493ebd60de5f285bcae0491a60b4f2a9f0f5c270edd1c4dbaef7a38fc04", size = 3209632, upload-time = "2025-05-14T17:51:59.384Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/16/730a82dda30765f63e0454918c982fb7193f6b398b31d63c7c3bd3652ae5/sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03968a349db483936c249f4d9cd14ff2c296adfa1290b660ba6516f973139582", size = 3233642, upload-time = "2025-05-14T17:55:29.901Z" },
+ { url = "https://files.pythonhosted.org/packages/04/61/c0d4607f7799efa8b8ea3c49b4621e861c8f5c41fd4b5b636c534fcb7d73/sqlalchemy-2.0.41-cp311-cp311-win32.whl", hash = "sha256:293cd444d82b18da48c9f71cd7005844dbbd06ca19be1ccf6779154439eec0b8", size = 2086475, upload-time = "2025-05-14T17:56:02.095Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8e/8344f8ae1cb6a479d0741c02cd4f666925b2bf02e2468ddaf5ce44111f30/sqlalchemy-2.0.41-cp311-cp311-win_amd64.whl", hash = "sha256:3d3549fc3e40667ec7199033a4e40a2f669898a00a7b18a931d3efb4c7900504", size = 2110903, upload-time = "2025-05-14T17:56:03.499Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9", size = 2119645, upload-time = "2025-05-14T17:55:24.854Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1", size = 2107399, upload-time = "2025-05-14T17:55:28.097Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70", size = 3293269, upload-time = "2025-05-14T17:50:38.227Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e", size = 3303364, upload-time = "2025-05-14T17:51:49.829Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078", size = 3229072, upload-time = "2025-05-14T17:50:39.774Z" },
+ { url = "https://files.pythonhosted.org/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074, upload-time = "2025-05-14T17:51:51.736Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514, upload-time = "2025-05-14T17:55:49.915Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557, upload-time = "2025-05-14T17:55:51.349Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224, upload-time = "2025-05-14T17:39:42.154Z" },
+]
+
+[[package]]
+name = "sseclient-py"
+version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4d/f7/0fa9f9f2477c4e3d8e28b0f5e066f0e72343c29c8a302ee6a77579e8986b/sqlglot-26.24.0.tar.gz", hash = "sha256:e778ca9cb685b4fc34b59d50432c20f463c63ec90d0448fa91afa7f320a88518", size = 5371208, upload-time = "2025-05-30T08:44:06.516Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/ed/3df5ab8bb0c12f86c28d0cadb11ed1de44a92ed35ce7ff4fd5518a809325/sseclient-py-1.8.0.tar.gz", hash = "sha256:c547c5c1a7633230a38dc599a21a2dc638f9b5c297286b48b46b935c71fac3e8", size = 7791, upload-time = "2023-09-01T19:39:20.45Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b2/11/6995759d913d714ff443478f5865b2616dbcd32b12764d02df9550d7a61e/sqlglot-26.24.0-py3-none-any.whl", hash = "sha256:81f7e47bb1b4b396c564359f47c7c1aee476575a0cadf84dc35f7189cab87f82", size = 464043, upload-time = "2025-05-30T08:44:00.801Z" },
+ { url = "https://files.pythonhosted.org/packages/49/58/97655efdfeb5b4eeab85b1fc5d3fa1023661246c2ab2a26ea8e47402d4f2/sseclient_py-1.8.0-py2.py3-none-any.whl", hash = "sha256:4ecca6dc0b9f963f8384e9d7fd529bf93dd7d708144c4fb5da0e0a1a926fee83", size = 8828, upload-time = "2023-09-01T19:39:17.627Z" },
]
[[package]]
@@ -5364,12 +5398,11 @@ wheels = [
[[package]]
name = "tablestore"
-version = "6.1.0"
+version = "6.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "crc32c" },
- { name = "enum34" },
{ name = "flatbuffers" },
{ name = "future" },
{ name = "numpy" },
@@ -5377,7 +5410,10 @@ dependencies = [
{ name = "six" },
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/0f/ed/5bdd906ec9d2dbae3909525dbb7602558c377e0cbcdddb6405d2d0d3f1af/tablestore-6.1.0.tar.gz", hash = "sha256:bfe6a3e0fe88a230729723c357f4a46b8869a06a4b936db20692ed587a721c1c", size = 135690, upload-time = "2024-12-20T07:38:37.428Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/58/48d65d181a69f7db19f7cdee01d252168fbfbad2d1bb25abed03e6df3b05/tablestore-6.2.0.tar.gz", hash = "sha256:0773e77c00542be1bfebbc3c7a85f72a881c63e4e7df7c5a9793a54144590e68", size = 85942, upload-time = "2025-04-15T12:11:20.655Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9c/da/30451712a769bcf417add8e81163d478a4d668b0e8d489a9d667260d55df/tablestore-6.2.0-py3-none-any.whl", hash = "sha256:6af496d841ab1ff3f78b46abbd87b95a08d89605c51664d2b30933b1d1c5583a", size = 106297, upload-time = "2025-04-15T12:11:17.476Z" },
+]
[[package]]
name = "tabulate"
@@ -5470,27 +5506,27 @@ wheels = [
[[package]]
name = "tokenizers"
-version = "0.21.1"
+version = "0.21.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/92/76/5ac0c97f1117b91b7eb7323dcd61af80d72f790b4df71249a7850c195f30/tokenizers-0.21.1.tar.gz", hash = "sha256:a1bb04dc5b448985f86ecd4b05407f5a8d97cb2c0532199b2a302a604a0165ab", size = 343256, upload-time = "2025-03-13T10:51:18.189Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ab/2d/b0fce2b8201635f60e8c95990080f58461cc9ca3d5026de2e900f38a7f21/tokenizers-0.21.2.tar.gz", hash = "sha256:fdc7cffde3e2113ba0e6cc7318c40e3438a4d74bbc62bf04bcc63bdfb082ac77", size = 351545, upload-time = "2025-06-24T10:24:52.449Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a5/1f/328aee25f9115bf04262e8b4e5a2050b7b7cf44b59c74e982db7270c7f30/tokenizers-0.21.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e78e413e9e668ad790a29456e677d9d3aa50a9ad311a40905d6861ba7692cf41", size = 2780767, upload-time = "2025-03-13T10:51:09.459Z" },
- { url = "https://files.pythonhosted.org/packages/ae/1a/4526797f3719b0287853f12c5ad563a9be09d446c44ac784cdd7c50f76ab/tokenizers-0.21.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:cd51cd0a91ecc801633829fcd1fda9cf8682ed3477c6243b9a095539de4aecf3", size = 2650555, upload-time = "2025-03-13T10:51:07.692Z" },
- { url = "https://files.pythonhosted.org/packages/4d/7a/a209b29f971a9fdc1da86f917fe4524564924db50d13f0724feed37b2a4d/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28da6b72d4fb14ee200a1bd386ff74ade8992d7f725f2bde2c495a9a98cf4d9f", size = 2937541, upload-time = "2025-03-13T10:50:56.679Z" },
- { url = "https://files.pythonhosted.org/packages/3c/1e/b788b50ffc6191e0b1fc2b0d49df8cff16fe415302e5ceb89f619d12c5bc/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:34d8cfde551c9916cb92014e040806122295a6800914bab5865deb85623931cf", size = 2819058, upload-time = "2025-03-13T10:50:59.525Z" },
- { url = "https://files.pythonhosted.org/packages/36/aa/3626dfa09a0ecc5b57a8c58eeaeb7dd7ca9a37ad9dd681edab5acd55764c/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aaa852d23e125b73d283c98f007e06d4595732104b65402f46e8ef24b588d9f8", size = 3133278, upload-time = "2025-03-13T10:51:04.678Z" },
- { url = "https://files.pythonhosted.org/packages/a4/4d/8fbc203838b3d26269f944a89459d94c858f5b3f9a9b6ee9728cdcf69161/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a21a15d5c8e603331b8a59548bbe113564136dc0f5ad8306dd5033459a226da0", size = 3144253, upload-time = "2025-03-13T10:51:01.261Z" },
- { url = "https://files.pythonhosted.org/packages/d8/1b/2bd062adeb7c7511b847b32e356024980c0ffcf35f28947792c2d8ad2288/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2fdbd4c067c60a0ac7eca14b6bd18a5bebace54eb757c706b47ea93204f7a37c", size = 3398225, upload-time = "2025-03-13T10:51:03.243Z" },
- { url = "https://files.pythonhosted.org/packages/8a/63/38be071b0c8e06840bc6046991636bcb30c27f6bb1e670f4f4bc87cf49cc/tokenizers-0.21.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dd9a0061e403546f7377df940e866c3e678d7d4e9643d0461ea442b4f89e61a", size = 3038874, upload-time = "2025-03-13T10:51:06.235Z" },
- { url = "https://files.pythonhosted.org/packages/ec/83/afa94193c09246417c23a3c75a8a0a96bf44ab5630a3015538d0c316dd4b/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:db9484aeb2e200c43b915a1a0150ea885e35f357a5a8fabf7373af333dcc8dbf", size = 9014448, upload-time = "2025-03-13T10:51:10.927Z" },
- { url = "https://files.pythonhosted.org/packages/ae/b3/0e1a37d4f84c0f014d43701c11eb8072704f6efe8d8fc2dcdb79c47d76de/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ed248ab5279e601a30a4d67bdb897ecbe955a50f1e7bb62bd99f07dd11c2f5b6", size = 8937877, upload-time = "2025-03-13T10:51:12.688Z" },
- { url = "https://files.pythonhosted.org/packages/ac/33/ff08f50e6d615eb180a4a328c65907feb6ded0b8f990ec923969759dc379/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:9ac78b12e541d4ce67b4dfd970e44c060a2147b9b2a21f509566d556a509c67d", size = 9186645, upload-time = "2025-03-13T10:51:14.723Z" },
- { url = "https://files.pythonhosted.org/packages/5f/aa/8ae85f69a9f6012c6f8011c6f4aa1c96154c816e9eea2e1b758601157833/tokenizers-0.21.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e5a69c1a4496b81a5ee5d2c1f3f7fbdf95e90a0196101b0ee89ed9956b8a168f", size = 9384380, upload-time = "2025-03-13T10:51:16.526Z" },
- { url = "https://files.pythonhosted.org/packages/e8/5b/a5d98c89f747455e8b7a9504910c865d5e51da55e825a7ae641fb5ff0a58/tokenizers-0.21.1-cp39-abi3-win32.whl", hash = "sha256:1039a3a5734944e09de1d48761ade94e00d0fa760c0e0551151d4dd851ba63e3", size = 2239506, upload-time = "2025-03-13T10:51:20.643Z" },
- { url = "https://files.pythonhosted.org/packages/e6/b6/072a8e053ae600dcc2ac0da81a23548e3b523301a442a6ca900e92ac35be/tokenizers-0.21.1-cp39-abi3-win_amd64.whl", hash = "sha256:0f0dcbcc9f6e13e675a66d7a5f2f225a736745ce484c1a4e07476a89ccdad382", size = 2435481, upload-time = "2025-03-13T10:51:19.243Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/cc/2936e2d45ceb130a21d929743f1e9897514691bec123203e10837972296f/tokenizers-0.21.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:342b5dfb75009f2255ab8dec0041287260fed5ce00c323eb6bab639066fef8ec", size = 2875206, upload-time = "2025-06-24T10:24:42.755Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/e6/33f41f2cc7861faeba8988e7a77601407bf1d9d28fc79c5903f8f77df587/tokenizers-0.21.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:126df3205d6f3a93fea80c7a8a266a78c1bd8dd2fe043386bafdd7736a23e45f", size = 2732655, upload-time = "2025-06-24T10:24:41.56Z" },
+ { url = "https://files.pythonhosted.org/packages/33/2b/1791eb329c07122a75b01035b1a3aa22ad139f3ce0ece1b059b506d9d9de/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a32cd81be21168bd0d6a0f0962d60177c447a1aa1b1e48fa6ec9fc728ee0b12", size = 3019202, upload-time = "2025-06-24T10:24:31.791Z" },
+ { url = "https://files.pythonhosted.org/packages/05/15/fd2d8104faa9f86ac68748e6f7ece0b5eb7983c7efc3a2c197cb98c99030/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8bd8999538c405133c2ab999b83b17c08b7fc1b48c1ada2469964605a709ef91", size = 2934539, upload-time = "2025-06-24T10:24:34.567Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/2e/53e8fd053e1f3ffbe579ca5f9546f35ac67cf0039ed357ad7ec57f5f5af0/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5e9944e61239b083a41cf8fc42802f855e1dca0f499196df37a8ce219abac6eb", size = 3248665, upload-time = "2025-06-24T10:24:39.024Z" },
+ { url = "https://files.pythonhosted.org/packages/00/15/79713359f4037aa8f4d1f06ffca35312ac83629da062670e8830917e2153/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:514cd43045c5d546f01142ff9c79a96ea69e4b5cda09e3027708cb2e6d5762ab", size = 3451305, upload-time = "2025-06-24T10:24:36.133Z" },
+ { url = "https://files.pythonhosted.org/packages/38/5f/959f3a8756fc9396aeb704292777b84f02a5c6f25c3fc3ba7530db5feb2c/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b1b9405822527ec1e0f7d8d2fdb287a5730c3a6518189c968254a8441b21faae", size = 3214757, upload-time = "2025-06-24T10:24:37.784Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/74/f41a432a0733f61f3d21b288de6dfa78f7acff309c6f0f323b2833e9189f/tokenizers-0.21.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fed9a4d51c395103ad24f8e7eb976811c57fbec2af9f133df471afcd922e5020", size = 3121887, upload-time = "2025-06-24T10:24:40.293Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/6a/bc220a11a17e5d07b0dfb3b5c628621d4dcc084bccd27cfaead659963016/tokenizers-0.21.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c41862df3d873665ec78b6be36fcc30a26e3d4902e9dd8608ed61d49a48bc19", size = 9091965, upload-time = "2025-06-24T10:24:44.431Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/bd/ac386d79c4ef20dc6f39c4706640c24823dca7ebb6f703bfe6b5f0292d88/tokenizers-0.21.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ed21dc7e624e4220e21758b2e62893be7101453525e3d23264081c9ef9a6d00d", size = 9053372, upload-time = "2025-06-24T10:24:46.455Z" },
+ { url = "https://files.pythonhosted.org/packages/63/7b/5440bf203b2a5358f074408f7f9c42884849cd9972879e10ee6b7a8c3b3d/tokenizers-0.21.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:0e73770507e65a0e0e2a1affd6b03c36e3bc4377bd10c9ccf51a82c77c0fe365", size = 9298632, upload-time = "2025-06-24T10:24:48.446Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/d2/faa1acac3f96a7427866e94ed4289949b2524f0c1878512516567d80563c/tokenizers-0.21.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:106746e8aa9014a12109e58d540ad5465b4c183768ea96c03cbc24c44d329958", size = 9470074, upload-time = "2025-06-24T10:24:50.378Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/a5/896e1ef0707212745ae9f37e84c7d50269411aef2e9ccd0de63623feecdf/tokenizers-0.21.2-cp39-abi3-win32.whl", hash = "sha256:cabda5a6d15d620b6dfe711e1af52205266d05b379ea85a8a301b3593c60e962", size = 2330115, upload-time = "2025-06-24T10:24:55.069Z" },
+ { url = "https://files.pythonhosted.org/packages/13/c3/cc2755ee10be859c4338c962a35b9a663788c0c0b50c0bdd8078fb6870cf/tokenizers-0.21.2-cp39-abi3-win_amd64.whl", hash = "sha256:58747bb898acdb1007f37a7bbe614346e98dc28708ffb66a3fd50ce169ac6c98", size = 2509918, upload-time = "2025-06-24T10:24:53.71Z" },
]
[[package]]
@@ -5594,20 +5630,20 @@ wheels = [
[[package]]
name = "types-aiofiles"
-version = "24.1.0.20250516"
+version = "24.1.0.20250708"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b3/13/41faafda1d85fc8afa744ee67a2d788b3ba63e5f1c7303e5d10c2d784d2d/types_aiofiles-24.1.0.20250516.tar.gz", hash = "sha256:7fd2a7f793bbe180b7b22cd4f59300fe61fdc9940b3bbc9899ffe32849b95188", size = 14304, upload-time = "2025-05-16T03:08:29.55Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/4a/d6/5c44761bc11cb5c7505013a39f397a9016bfb3a5c932032b2db16c38b87b/types_aiofiles-24.1.0.20250708.tar.gz", hash = "sha256:c8207ed7385491ce5ba94da02658164ebd66b69a44e892288c9f20cbbf5284ff", size = 14322, upload-time = "2025-07-08T03:14:44.814Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/70/e7/828287ba5d1107db1093a123fe8e481eb5aab55c911f1100f28d0df80d5a/types_aiofiles-24.1.0.20250516-py3-none-any.whl", hash = "sha256:ec265994629146804b656a971c46f393ce860305834b3cacb4b8b6fb7dba7e33", size = 14253, upload-time = "2025-05-16T03:08:28.67Z" },
+ { url = "https://files.pythonhosted.org/packages/44/e9/4e0cc79c630040aae0634ac9393341dc2aff1a5be454be9741cc6cc8989f/types_aiofiles-24.1.0.20250708-py3-none-any.whl", hash = "sha256:07f8f06465fd415d9293467d1c66cd074b2c3b62b679e26e353e560a8cf63720", size = 14320, upload-time = "2025-07-08T03:14:44.009Z" },
]
[[package]]
name = "types-awscrt"
-version = "0.27.2"
+version = "0.27.4"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/36/6c/583522cfb3c330e92e726af517a91c13247e555e021791a60f1b03c6ff16/types_awscrt-0.27.2.tar.gz", hash = "sha256:acd04f57119eb15626ab0ba9157fc24672421de56e7bd7b9f61681fedee44e91", size = 16304, upload-time = "2025-05-16T03:10:08.712Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/95/02564024f8668feab6733a2c491005b5281b048b3d0573510622cbcd9fd4/types_awscrt-0.27.4.tar.gz", hash = "sha256:c019ba91a097e8a31d6948f6176ede1312963f41cdcacf82482ac877cbbcf390", size = 16941, upload-time = "2025-06-29T22:58:04.756Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4c/82/1ee2e5c9d28deac086ab3a6ff07c8bc393ef013a083f546c623699881715/types_awscrt-0.27.2-py3-none-any.whl", hash = "sha256:49a045f25bbd5ad2865f314512afced933aed35ddbafc252e2268efa8a787e4e", size = 37761, upload-time = "2025-05-16T03:10:07.466Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/40/cb4d04df4ac3520858f5b397a4ab89f34be2601000002a26edd8ddc0cac5/types_awscrt-0.27.4-py3-none-any.whl", hash = "sha256:a8c4b9d9ae66d616755c322aba75ab9bd793c6fef448917e6de2e8b8cdf66fb4", size = 39626, upload-time = "2025-06-29T22:58:03.157Z" },
]
[[package]]
@@ -5654,11 +5690,11 @@ wheels = [
[[package]]
name = "types-defusedxml"
-version = "0.7.0.20250516"
+version = "0.7.0.20250708"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/55/9d/3ba8b80536402f1a125bc5a44d82ab686aafa55a85f56160e076b2ac30de/types_defusedxml-0.7.0.20250516.tar.gz", hash = "sha256:164c2945077fa450f24ed09633f8b3a80694687fefbbc1cba5f24e4ba570666b", size = 10298, upload-time = "2025-05-16T03:08:18.951Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/b9/4b/79d046a7211e110afd885be04bb9423546df2a662ed28251512d60e51fb6/types_defusedxml-0.7.0.20250708.tar.gz", hash = "sha256:7b785780cc11c18a1af086308bf94bf53a0907943a1d145dbe00189bef323cb8", size = 10541, upload-time = "2025-07-08T03:14:33.325Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2e/7b/567b0978150edccf7fa3aa8f2566ea9c3ffc9481ce7d64428166934d6d7f/types_defusedxml-0.7.0.20250516-py3-none-any.whl", hash = "sha256:00e793e5c385c3e142d7c2acc3b4ccea2fe0828cee11e35501f0ba40386630a0", size = 12576, upload-time = "2025-05-16T03:08:17.892Z" },
+ { url = "https://files.pythonhosted.org/packages/24/f8/870de7fbd5fee5643f05061db948df6bd574a05a42aee91e37ad47c999ef/types_defusedxml-0.7.0.20250708-py3-none-any.whl", hash = "sha256:cc426cbc31c61a0f1b1c2ad9b9ef9ef846645f28fd708cd7727a6353b5c52e54", size = 13478, upload-time = "2025-07-08T03:14:32.633Z" },
]
[[package]]
@@ -5672,11 +5708,11 @@ wheels = [
[[package]]
name = "types-docutils"
-version = "0.21.0.20250526"
+version = "0.21.0.20250708"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/34/bf/bb5695f7a9660f79a9cd999ea13ff7331b8f2d03aec3d2fd7c38be4bc8aa/types_docutils-0.21.0.20250526.tar.gz", hash = "sha256:6c7ba387716315df0d86a796baec9d5a71825ed2746cb7763193aafbb70ac86c", size = 38140, upload-time = "2025-05-26T03:10:49.242Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/39/86/24394a71a04f416ca03df51863a3d3e2cd0542fdc40989188dca30ffb5bf/types_docutils-0.21.0.20250708.tar.gz", hash = "sha256:5625a82a9a2f26d8384545607c157e023a48ed60d940dfc738db125282864172", size = 42011, upload-time = "2025-07-08T03:14:24.214Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/35/84/73bca8d1364f6685bd6e00eaa15e653ef96163231fbd7a612f3a845497fb/types_docutils-0.21.0.20250526-py3-none-any.whl", hash = "sha256:44d9f9ed19bb75071deb6804947c123f30bbc617a656420f044e09b9f16b72d1", size = 62000, upload-time = "2025-05-26T03:10:48.101Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/17/8c1153fc1576a0dcffdd157c69a12863c3f9485054256f6791ea17d95aed/types_docutils-0.21.0.20250708-py3-none-any.whl", hash = "sha256:166630d1aec18b9ca02547873210e04bf7674ba8f8da9cd9e6a5e77dc99372c2", size = 67953, upload-time = "2025-07-08T03:14:23.057Z" },
]
[[package]]
@@ -5728,11 +5764,11 @@ wheels = [
[[package]]
name = "types-html5lib"
-version = "1.1.11.20250516"
+version = "1.1.11.20250708"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d0/ed/9f092ff479e2b5598941855f314a22953bb04b5fb38bcba3f880feb833ba/types_html5lib-1.1.11.20250516.tar.gz", hash = "sha256:65043a6718c97f7d52567cc0cdf41efbfc33b1f92c6c0c5e19f60a7ec69ae720", size = 16136, upload-time = "2025-05-16T03:07:12.231Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/d4/3b/1f5ba4358cfc1421cced5cdb9d2b08b4b99e4f9a41da88ce079f6d1a7bf1/types_html5lib-1.1.11.20250708.tar.gz", hash = "sha256:24321720fdbac71cee50d5a4bec9b7448495b7217974cffe3fcf1ede4eef7afe", size = 16799, upload-time = "2025-07-08T03:13:53.14Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cc/3b/cb5b23c7b51bf48b8c9f175abb9dce2f1ecd2d2c25f92ea9f4e3720e9398/types_html5lib-1.1.11.20250516-py3-none-any.whl", hash = "sha256:5e407b14b1bd2b9b1107cbd1e2e19d4a0c46d60febd231c7ab7313d7405663c1", size = 21770, upload-time = "2025-05-16T03:07:11.102Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/50/5fc23cf647eee23acdd337c8150861d39980cf11f33dd87f78e87d2a4bad/types_html5lib-1.1.11.20250708-py3-none-any.whl", hash = "sha256:bb898066b155de7081cb182179e2ded31b9e0e234605e2cb46536894e68a6954", size = 22913, upload-time = "2025-07-08T03:13:52.098Z" },
]
[[package]]
@@ -5851,11 +5887,11 @@ wheels = [
[[package]]
name = "types-pymysql"
-version = "1.1.0.20250516"
+version = "1.1.0.20250708"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/db/11/cdaa90b82cb25c5e04e75f0b0616872aa5775b001096779375084f8dbbcf/types_pymysql-1.1.0.20250516.tar.gz", hash = "sha256:fea4a9776101cf893dfc868f42ce10d2e46dcc498c792cc7c9c0fe00cb744234", size = 19640, upload-time = "2025-05-16T03:06:54.568Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/65/a3/db349a06c64b8c041c165fc470b81d37404ec342014625c7a6b7f7a4f680/types_pymysql-1.1.0.20250708.tar.gz", hash = "sha256:2cbd7cfcf9313eda784910578c4f1d06f8cc03a15cd30ce588aa92dd6255011d", size = 21715, upload-time = "2025-07-08T03:13:56.463Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ab/64/129656e04ddda35d69faae914ce67cf60d83407ddd7afdef1e7c50bbb74a/types_pymysql-1.1.0.20250516-py3-none-any.whl", hash = "sha256:41c87a832e3ff503d5120cc6cebd64f6dcb3c407d9580a98b2cb3e3bcd109aa6", size = 20328, upload-time = "2025-05-16T03:06:53.681Z" },
+ { url = "https://files.pythonhosted.org/packages/88/e5/7f72c520f527175b6455e955426fd4f971128b4fa2f8ab2f505f254a1ddc/types_pymysql-1.1.0.20250708-py3-none-any.whl", hash = "sha256:9252966d2795945b2a7a53d5cdc49fe8e4e2f3dde4c104ed7fc782a83114e365", size = 22860, upload-time = "2025-07-08T03:13:55.367Z" },
]
[[package]]
@@ -5873,11 +5909,20 @@ wheels = [
[[package]]
name = "types-python-dateutil"
-version = "2.9.0.20250516"
+version = "2.9.0.20250708"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/95/6bdde7607da2e1e99ec1c1672a759d42f26644bbacf939916e086db34870/types_python_dateutil-2.9.0.20250708.tar.gz", hash = "sha256:ccdbd75dab2d6c9696c350579f34cffe2c281e4c5f27a585b2a2438dd1d5c8ab", size = 15834, upload-time = "2025-07-08T03:14:03.382Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/72/52/43e70a8e57fefb172c22a21000b03ebcc15e47e97f5cb8495b9c2832efb4/types_python_dateutil-2.9.0.20250708-py3-none-any.whl", hash = "sha256:4d6d0cc1cc4d24a2dc3816024e502564094497b713f7befda4d5bc7a8e3fd21f", size = 17724, upload-time = "2025-07-08T03:14:02.593Z" },
+]
+
+[[package]]
+name = "types-python-http-client"
+version = "3.3.7.20250708"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ef/88/d65ed807393285204ab6e2801e5d11fbbea811adcaa979a2ed3b67a5ef41/types_python_dateutil-2.9.0.20250516.tar.gz", hash = "sha256:13e80d6c9c47df23ad773d54b2826bd52dbbb41be87c3f339381c1700ad21ee5", size = 13943, upload-time = "2025-05-16T03:06:58.385Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/55/a0/0ad93698a3ebc6846ca23aca20ff6f6f8ebe7b4f0c1de7f19e87c03dbe8f/types_python_http_client-3.3.7.20250708.tar.gz", hash = "sha256:5f85b32dc64671a4e5e016142169aa187c5abed0b196680944e4efd3d5ce3322", size = 7707, upload-time = "2025-07-08T03:14:36.197Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c5/3f/b0e8db149896005adc938a1e7f371d6d7e9eca4053a29b108978ed15e0c2/types_python_dateutil-2.9.0.20250516-py3-none-any.whl", hash = "sha256:2b2b3f57f9c6a61fba26a9c0ffb9ea5681c9b83e69cd897c6b5f668d9c0cab93", size = 14356, upload-time = "2025-05-16T03:06:57.249Z" },
+ { url = "https://files.pythonhosted.org/packages/85/4f/b88274658cf489e35175be8571c970e9a1219713bafd8fc9e166d7351ecb/types_python_http_client-3.3.7.20250708-py3-none-any.whl", hash = "sha256:e2fc253859decab36713d82fc7f205868c3ddeaee79dbb55956ad9ca77abe12b", size = 8890, upload-time = "2025-07-08T03:14:35.506Z" },
]
[[package]]
@@ -5918,14 +5963,14 @@ wheels = [
[[package]]
name = "types-requests"
-version = "2.32.0.20250602"
+version = "2.32.4.20250611"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/48/b0/5321e6eeba5d59e4347fcf9bf06a5052f085c3aa0f4876230566d6a4dc97/types_requests-2.32.0.20250602.tar.gz", hash = "sha256:ee603aeefec42051195ae62ca7667cd909a2f8128fdf8aad9e8a5219ecfab3bf", size = 23042, upload-time = "2025-06-02T03:15:02.958Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/6d/7f/73b3a04a53b0fd2a911d4ec517940ecd6600630b559e4505cc7b68beb5a0/types_requests-2.32.4.20250611.tar.gz", hash = "sha256:741c8777ed6425830bf51e54d6abe245f79b4dcb9019f1622b773463946bf826", size = 23118, upload-time = "2025-06-11T03:11:41.272Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/da/18/9b782980e575c6581d5c0c1c99f4c6f89a1d7173dad072ee96b2756c02e6/types_requests-2.32.0.20250602-py3-none-any.whl", hash = "sha256:f4f335f87779b47ce10b8b8597b409130299f6971ead27fead4fe7ba6ea3e726", size = 20638, upload-time = "2025-06-02T03:15:01.959Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/ea/0be9258c5a4fa1ba2300111aa5a0767ee6d18eb3fd20e91616c12082284d/types_requests-2.32.4.20250611-py3-none-any.whl", hash = "sha256:ad2fe5d3b0cb3c2c902c8815a70e7fb2302c4b8c1f77bdcd738192cdb3878072", size = 20643, upload-time = "2025-06-11T03:11:40.186Z" },
]
[[package]]
@@ -6026,11 +6071,11 @@ wheels = [
[[package]]
name = "typing-extensions"
-version = "4.14.0"
+version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d1/bc/51647cd02527e87d05cb083ccc402f93e441606ff1f01739a62c8ad09ba5/typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4", size = 107423, upload-time = "2025-06-02T14:52:11.399Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/69/e0/552843e0d356fbb5256d21449fa957fa4eff3bbc135a74a691ee70c7c5da/typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af", size = 43839, upload-time = "2025-06-02T14:52:10.026Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
]
[[package]]
@@ -6158,7 +6203,7 @@ pptx = [
[[package]]
name = "unstructured-client"
-version = "0.36.0"
+version = "0.38.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -6169,9 +6214,9 @@ dependencies = [
{ name = "pypdf" },
{ name = "requests-toolbelt" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/9d/4d/d829dbef1138251de771cd52b277d93fb1c4e79d56be3e44e6d2ce76bd62/unstructured_client-0.36.0.tar.gz", hash = "sha256:ab293498100275c0e1d74c926c82dae2b3ba3fbb88945c0ba03b4b7a29197e4a", size = 86010, upload-time = "2025-05-29T00:11:11.429Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/85/60/412092671bfc4952640739f2c0c9b2f4c8af26a3c921738fd12621b4ddd8/unstructured_client-0.38.1.tar.gz", hash = "sha256:43ab0670dd8ff53d71e74f9b6dfe490a84a5303dab80a4873e118a840c6d46ca", size = 91781, upload-time = "2025-07-03T15:46:35.054Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b9/4a/ae162e583bbdd0996f92ad18871a737d710260d8c0cfd78f1be6aa0ac150/unstructured_client-0.36.0-py3-none-any.whl", hash = "sha256:d0ecf3ac4d481437d858147904ff6e41205032cf8353af5cdd3ebaa190481d6a", size = 195765, upload-time = "2025-05-29T00:11:09.677Z" },
+ { url = "https://files.pythonhosted.org/packages/26/e0/8c249f00ba85fb4aba5c541463312befbfbf491105ff5c06e508089467be/unstructured_client-0.38.1-py3-none-any.whl", hash = "sha256:71e5467870d0a0119c788c29ec8baf5c0f7123f424affc9d6682eeeb7b8d45fa", size = 212626, upload-time = "2025-07-03T15:46:33.929Z" },
]
[[package]]
@@ -6197,54 +6242,33 @@ wheels = [
[[package]]
name = "urllib3"
-version = "2.4.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672, upload-time = "2025-04-10T15:23:39.232Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680, upload-time = "2025-04-10T15:23:37.377Z" },
-]
-
-[[package]]
-name = "uuid-utils"
-version = "0.11.0"
+version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/24/7f/7d83b937889d65682d95b40c94ba226b353d3f532290ee3acb17c8746e49/uuid_utils-0.11.0.tar.gz", hash = "sha256:18cf2b7083da7f3cca0517647213129eb16d20d7ed0dd74b3f4f8bff2aa334ea", size = 18854, upload-time = "2025-05-22T11:23:15.596Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/20/4a34f2a6e77b1f0f3334b111e4d2411fc8646ab2987892a36507e2d6a498/uuid_utils-0.11.0-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:094445ccd323bc5507e28e9d6d86b983513efcf19ab59c2dd75239cef765631a", size = 593779, upload-time = "2025-05-22T11:22:41.36Z" },
- { url = "https://files.pythonhosted.org/packages/a1/a1/1897cd3d37144f698392ec8aae89da2c00c6d34acd77f75312477f4510ab/uuid_utils-0.11.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6430b53d343215f85269ffd74e1d1f4b25ae1031acf0ac24ff3d5721f6a06f48", size = 300848, upload-time = "2025-05-22T11:22:43.221Z" },
- { url = "https://files.pythonhosted.org/packages/d4/36/3ae8896de8a5320a9e7529452ed29af0082daf8c3787f17c5cbf9defc651/uuid_utils-0.11.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be2e6e4318d23195887fa74fa1d64565a34f7127fdcf22918954981d79765f68", size = 336053, upload-time = "2025-05-22T11:22:44.741Z" },
- { url = "https://files.pythonhosted.org/packages/fe/b6/751e84cd056074a40ca9ac21db6ca4802e31d78207309c0d9c8ff69cd43b/uuid_utils-0.11.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d37289ab72aa30b5550bfa64d91431c62c89e4969bdf989988aa97f918d5f803", size = 338529, upload-time = "2025-05-22T11:22:46.303Z" },
- { url = "https://files.pythonhosted.org/packages/3b/c2/f6a1c00a1b067a886fc57c24da46bb0bcb753c92afb898871c6df3ae606f/uuid_utils-0.11.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1012595220f945fe09641f1365a8a06915bf432cac1b31ebd262944934a9b787", size = 480378, upload-time = "2025-05-22T11:22:47.482Z" },
- { url = "https://files.pythonhosted.org/packages/60/ea/cefc0521e07a35e85416d145382ac4817957cdec037271d0c9e27cbc7d45/uuid_utils-0.11.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35cd3fc718a673e4516e87afb9325558969eca513aa734515b9031d1b651bbb1", size = 332220, upload-time = "2025-05-22T11:22:48.55Z" },
- { url = "https://files.pythonhosted.org/packages/03/91/5929f209bd4660a7e3b4d47d26189d3cf33e14297312a5f51f5451805fec/uuid_utils-0.11.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ed325e0c40e0f59ae82b347f534df954b50cedf12bf60d025625538530e1965d", size = 359052, upload-time = "2025-05-22T11:22:49.617Z" },
- { url = "https://files.pythonhosted.org/packages/d8/0d/32034d5b13bc07dd95f23122cb743b4eeca8e6d88173ea3c7100c67b6269/uuid_utils-0.11.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5c8b7cf201990ee3140956e541967bd556a7365ec738cb504b04187ad89c757a", size = 515186, upload-time = "2025-05-22T11:22:50.808Z" },
- { url = "https://files.pythonhosted.org/packages/e7/43/ccf2474f723d6de5e214c22999ffb34219acf83d1e3fff6a4734172e10c0/uuid_utils-0.11.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:9966df55bed5d538ba2e9cc40115796480f437f9007727116ef99dc2f42bd5fa", size = 535318, upload-time = "2025-05-22T11:22:52.304Z" },
- { url = "https://files.pythonhosted.org/packages/fb/05/f668b4ad2b3542cd021c4b27d1ff4e425f854f299bcf7ee36f304399a58c/uuid_utils-0.11.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cb04b6c604968424b7e6398d54debbdd5b771b39fc1e648c6eabf3f1dc20582e", size = 502691, upload-time = "2025-05-22T11:22:53.483Z" },
- { url = "https://files.pythonhosted.org/packages/9e/0b/b906301638eef837c89b19206989dbe27794c591d794ecc06167d9a47c41/uuid_utils-0.11.0-cp39-abi3-win32.whl", hash = "sha256:18420eb3316bb514f09f2da15750ac135478c3a12a704e2c5fb59eab642bb255", size = 180147, upload-time = "2025-05-22T11:22:54.598Z" },
- { url = "https://files.pythonhosted.org/packages/56/99/ad24ee5ecfc5fbd4a4490bb59c0e72ce604d5eef08683d345546ff6a6f2d/uuid_utils-0.11.0-cp39-abi3-win_amd64.whl", hash = "sha256:37c4805af61a7cce899597d34e7c3dd5cb6a8b4b93a90fbca3826b071ba544df", size = 183574, upload-time = "2025-05-22T11:22:55.581Z" },
- { url = "https://files.pythonhosted.org/packages/0e/76/2301b1d34defc8c234596ffb6e6d456cd7ef061d108e10a14ceda5ec5d4b/uuid_utils-0.11.0-cp39-abi3-win_arm64.whl", hash = "sha256:4065cf17bbe97f6d8ccc7dc6a0bae7d28fd4797d7f32028a5abd979aeb7bf7c9", size = 181014, upload-time = "2025-05-22T11:22:56.575Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "uuid6"
-version = "2024.7.10"
+version = "2025.0.1"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/2c/56/2560a9f1ccab9e12b1b3478a3c870796cf4d8ee5652bb19b61751cced14a/uuid6-2024.7.10.tar.gz", hash = "sha256:2d29d7f63f593caaeea0e0d0dd0ad8129c9c663b29e19bdf882e864bedf18fb0", size = 8705, upload-time = "2024-07-10T16:39:37.592Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/ca/b7/4c0f736ca824b3a25b15e8213d1bcfc15f8ac2ae48d1b445b310892dc4da/uuid6-2025.0.1.tar.gz", hash = "sha256:cd0af94fa428675a44e32c5319ec5a3485225ba2179eefcf4c3f205ae30a81bd", size = 13932, upload-time = "2025-07-04T18:30:35.186Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d3/3e/4ae6af487ce5781ed71d5fe10aca72e7cbc4d4f45afc31b120287082a8dd/uuid6-2024.7.10-py3-none-any.whl", hash = "sha256:93432c00ba403751f722829ad21759ff9db051dea140bf81493271e8e4dd18b7", size = 6376, upload-time = "2024-07-10T16:39:36.148Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/b2/93faaab7962e2aa8d6e174afb6f76be2ca0ce89fde14d3af835acebcaa59/uuid6-2025.0.1-py3-none-any.whl", hash = "sha256:80530ce4d02a93cdf82e7122ca0da3ebbbc269790ec1cb902481fa3e9cc9ff99", size = 6979, upload-time = "2025-07-04T18:30:34.001Z" },
]
[[package]]
name = "uvicorn"
-version = "0.34.3"
+version = "0.35.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/de/ad/713be230bcda622eaa35c28f0d328c3675c371238470abdea52417f17a8e/uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a", size = 76631, upload-time = "2025-06-01T07:48:17.531Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/6d/0d/8adfeaa62945f90d19ddc461c55f4a50c258af7662d34b6a3d5d1f8646f6/uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885", size = 62431, upload-time = "2025-06-01T07:48:15.664Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" },
]
[package.optional-dependencies]
@@ -6316,72 +6340,73 @@ wheels = [
[[package]]
name = "wandb"
-version = "0.19.11"
+version = "0.21.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
- { name = "docker-pycreds" },
{ name = "gitpython" },
+ { name = "packaging" },
{ name = "platformdirs" },
{ name = "protobuf" },
- { name = "psutil" },
{ name = "pydantic" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "sentry-sdk" },
- { name = "setproctitle" },
- { name = "setuptools" },
{ name = "typing-extensions" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/39/98/0ff2925a21b998d4b84731429f4554ca3d9b5cad42c09c075e7306c3aca0/wandb-0.19.11.tar.gz", hash = "sha256:3f50a27dfadbb25946a513ffe856c0e8e538b5626ef207aa50b00c3b0356bff8", size = 39511477, upload-time = "2025-05-07T20:50:01.341Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/73/09/c84264a219e20efd615e4d5d150cc7d359d57d51328d3fa94ee02d70ed9c/wandb-0.21.0.tar.gz", hash = "sha256:473e01ef200b59d780416062991effa7349a34e51425d4be5ff482af2dc39e02", size = 40085784, upload-time = "2025-07-02T00:24:15.516Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/4f/2c/f8bab58c73fdde4442f1baffd9ea5d1bb3113906a97a27e8d9ab72db7a69/wandb-0.19.11-py3-none-any.whl", hash = "sha256:ff3bf050ba25ebae7aedc9a775ffab90c28068832edfe5458423f488c2558f82", size = 6481327, upload-time = "2025-05-07T20:49:33.461Z" },
- { url = "https://files.pythonhosted.org/packages/45/4a/34b364280f690f4c6d7660f528fba9f13bdecabc4c869d266a4632cf836e/wandb-0.19.11-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:0823fd9aa6343f40c04e01959997ca8c6d6adf1bd81c8d45261fa4915f1c6b67", size = 20555751, upload-time = "2025-05-07T20:49:36.392Z" },
- { url = "https://files.pythonhosted.org/packages/d8/e6/a27868fdb83a60df37b9d15e52c3353dd88d74442f27ae48cf765c6b9554/wandb-0.19.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c758ef5439599d9023db5b3cf1698477055d82f9fae48af2779f63f1d289167c", size = 20377587, upload-time = "2025-05-07T20:49:39.126Z" },
- { url = "https://files.pythonhosted.org/packages/21/f7/d5cf5b58c2b3015364c7b2b6af6a440cbeda4103b67332e1e64b30f6252d/wandb-0.19.11-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:de2dfd4911e7691735e271654c735e7b90cdee9d29a3796fbf06e9e92d48f3d7", size = 20985041, upload-time = "2025-05-07T20:49:41.571Z" },
- { url = "https://files.pythonhosted.org/packages/68/06/8b827f16a0b8f18002d2fffa7c5a7fd447946e0d0c68aeec0dd7eb18cdd3/wandb-0.19.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cfff738850770d26b13f8f3fe400a6456f1e39e87f3f29d5aa241b249476df95", size = 20017696, upload-time = "2025-05-07T20:49:44.04Z" },
- { url = "https://files.pythonhosted.org/packages/f9/31/eeb2878b26566c04c3e9b8b20b3ec3c54a2be50535088d36a37c008e07a3/wandb-0.19.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e8ff673007448df11cc69379ae0df28ead866800dc1ec7bc151b402db0bbcf40", size = 21425857, upload-time = "2025-05-07T20:49:46.347Z" },
- { url = "https://files.pythonhosted.org/packages/10/30/08988360678ae78334bb16625c28260fcaba49f500b89f8766807cb74d71/wandb-0.19.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:858bc5023fa1b3285d89d15f62be78afdb28301064daa49ea3f4ebde5dcedad2", size = 20023145, upload-time = "2025-05-07T20:49:48.965Z" },
- { url = "https://files.pythonhosted.org/packages/c8/e9/a639c42c8ca517c4d25e8970d64d0c5a9bd35b784faed5f47d9cca3dcd12/wandb-0.19.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:90e4b57649896acb16c3dd41b3093df1a169c2f1d94ff15d76af86b8a60dcdac", size = 21504842, upload-time = "2025-05-07T20:49:51.628Z" },
- { url = "https://files.pythonhosted.org/packages/44/74/dbe9277dd935b77dd16939cdf15357766fec0813a6e336cf5f1d07eb016e/wandb-0.19.11-py3-none-win32.whl", hash = "sha256:38dea43c7926d8800405a73b80b9adfe81eb315fc6f2ac6885c77eb966634421", size = 20767584, upload-time = "2025-05-07T20:49:56.629Z" },
- { url = "https://files.pythonhosted.org/packages/36/d5/215cac3edec5c5ac6e7231beb9d22466d5d4e4a132fa3a1d044f7d682c15/wandb-0.19.11-py3-none-win_amd64.whl", hash = "sha256:73402003c56ddc2198878492ab2bff55bb49bce5587eae5960e737d27c0c48f7", size = 20767588, upload-time = "2025-05-07T20:49:58.85Z" },
+ { url = "https://files.pythonhosted.org/packages/38/dd/65eac086e1bc337bb5f0eed65ba1fe4a6dbc62c97f094e8e9df1ef83ffed/wandb-0.21.0-py3-none-any.whl", hash = "sha256:316e8cd4329738f7562f7369e6eabeeb28ef9d473203f7ead0d03e5dba01c90d", size = 6504284, upload-time = "2025-07-02T00:23:46.671Z" },
+ { url = "https://files.pythonhosted.org/packages/17/a7/80556ce9097f59e10807aa68f4a9b29d736a90dca60852a9e2af1641baf8/wandb-0.21.0-py3-none-macosx_10_14_x86_64.whl", hash = "sha256:701d9cbdfcc8550a330c1b54a26f1585519180e0f19247867446593d34ace46b", size = 21717388, upload-time = "2025-07-02T00:23:49.348Z" },
+ { url = "https://files.pythonhosted.org/packages/23/ae/660bc75aa37bd23409822ea5ed616177d94873172d34271693c80405c820/wandb-0.21.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:01689faa6b691df23ba2367e0a1ecf6e4d0be44474905840098eedd1fbcb8bdf", size = 21141465, upload-time = "2025-07-02T00:23:52.602Z" },
+ { url = "https://files.pythonhosted.org/packages/23/ab/9861929530be56557c74002868c85d0d8ac57050cc21863afe909ae3d46f/wandb-0.21.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:55d3f42ddb7971d1699752dff2b85bcb5906ad098d18ab62846c82e9ce5a238d", size = 21793511, upload-time = "2025-07-02T00:23:55.447Z" },
+ { url = "https://files.pythonhosted.org/packages/de/52/e5cad2eff6fbed1ac06f4a5b718457fa2fd437f84f5c8f0d31995a2ef046/wandb-0.21.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:893508f0c7da48917448daa5cd622c27ce7ce15119adaa861185034c2bd7b14c", size = 20704643, upload-time = "2025-07-02T00:23:58.255Z" },
+ { url = "https://files.pythonhosted.org/packages/83/8f/6bed9358cc33767c877b221d4f565e1ddf00caf4bbbe54d2e3bbc932c6a7/wandb-0.21.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4e8245a8912247ddf7654f7b5330f583a6c56ab88fee65589158490d583c57d", size = 22243012, upload-time = "2025-07-02T00:24:01.423Z" },
+ { url = "https://files.pythonhosted.org/packages/be/61/9048015412ea5ca916844af55add4fed7c21fe1ad70bb137951e70b550c5/wandb-0.21.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2e4c4f951e0d02755e315679bfdcb5bc38c1b02e2e5abc5432b91a91bb0cf246", size = 20716440, upload-time = "2025-07-02T00:24:04.198Z" },
+ { url = "https://files.pythonhosted.org/packages/02/d9/fcd2273d8ec3f79323e40a031aba5d32d6fa9065702010eb428b5ffbab62/wandb-0.21.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:873749966eeac0069e0e742e6210641b6227d454fb1dae2cf5c437c6ed42d3ca", size = 22320652, upload-time = "2025-07-02T00:24:07.175Z" },
+ { url = "https://files.pythonhosted.org/packages/80/68/b8308db6b9c3c96dcd03be17c019aee105e1d7dc1e74d70756cdfb9241c6/wandb-0.21.0-py3-none-win32.whl", hash = "sha256:9d3cccfba658fa011d6cab9045fa4f070a444885e8902ae863802549106a5dab", size = 21484296, upload-time = "2025-07-02T00:24:10.147Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/96/71cc033e8abd00e54465e68764709ed945e2da2d66d764f72f4660262b22/wandb-0.21.0-py3-none-win_amd64.whl", hash = "sha256:28a0b2dad09d7c7344ac62b0276be18a2492a5578e4d7c84937a3e1991edaac7", size = 21484301, upload-time = "2025-07-02T00:24:12.658Z" },
]
[[package]]
name = "watchfiles"
-version = "1.0.5"
+version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/03/e2/8ed598c42057de7aa5d97c472254af4906ff0a59a66699d426fc9ef795d7/watchfiles-1.0.5.tar.gz", hash = "sha256:b7529b5dcc114679d43827d8c35a07c493ad6f083633d573d81c660abc5979e9", size = 94537, upload-time = "2025-04-08T10:36:26.722Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/39/f4/41b591f59021786ef517e1cdc3b510383551846703e03f204827854a96f8/watchfiles-1.0.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:237f9be419e977a0f8f6b2e7b0475ababe78ff1ab06822df95d914a945eac827", size = 405336, upload-time = "2025-04-08T10:34:59.359Z" },
- { url = "https://files.pythonhosted.org/packages/ae/06/93789c135be4d6d0e4f63e96eea56dc54050b243eacc28439a26482b5235/watchfiles-1.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0da39ff917af8b27a4bdc5a97ac577552a38aac0d260a859c1517ea3dc1a7c4", size = 395977, upload-time = "2025-04-08T10:35:00.522Z" },
- { url = "https://files.pythonhosted.org/packages/d2/db/1cd89bd83728ca37054512d4d35ab69b5f12b8aa2ac9be3b0276b3bf06cc/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cfcb3952350e95603f232a7a15f6c5f86c5375e46f0bd4ae70d43e3e063c13d", size = 455232, upload-time = "2025-04-08T10:35:01.698Z" },
- { url = "https://files.pythonhosted.org/packages/40/90/d8a4d44ffe960517e487c9c04f77b06b8abf05eb680bed71c82b5f2cad62/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68b2dddba7a4e6151384e252a5632efcaa9bc5d1c4b567f3cb621306b2ca9f63", size = 459151, upload-time = "2025-04-08T10:35:03.358Z" },
- { url = "https://files.pythonhosted.org/packages/6c/da/267a1546f26465dead1719caaba3ce660657f83c9d9c052ba98fb8856e13/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:95cf944fcfc394c5f9de794ce581914900f82ff1f855326f25ebcf24d5397418", size = 489054, upload-time = "2025-04-08T10:35:04.561Z" },
- { url = "https://files.pythonhosted.org/packages/b1/31/33850dfd5c6efb6f27d2465cc4c6b27c5a6f5ed53c6fa63b7263cf5f60f6/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecf6cd9f83d7c023b1aba15d13f705ca7b7d38675c121f3cc4a6e25bd0857ee9", size = 523955, upload-time = "2025-04-08T10:35:05.786Z" },
- { url = "https://files.pythonhosted.org/packages/09/84/b7d7b67856efb183a421f1416b44ca975cb2ea6c4544827955dfb01f7dc2/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:852de68acd6212cd6d33edf21e6f9e56e5d98c6add46f48244bd479d97c967c6", size = 502234, upload-time = "2025-04-08T10:35:07.187Z" },
- { url = "https://files.pythonhosted.org/packages/71/87/6dc5ec6882a2254cfdd8b0718b684504e737273903b65d7338efaba08b52/watchfiles-1.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5730f3aa35e646103b53389d5bc77edfbf578ab6dab2e005142b5b80a35ef25", size = 454750, upload-time = "2025-04-08T10:35:08.859Z" },
- { url = "https://files.pythonhosted.org/packages/3d/6c/3786c50213451a0ad15170d091570d4a6554976cf0df19878002fc96075a/watchfiles-1.0.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:18b3bd29954bc4abeeb4e9d9cf0b30227f0f206c86657674f544cb032296acd5", size = 631591, upload-time = "2025-04-08T10:35:10.64Z" },
- { url = "https://files.pythonhosted.org/packages/1b/b3/1427425ade4e359a0deacce01a47a26024b2ccdb53098f9d64d497f6684c/watchfiles-1.0.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ba5552a1b07c8edbf197055bc9d518b8f0d98a1c6a73a293bc0726dce068ed01", size = 625370, upload-time = "2025-04-08T10:35:12.412Z" },
- { url = "https://files.pythonhosted.org/packages/15/ba/f60e053b0b5b8145d682672024aa91370a29c5c921a88977eb565de34086/watchfiles-1.0.5-cp311-cp311-win32.whl", hash = "sha256:2f1fefb2e90e89959447bc0420fddd1e76f625784340d64a2f7d5983ef9ad246", size = 277791, upload-time = "2025-04-08T10:35:13.719Z" },
- { url = "https://files.pythonhosted.org/packages/50/ed/7603c4e164225c12c0d4e8700b64bb00e01a6c4eeea372292a3856be33a4/watchfiles-1.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b6e76ceb1dd18c8e29c73f47d41866972e891fc4cc7ba014f487def72c1cf096", size = 291622, upload-time = "2025-04-08T10:35:15.071Z" },
- { url = "https://files.pythonhosted.org/packages/a2/c2/99bb7c96b4450e36877fde33690ded286ff555b5a5c1d925855d556968a1/watchfiles-1.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:266710eb6fddc1f5e51843c70e3bebfb0f5e77cf4f27129278c70554104d19ed", size = 283699, upload-time = "2025-04-08T10:35:16.732Z" },
- { url = "https://files.pythonhosted.org/packages/2a/8c/4f0b9bdb75a1bfbd9c78fad7d8854369283f74fe7cf03eb16be77054536d/watchfiles-1.0.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b5eb568c2aa6018e26da9e6c86f3ec3fd958cee7f0311b35c2630fa4217d17f2", size = 401511, upload-time = "2025-04-08T10:35:17.956Z" },
- { url = "https://files.pythonhosted.org/packages/dc/4e/7e15825def77f8bd359b6d3f379f0c9dac4eb09dd4ddd58fd7d14127179c/watchfiles-1.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a04059f4923ce4e856b4b4e5e783a70f49d9663d22a4c3b3298165996d1377f", size = 392715, upload-time = "2025-04-08T10:35:19.202Z" },
- { url = "https://files.pythonhosted.org/packages/58/65/b72fb817518728e08de5840d5d38571466c1b4a3f724d190cec909ee6f3f/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e380c89983ce6e6fe2dd1e1921b9952fb4e6da882931abd1824c092ed495dec", size = 454138, upload-time = "2025-04-08T10:35:20.586Z" },
- { url = "https://files.pythonhosted.org/packages/3e/a4/86833fd2ea2e50ae28989f5950b5c3f91022d67092bfec08f8300d8b347b/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe43139b2c0fdc4a14d4f8d5b5d967f7a2777fd3d38ecf5b1ec669b0d7e43c21", size = 458592, upload-time = "2025-04-08T10:35:21.87Z" },
- { url = "https://files.pythonhosted.org/packages/38/7e/42cb8df8be9a37e50dd3a818816501cf7a20d635d76d6bd65aae3dbbff68/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee0822ce1b8a14fe5a066f93edd20aada932acfe348bede8aa2149f1a4489512", size = 487532, upload-time = "2025-04-08T10:35:23.143Z" },
- { url = "https://files.pythonhosted.org/packages/fc/fd/13d26721c85d7f3df6169d8b495fcac8ab0dc8f0945ebea8845de4681dab/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a0dbcb1c2d8f2ab6e0a81c6699b236932bd264d4cef1ac475858d16c403de74d", size = 522865, upload-time = "2025-04-08T10:35:24.702Z" },
- { url = "https://files.pythonhosted.org/packages/a1/0d/7f9ae243c04e96c5455d111e21b09087d0eeaf9a1369e13a01c7d3d82478/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2014a2b18ad3ca53b1f6c23f8cd94a18ce930c1837bd891262c182640eb40a6", size = 499887, upload-time = "2025-04-08T10:35:25.969Z" },
- { url = "https://files.pythonhosted.org/packages/8e/0f/a257766998e26aca4b3acf2ae97dff04b57071e991a510857d3799247c67/watchfiles-1.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10f6ae86d5cb647bf58f9f655fcf577f713915a5d69057a0371bc257e2553234", size = 454498, upload-time = "2025-04-08T10:35:27.353Z" },
- { url = "https://files.pythonhosted.org/packages/81/79/8bf142575a03e0af9c3d5f8bcae911ee6683ae93a625d349d4ecf4c8f7df/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1a7bac2bde1d661fb31f4d4e8e539e178774b76db3c2c17c4bb3e960a5de07a2", size = 630663, upload-time = "2025-04-08T10:35:28.685Z" },
- { url = "https://files.pythonhosted.org/packages/f1/80/abe2e79f610e45c63a70d271caea90c49bbf93eb00fa947fa9b803a1d51f/watchfiles-1.0.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ab626da2fc1ac277bbf752446470b367f84b50295264d2d313e28dc4405d663", size = 625410, upload-time = "2025-04-08T10:35:30.42Z" },
- { url = "https://files.pythonhosted.org/packages/91/6f/bc7fbecb84a41a9069c2c6eb6319f7f7df113adf113e358c57fc1aff7ff5/watchfiles-1.0.5-cp312-cp312-win32.whl", hash = "sha256:9f4571a783914feda92018ef3901dab8caf5b029325b5fe4558c074582815249", size = 277965, upload-time = "2025-04-08T10:35:32.023Z" },
- { url = "https://files.pythonhosted.org/packages/99/a5/bf1c297ea6649ec59e935ab311f63d8af5faa8f0b86993e3282b984263e3/watchfiles-1.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:360a398c3a19672cf93527f7e8d8b60d8275119c5d900f2e184d32483117a705", size = 291693, upload-time = "2025-04-08T10:35:33.225Z" },
- { url = "https://files.pythonhosted.org/packages/7f/7b/fd01087cc21db5c47e5beae507b87965db341cce8a86f9eb12bf5219d4e0/watchfiles-1.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:1a2902ede862969077b97523987c38db28abbe09fb19866e711485d9fbf0d417", size = 283287, upload-time = "2025-04-08T10:35:34.568Z" },
+sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406, upload-time = "2025-06-15T19:06:59.42Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8b/78/7401154b78ab484ccaaeef970dc2af0cb88b5ba8a1b415383da444cdd8d3/watchfiles-1.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c9649dfc57cc1f9835551deb17689e8d44666315f2e82d337b9f07bd76ae3aa2", size = 405751, upload-time = "2025-06-15T19:05:07.679Z" },
+ { url = "https://files.pythonhosted.org/packages/76/63/e6c3dbc1f78d001589b75e56a288c47723de28c580ad715eb116639152b5/watchfiles-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:406520216186b99374cdb58bc48e34bb74535adec160c8459894884c983a149c", size = 397313, upload-time = "2025-06-15T19:05:08.764Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/a2/8afa359ff52e99af1632f90cbf359da46184207e893a5f179301b0c8d6df/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45350fd1dc75cd68d3d72c47f5b513cb0578da716df5fba02fff31c69d5f2d", size = 450792, upload-time = "2025-06-15T19:05:09.869Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/bf/7446b401667f5c64972a57a0233be1104157fc3abf72c4ef2666c1bd09b2/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11ee4444250fcbeb47459a877e5e80ed994ce8e8d20283857fc128be1715dac7", size = 458196, upload-time = "2025-06-15T19:05:11.91Z" },
+ { url = "https://files.pythonhosted.org/packages/58/2f/501ddbdfa3fa874ea5597c77eeea3d413579c29af26c1091b08d0c792280/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda8136e6a80bdea23e5e74e09df0362744d24ffb8cd59c4a95a6ce3d142f79c", size = 484788, upload-time = "2025-06-15T19:05:13.373Z" },
+ { url = "https://files.pythonhosted.org/packages/61/1e/9c18eb2eb5c953c96bc0e5f626f0e53cfef4bd19bd50d71d1a049c63a575/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b915daeb2d8c1f5cee4b970f2e2c988ce6514aace3c9296e58dd64dc9aa5d575", size = 597879, upload-time = "2025-06-15T19:05:14.725Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/6c/1467402e5185d89388b4486745af1e0325007af0017c3384cc786fff0542/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed8fc66786de8d0376f9f913c09e963c66e90ced9aa11997f93bdb30f7c872a8", size = 477447, upload-time = "2025-06-15T19:05:15.775Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/a1/ec0a606bde4853d6c4a578f9391eeb3684a9aea736a8eb217e3e00aa89a1/watchfiles-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe4371595edf78c41ef8ac8df20df3943e13defd0efcb732b2e393b5a8a7a71f", size = 453145, upload-time = "2025-06-15T19:05:17.17Z" },
+ { url = "https://files.pythonhosted.org/packages/90/b9/ef6f0c247a6a35d689fc970dc7f6734f9257451aefb30def5d100d6246a5/watchfiles-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b7c5f6fe273291f4d414d55b2c80d33c457b8a42677ad14b4b47ff025d0893e4", size = 626539, upload-time = "2025-06-15T19:05:18.557Z" },
+ { url = "https://files.pythonhosted.org/packages/34/44/6ffda5537085106ff5aaa762b0d130ac6c75a08015dd1621376f708c94de/watchfiles-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7738027989881e70e3723c75921f1efa45225084228788fc59ea8c6d732eb30d", size = 624472, upload-time = "2025-06-15T19:05:19.588Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/e3/71170985c48028fa3f0a50946916a14055e741db11c2e7bc2f3b61f4d0e3/watchfiles-1.1.0-cp311-cp311-win32.whl", hash = "sha256:622d6b2c06be19f6e89b1d951485a232e3b59618def88dbeda575ed8f0d8dbf2", size = 279348, upload-time = "2025-06-15T19:05:20.856Z" },
+ { url = "https://files.pythonhosted.org/packages/89/1b/3e39c68b68a7a171070f81fc2561d23ce8d6859659406842a0e4bebf3bba/watchfiles-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:48aa25e5992b61debc908a61ab4d3f216b64f44fdaa71eb082d8b2de846b7d12", size = 292607, upload-time = "2025-06-15T19:05:21.937Z" },
+ { url = "https://files.pythonhosted.org/packages/61/9f/2973b7539f2bdb6ea86d2c87f70f615a71a1fc2dba2911795cea25968aea/watchfiles-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:00645eb79a3faa70d9cb15c8d4187bb72970b2470e938670240c7998dad9f13a", size = 285056, upload-time = "2025-06-15T19:05:23.12Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/b8/858957045a38a4079203a33aaa7d23ea9269ca7761c8a074af3524fbb240/watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179", size = 402339, upload-time = "2025-06-15T19:05:24.516Z" },
+ { url = "https://files.pythonhosted.org/packages/80/28/98b222cca751ba68e88521fabd79a4fab64005fc5976ea49b53fa205d1fa/watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5", size = 394409, upload-time = "2025-06-15T19:05:25.469Z" },
+ { url = "https://files.pythonhosted.org/packages/86/50/dee79968566c03190677c26f7f47960aff738d32087087bdf63a5473e7df/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297", size = 450939, upload-time = "2025-06-15T19:05:26.494Z" },
+ { url = "https://files.pythonhosted.org/packages/40/45/a7b56fb129700f3cfe2594a01aa38d033b92a33dddce86c8dfdfc1247b72/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0", size = 457270, upload-time = "2025-06-15T19:05:27.466Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/c8/fa5ef9476b1d02dc6b5e258f515fcaaecf559037edf8b6feffcbc097c4b8/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e", size = 483370, upload-time = "2025-06-15T19:05:28.548Z" },
+ { url = "https://files.pythonhosted.org/packages/98/68/42cfcdd6533ec94f0a7aab83f759ec11280f70b11bfba0b0f885e298f9bd/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee", size = 598654, upload-time = "2025-06-15T19:05:29.997Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/74/b2a1544224118cc28df7e59008a929e711f9c68ce7d554e171b2dc531352/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd", size = 478667, upload-time = "2025-06-15T19:05:31.172Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/77/e3362fe308358dc9f8588102481e599c83e1b91c2ae843780a7ded939a35/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f", size = 452213, upload-time = "2025-06-15T19:05:32.299Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/17/c8f1a36540c9a1558d4faf08e909399e8133599fa359bf52ec8fcee5be6f/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4", size = 626718, upload-time = "2025-06-15T19:05:33.415Z" },
+ { url = "https://files.pythonhosted.org/packages/26/45/fb599be38b4bd38032643783d7496a26a6f9ae05dea1a42e58229a20ac13/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f", size = 623098, upload-time = "2025-06-15T19:05:34.534Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/e7/fdf40e038475498e160cd167333c946e45d8563ae4dd65caf757e9ffe6b4/watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd", size = 279209, upload-time = "2025-06-15T19:05:35.577Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/d3/3ae9d5124ec75143bdf088d436cba39812122edc47709cd2caafeac3266f/watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47", size = 292786, upload-time = "2025-06-15T19:05:36.559Z" },
+ { url = "https://files.pythonhosted.org/packages/26/2f/7dd4fc8b5f2b34b545e19629b4a018bfb1de23b3a496766a2c1165ca890d/watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6", size = 284343, upload-time = "2025-06-15T19:05:37.5Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/6b/686dcf5d3525ad17b384fd94708e95193529b460a1b7bf40851f1328ec6e/watchfiles-1.1.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0ece16b563b17ab26eaa2d52230c9a7ae46cf01759621f4fbbca280e438267b3", size = 406910, upload-time = "2025-06-15T19:06:49.335Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/d3/71c2dcf81dc1edcf8af9f4d8d63b1316fb0a2dd90cbfd427e8d9dd584a90/watchfiles-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c", size = 398816, upload-time = "2025-06-15T19:06:50.433Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/fa/12269467b2fc006f8fce4cd6c3acfa77491dd0777d2a747415f28ccc8c60/watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432", size = 451584, upload-time = "2025-06-15T19:06:51.834Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/d3/254cea30f918f489db09d6a8435a7de7047f8cb68584477a515f160541d6/watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792", size = 454009, upload-time = "2025-06-15T19:06:52.896Z" },
]
[[package]]
@@ -6395,7 +6420,7 @@ wheels = [
[[package]]
name = "weave"
-version = "0.51.50"
+version = "0.51.54"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
@@ -6409,12 +6434,11 @@ dependencies = [
{ name = "pydantic" },
{ name = "rich" },
{ name = "tenacity" },
- { name = "uuid-utils" },
{ name = "wandb" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/fe/d1/47ab7923eb389ec7b1ca0138d9929dc50bfd0dfac324f42167f99fa02798/weave-0.51.50.tar.gz", hash = "sha256:773434765a3230bf8f4dfe9e04f9c7dfd90b03f18bb5e069186ce67d1f7c4dd8", size = 410739, upload-time = "2025-06-02T21:20:45.208Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/fb/2b/bdac08ae2fa7f660e3fb02e9f4acec5a5683509decd8fbd1ad5641160d3a/weave-0.51.54.tar.gz", hash = "sha256:41aaaa770c0ac2259325dd6035e1bf96f47fb92dbd4eec54d3ef4847587cc061", size = 425873, upload-time = "2025-06-16T21:57:47.582Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/e4/4a/72ed6f8435759f44090c8ae81d3a50716f0c3e527e733a78e77b9a834372/weave-0.51.50-py3-none-any.whl", hash = "sha256:23fb74ec95f57fe30f31019f32f6626f39993aa228024eb9cf8fe83e58b698de", size = 524057, upload-time = "2025-06-02T21:20:43.205Z" },
+ { url = "https://files.pythonhosted.org/packages/48/4d/7cee23e5bf5faab149aeb7cca367a434c4aec1fa0cb1f5a1d20149a2bf6f/weave-0.51.54-py3-none-any.whl", hash = "sha256:7de2c0da8061bc007de2f74fb3dd2496d24337dff3723f057be49fcf53e0a3a2", size = 542168, upload-time = "2025-06-16T21:57:44.929Z" },
]
[[package]]
@@ -6548,20 +6572,20 @@ wheels = [
[[package]]
name = "xlrd"
-version = "2.0.1"
+version = "2.0.2"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a6/b3/19a2540d21dea5f908304375bd43f5ed7a4c28a370dc9122c565423e6b44/xlrd-2.0.1.tar.gz", hash = "sha256:f72f148f54442c6b056bf931dbc34f986fd0c3b0b6b5a58d013c9aef274d0c88", size = 100259, upload-time = "2020-12-11T10:14:22.201Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a6/0c/c2a72d51fe56e08a08acc85d13013558a2d793028ae7385448a6ccdfae64/xlrd-2.0.1-py2.py3-none-any.whl", hash = "sha256:6a33ee89877bd9abc1158129f6e94be74e2679636b8a205b43b85206c3f0bbdd", size = 96531, upload-time = "2020-12-11T10:14:20.877Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" },
]
[[package]]
name = "xlsxwriter"
-version = "3.2.3"
+version = "3.2.5"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a7/d1/e026d33dd5d552e5bf3a873dee54dad66b550230df8290d79394f09b2315/xlsxwriter-3.2.3.tar.gz", hash = "sha256:ad6fd41bdcf1b885876b1f6b7087560aecc9ae5a9cc2ba97dcac7ab2e210d3d5", size = 209135, upload-time = "2025-04-17T10:11:23.481Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/a7/47/7704bac42ac6fe1710ae099b70e6a1e68ed173ef14792b647808c357da43/xlsxwriter-3.2.5.tar.gz", hash = "sha256:7e88469d607cdc920151c0ab3ce9cf1a83992d4b7bc730c5ffdd1a12115a7dbe", size = 213306, upload-time = "2025-06-17T08:59:14.619Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/37/b1/a252d499f2760b314fcf264d2b36fcc4343a1ecdb25492b210cb0db70a68/XlsxWriter-3.2.3-py3-none-any.whl", hash = "sha256:593f8296e8a91790c6d0378ab08b064f34a642b3feb787cf6738236bd0a4860d", size = 169433, upload-time = "2025-04-17T10:11:21.329Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/34/a22e6664211f0c8879521328000bdcae9bf6dbafa94a923e531f6d5b3f73/xlsxwriter-3.2.5-py3-none-any.whl", hash = "sha256:4f4824234e1eaf9d95df9a8fe974585ff91d0f5e3d3f12ace5b71e443c1c6abd", size = 172347, upload-time = "2025-06-17T08:59:13.453Z" },
]
[[package]]
@@ -6621,23 +6645,23 @@ wheels = [
[[package]]
name = "zipp"
-version = "3.22.0"
+version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/12/b6/7b3d16792fdf94f146bed92be90b4eb4563569eca91513c8609aebf0c167/zipp-3.22.0.tar.gz", hash = "sha256:dd2f28c3ce4bc67507bfd3781d21b7bb2be31103b51a4553ad7d90b84e57ace5", size = 25257, upload-time = "2025-05-26T14:46:32.217Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/ad/da/f64669af4cae46f17b90798a827519ce3737d31dbafad65d391e49643dc4/zipp-3.22.0-py3-none-any.whl", hash = "sha256:fe208f65f2aca48b81f9e6fd8cf7b8b32c26375266b009b413d45306b6148343", size = 9796, upload-time = "2025-05-26T14:46:30.775Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
]
[[package]]
name = "zope-event"
-version = "5.0"
+version = "5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "setuptools" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/46/c2/427f1867bb96555d1d34342f1dd97f8c420966ab564d58d18469a1db8736/zope.event-5.0.tar.gz", hash = "sha256:bac440d8d9891b4068e2b5a2c5e2c9765a9df762944bda6955f96bb9b91e67cd", size = 17350, upload-time = "2023-06-23T06:28:35.709Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/8b/c7/31e6f40282a2c548602c177826df281177caf79efaa101dd14314fb4ee73/zope_event-5.1.tar.gz", hash = "sha256:a153660e0c228124655748e990396b9d8295d6e4f546fa1b34f3319e1c666e7f", size = 18632, upload-time = "2025-06-26T07:14:22.72Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/fe/42/f8dbc2b9ad59e927940325a22d6d3931d630c3644dae7e2369ef5d9ba230/zope.event-5.0-py3-none-any.whl", hash = "sha256:2832e95014f4db26c47a13fdaef84cef2f4df37e66b59d8f1f4a8f319a632c26", size = 6824, upload-time = "2023-06-23T06:28:32.652Z" },
+ { url = "https://files.pythonhosted.org/packages/00/ed/d8c3f56c1edb0ee9b51461dd08580382e9589850f769b69f0dedccff5215/zope_event-5.1-py3-none-any.whl", hash = "sha256:53de8f0e9f61dc0598141ac591f49b042b6d74784dab49971b9cc91d0f73a7df", size = 6905, upload-time = "2025-06-26T07:14:21.779Z" },
]
[[package]]
diff --git a/dev/mypy-check b/dev/mypy-check
index b1c2c969a8..8a2342730c 100755
--- a/dev/mypy-check
+++ b/dev/mypy-check
@@ -7,4 +7,4 @@ cd "$SCRIPT_DIR/.."
# run mypy checks
uv run --directory api --dev --with pip \
- python -m mypy --install-types --non-interactive ./
+ python -m mypy --install-types --non-interactive --exclude venv ./
diff --git a/docker/.env.example b/docker/.env.example
index 4cf5e202d0..e08a81e49e 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -47,6 +47,11 @@ APP_WEB_URL=
# ensuring port 5001 is externally accessible (see docker-compose.yaml).
FILES_URL=
+# INTERNAL_FILES_URL is used for plugin daemon communication within Docker network.
+# Set this to the internal Docker service URL for proper plugin file access.
+# Example: INTERNAL_FILES_URL=http://api:5001
+INTERNAL_FILES_URL=
+
# ------------------------------
# Server Configuration
# ------------------------------
@@ -209,6 +214,10 @@ SQLALCHEMY_POOL_SIZE=30
SQLALCHEMY_POOL_RECYCLE=3600
# Whether to print SQL, default is false.
SQLALCHEMY_ECHO=false
+# If True, will test connections for liveness upon each checkout
+SQLALCHEMY_POOL_PRE_PING=false
+# Whether to enable the Last in first out option or use default FIFO queue if is false
+SQLALCHEMY_POOL_USE_LIFO=false
# Maximum number of connections to the database
# Default is 100
@@ -285,6 +294,7 @@ BROKER_USE_SSL=false
# If you are using Redis Sentinel for high availability, configure the following settings.
CELERY_USE_SENTINEL=false
CELERY_SENTINEL_MASTER_NAME=
+CELERY_SENTINEL_PASSWORD=
CELERY_SENTINEL_SOCKET_TIMEOUT=0.1
# ------------------------------
@@ -399,7 +409,7 @@ SUPABASE_URL=your-server-url
# ------------------------------
# The type of vector store to use.
-# Supported values are `weaviate`, `qdrant`, `milvus`, `myscale`, `relyt`, `pgvector`, `pgvecto-rs`, `chroma`, `opensearch`, `oracle`, `tencent`, `elasticsearch`, `elasticsearch-ja`, `analyticdb`, `couchbase`, `vikingdb`, `oceanbase`, `opengauss`, `tablestore`,`vastbase`,`tidb`,`tidb_on_qdrant`,`baidu`,`lindorm`,`huawei_cloud`,`upstash`.
+# Supported values are `weaviate`, `qdrant`, `milvus`, `myscale`, `relyt`, `pgvector`, `pgvecto-rs`, `chroma`, `opensearch`, `oracle`, `tencent`, `elasticsearch`, `elasticsearch-ja`, `analyticdb`, `couchbase`, `vikingdb`, `oceanbase`, `opengauss`, `tablestore`,`vastbase`,`tidb`,`tidb_on_qdrant`,`baidu`,`lindorm`,`huawei_cloud`,`upstash`, `matrixone`.
VECTOR_STORE=weaviate
# The Weaviate endpoint URL. Only available when VECTOR_STORE is `weaviate`.
@@ -490,6 +500,13 @@ TIDB_VECTOR_USER=
TIDB_VECTOR_PASSWORD=
TIDB_VECTOR_DATABASE=dify
+# Matrixone vector configurations.
+MATRIXONE_HOST=matrixone
+MATRIXONE_PORT=6001
+MATRIXONE_USER=dump
+MATRIXONE_PASSWORD=111
+MATRIXONE_DATABASE=dify
+
# Tidb on qdrant configuration, only available when VECTOR_STORE is `tidb_on_qdrant`
TIDB_ON_QDRANT_URL=http://127.0.0.1
TIDB_ON_QDRANT_API_KEY=dify
@@ -719,10 +736,11 @@ NOTION_INTERNAL_SECRET=
# Mail related configuration
# ------------------------------
-# Mail type, support: resend, smtp
+# Mail type, support: resend, smtp, sendgrid
MAIL_TYPE=resend
# Default send from email address, if not specified
+# If using SendGrid, use the 'from' field for authentication if necessary.
MAIL_DEFAULT_SEND_FROM=
# API-Key for the Resend email provider, used when MAIL_TYPE is `resend`.
@@ -738,6 +756,9 @@ SMTP_PASSWORD=
SMTP_USE_TLS=true
SMTP_OPPORTUNISTIC_TLS=false
+# Sendgid configuration
+SENDGRID_API_KEY=
+
# ------------------------------
# Others Configuration
# ------------------------------
@@ -782,11 +803,27 @@ WORKFLOW_FILE_UPLOAD_LIMIT=10
# hybrid: Save new data to object storage, read from both object storage and RDBMS
WORKFLOW_NODE_EXECUTION_STORAGE=rdbms
+# Repository configuration
+# Core workflow execution repository implementation
+CORE_WORKFLOW_EXECUTION_REPOSITORY=core.repositories.sqlalchemy_workflow_execution_repository.SQLAlchemyWorkflowExecutionRepository
+
+# Core workflow node execution repository implementation
+CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY=core.repositories.sqlalchemy_workflow_node_execution_repository.SQLAlchemyWorkflowNodeExecutionRepository
+
+# API workflow node execution repository implementation
+API_WORKFLOW_NODE_EXECUTION_REPOSITORY=repositories.sqlalchemy_api_workflow_node_execution_repository.DifyAPISQLAlchemyWorkflowNodeExecutionRepository
+
+# API workflow run repository implementation
+API_WORKFLOW_RUN_REPOSITORY=repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository
+
# HTTP request node in workflow configuration
HTTP_REQUEST_NODE_MAX_BINARY_SIZE=10485760
HTTP_REQUEST_NODE_MAX_TEXT_SIZE=1048576
HTTP_REQUEST_NODE_SSL_VERIFY=True
+# Respect X-* headers to redirect clients
+RESPECT_XFORWARD_HEADERS_ENABLED=false
+
# SSRF Proxy server HTTP URL
SSRF_PROXY_HTTP_URL=http://ssrf_proxy:3128
# SSRF Proxy server HTTPS URL
@@ -811,11 +848,15 @@ MAX_ITERATIONS_NUM=99
# The timeout for the text generation in millisecond
TEXT_GENERATION_TIMEOUT_MS=60000
+# Allow rendering unsafe URLs which have "data:" scheme.
+ALLOW_UNSAFE_DATA_SCHEME=false
+
# ------------------------------
# Environment Variables for db Service
# ------------------------------
-PGUSER=${DB_USERNAME}
+# The name of the default postgres user.
+POSTGRES_USER=${DB_USERNAME}
# The password for the default postgres user.
POSTGRES_PASSWORD=${DB_PASSWORD}
# The name of the default postgres database.
@@ -942,7 +983,7 @@ NGINX_SSL_PROTOCOLS=TLSv1.1 TLSv1.2 TLSv1.3
# Nginx performance tuning
NGINX_WORKER_PROCESSES=auto
-NGINX_CLIENT_MAX_BODY_SIZE=15M
+NGINX_CLIENT_MAX_BODY_SIZE=100M
NGINX_KEEPALIVE_TIMEOUT=65
# Proxy settings
@@ -1067,6 +1108,7 @@ PLUGIN_MEDIA_CACHE_PATH=assets
# Plugin oss bucket
PLUGIN_STORAGE_OSS_BUCKET=
# Plugin oss s3 credentials
+PLUGIN_S3_USE_AWS=false
PLUGIN_S3_USE_AWS_MANAGED_IAM=false
PLUGIN_S3_ENDPOINT=
PLUGIN_S3_USE_PATH_STYLE=false
diff --git a/docker/docker-compose-template.yaml b/docker/docker-compose-template.yaml
index a409a729ce..7c1544acb9 100644
--- a/docker/docker-compose-template.yaml
+++ b/docker/docker-compose-template.yaml
@@ -2,7 +2,7 @@ x-shared-env: &shared-api-worker-env
services:
# API service
api:
- image: langgenius/dify-api:1.4.2
+ image: langgenius/dify-api:1.6.0
restart: always
environment:
# Use the shared environment variables.
@@ -31,7 +31,7 @@ services:
# worker service
# The Celery worker for processing the queue.
worker:
- image: langgenius/dify-api:1.4.2
+ image: langgenius/dify-api:1.6.0
restart: always
environment:
# Use the shared environment variables.
@@ -57,7 +57,7 @@ services:
# Frontend web application.
web:
- image: langgenius/dify-web:1.4.2
+ image: langgenius/dify-web:1.6.0
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@@ -67,6 +67,7 @@ services:
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
CSP_WHITELIST: ${CSP_WHITELIST:-}
ALLOW_EMBED: ${ALLOW_EMBED:-false}
+ ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false}
MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai}
MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai}
TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-}
@@ -84,7 +85,7 @@ services:
image: postgres:15-alpine
restart: always
environment:
- PGUSER: ${PGUSER:-postgres}
+ POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-difyai123456}
POSTGRES_DB: ${POSTGRES_DB:-dify}
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
@@ -142,7 +143,7 @@ services:
# plugin daemon
plugin_daemon:
- image: langgenius/dify-plugin-daemon:0.1.2-local
+ image: langgenius/dify-plugin-daemon:0.1.3-local
restart: always
environment:
# Use the shared environment variables.
@@ -168,6 +169,7 @@ services:
PLUGIN_MEDIA_CACHE_PATH: ${PLUGIN_MEDIA_CACHE_PATH:-assets}
PLUGIN_STORAGE_OSS_BUCKET: ${PLUGIN_STORAGE_OSS_BUCKET:-}
S3_USE_AWS_MANAGED_IAM: ${PLUGIN_S3_USE_AWS_MANAGED_IAM:-false}
+ S3_USE_AWS: ${PLUGIN_S3_USE_AWS:-false}
S3_ENDPOINT: ${PLUGIN_S3_ENDPOINT:-}
S3_USE_PATH_STYLE: ${PLUGIN_S3_USE_PATH_STYLE:-false}
AWS_ACCESS_KEY: ${PLUGIN_AWS_ACCESS_KEY:-}
@@ -264,7 +266,7 @@ services:
NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key}
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3}
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
- NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-15M}
+ NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s}
NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s}
@@ -434,7 +436,7 @@ services:
# OceanBase vector database
oceanbase:
- image: oceanbase/oceanbase-ce:4.3.5.1-101000042025031818
+ image: oceanbase/oceanbase-ce:4.3.5-lts
container_name: oceanbase
profiles:
- oceanbase
@@ -449,9 +451,15 @@ services:
OB_TENANT_PASSWORD: ${OCEANBASE_VECTOR_PASSWORD:-difyai123456}
OB_CLUSTER_NAME: ${OCEANBASE_CLUSTER_NAME:-difyai}
OB_SERVER_IP: 127.0.0.1
- MODE: MINI
+ MODE: mini
ports:
- "${OCEANBASE_VECTOR_PORT:-2881}:2881"
+ healthcheck:
+ test: [ 'CMD-SHELL', 'obclient -h127.0.0.1 -P2881 -uroot@test -p$${OB_TENANT_PASSWORD} -e "SELECT 1;"' ]
+ interval: 10s
+ retries: 30
+ start_period: 30s
+ timeout: 10s
# Oracle vector database
oracle:
@@ -610,6 +618,18 @@ services:
ports:
- ${MYSCALE_PORT:-8123}:${MYSCALE_PORT:-8123}
+ # Matrixone vector store.
+ matrixone:
+ hostname: matrixone
+ image: matrixorigin/matrixone:2.1.1
+ profiles:
+ - matrixone
+ restart: always
+ volumes:
+ - ./volumes/matrixone/data:/mo-data
+ ports:
+ - ${MATRIXONE_PORT:-6001}:${MATRIXONE_PORT:-6001}
+
# https://www.elastic.co/guide/en/elasticsearch/reference/current/settings.html
# https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#docker-prod-prerequisites
elasticsearch:
diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml
index dceee484ca..0b1885755b 100644
--- a/docker/docker-compose.middleware.yaml
+++ b/docker/docker-compose.middleware.yaml
@@ -71,7 +71,7 @@ services:
# plugin daemon
plugin_daemon:
- image: langgenius/dify-plugin-daemon:0.1.2-local
+ image: langgenius/dify-plugin-daemon:0.1.3-local
restart: always
env_file:
- ./middleware.env
@@ -104,6 +104,7 @@ services:
PLUGIN_PACKAGE_CACHE_PATH: ${PLUGIN_PACKAGE_CACHE_PATH:-plugin_packages}
PLUGIN_MEDIA_CACHE_PATH: ${PLUGIN_MEDIA_CACHE_PATH:-assets}
PLUGIN_STORAGE_OSS_BUCKET: ${PLUGIN_STORAGE_OSS_BUCKET:-}
+ S3_USE_AWS: ${PLUGIN_S3_USE_AWS:-false}
S3_USE_AWS_MANAGED_IAM: ${PLUGIN_S3_USE_AWS_MANAGED_IAM:-false}
S3_ENDPOINT: ${PLUGIN_S3_ENDPOINT:-}
S3_USE_PATH_STYLE: ${PLUGIN_S3_USE_PATH_STYLE:-false}
diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml
index d927334118..73e061e770 100644
--- a/docker/docker-compose.yaml
+++ b/docker/docker-compose.yaml
@@ -11,6 +11,7 @@ x-shared-env: &shared-api-worker-env
APP_API_URL: ${APP_API_URL:-}
APP_WEB_URL: ${APP_WEB_URL:-}
FILES_URL: ${FILES_URL:-}
+ INTERNAL_FILES_URL: ${INTERNAL_FILES_URL:-}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_FILE: ${LOG_FILE:-/app/logs/server.log}
LOG_FILE_MAX_SIZE: ${LOG_FILE_MAX_SIZE:-20}
@@ -55,6 +56,8 @@ x-shared-env: &shared-api-worker-env
SQLALCHEMY_POOL_SIZE: ${SQLALCHEMY_POOL_SIZE:-30}
SQLALCHEMY_POOL_RECYCLE: ${SQLALCHEMY_POOL_RECYCLE:-3600}
SQLALCHEMY_ECHO: ${SQLALCHEMY_ECHO:-false}
+ SQLALCHEMY_POOL_PRE_PING: ${SQLALCHEMY_POOL_PRE_PING:-false}
+ SQLALCHEMY_POOL_USE_LIFO: ${SQLALCHEMY_POOL_USE_LIFO:-false}
POSTGRES_MAX_CONNECTIONS: ${POSTGRES_MAX_CONNECTIONS:-100}
POSTGRES_SHARED_BUFFERS: ${POSTGRES_SHARED_BUFFERS:-128MB}
POSTGRES_WORK_MEM: ${POSTGRES_WORK_MEM:-4MB}
@@ -79,6 +82,7 @@ x-shared-env: &shared-api-worker-env
BROKER_USE_SSL: ${BROKER_USE_SSL:-false}
CELERY_USE_SENTINEL: ${CELERY_USE_SENTINEL:-false}
CELERY_SENTINEL_MASTER_NAME: ${CELERY_SENTINEL_MASTER_NAME:-}
+ CELERY_SENTINEL_PASSWORD: ${CELERY_SENTINEL_PASSWORD:-}
CELERY_SENTINEL_SOCKET_TIMEOUT: ${CELERY_SENTINEL_SOCKET_TIMEOUT:-0.1}
WEB_API_CORS_ALLOW_ORIGINS: ${WEB_API_CORS_ALLOW_ORIGINS:-*}
CONSOLE_CORS_ALLOW_ORIGINS: ${CONSOLE_CORS_ALLOW_ORIGINS:-*}
@@ -195,6 +199,11 @@ x-shared-env: &shared-api-worker-env
TIDB_VECTOR_USER: ${TIDB_VECTOR_USER:-}
TIDB_VECTOR_PASSWORD: ${TIDB_VECTOR_PASSWORD:-}
TIDB_VECTOR_DATABASE: ${TIDB_VECTOR_DATABASE:-dify}
+ MATRIXONE_HOST: ${MATRIXONE_HOST:-matrixone}
+ MATRIXONE_PORT: ${MATRIXONE_PORT:-6001}
+ MATRIXONE_USER: ${MATRIXONE_USER:-dump}
+ MATRIXONE_PASSWORD: ${MATRIXONE_PASSWORD:-111}
+ MATRIXONE_DATABASE: ${MATRIXONE_DATABASE:-dify}
TIDB_ON_QDRANT_URL: ${TIDB_ON_QDRANT_URL:-http://127.0.0.1}
TIDB_ON_QDRANT_API_KEY: ${TIDB_ON_QDRANT_API_KEY:-dify}
TIDB_ON_QDRANT_CLIENT_TIMEOUT: ${TIDB_ON_QDRANT_CLIENT_TIMEOUT:-20}
@@ -322,6 +331,7 @@ x-shared-env: &shared-api-worker-env
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_USE_TLS: ${SMTP_USE_TLS:-true}
SMTP_OPPORTUNISTIC_TLS: ${SMTP_OPPORTUNISTIC_TLS:-false}
+ SENDGRID_API_KEY: ${SENDGRID_API_KEY:-}
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000}
INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72}
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES: ${RESET_PASSWORD_TOKEN_EXPIRY_MINUTES:-5}
@@ -346,9 +356,14 @@ x-shared-env: &shared-api-worker-env
WORKFLOW_PARALLEL_DEPTH_LIMIT: ${WORKFLOW_PARALLEL_DEPTH_LIMIT:-3}
WORKFLOW_FILE_UPLOAD_LIMIT: ${WORKFLOW_FILE_UPLOAD_LIMIT:-10}
WORKFLOW_NODE_EXECUTION_STORAGE: ${WORKFLOW_NODE_EXECUTION_STORAGE:-rdbms}
+ CORE_WORKFLOW_EXECUTION_REPOSITORY: ${CORE_WORKFLOW_EXECUTION_REPOSITORY:-core.repositories.sqlalchemy_workflow_execution_repository.SQLAlchemyWorkflowExecutionRepository}
+ CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY: ${CORE_WORKFLOW_NODE_EXECUTION_REPOSITORY:-core.repositories.sqlalchemy_workflow_node_execution_repository.SQLAlchemyWorkflowNodeExecutionRepository}
+ API_WORKFLOW_NODE_EXECUTION_REPOSITORY: ${API_WORKFLOW_NODE_EXECUTION_REPOSITORY:-repositories.sqlalchemy_api_workflow_node_execution_repository.DifyAPISQLAlchemyWorkflowNodeExecutionRepository}
+ API_WORKFLOW_RUN_REPOSITORY: ${API_WORKFLOW_RUN_REPOSITORY:-repositories.sqlalchemy_api_workflow_run_repository.DifyAPISQLAlchemyWorkflowRunRepository}
HTTP_REQUEST_NODE_MAX_BINARY_SIZE: ${HTTP_REQUEST_NODE_MAX_BINARY_SIZE:-10485760}
HTTP_REQUEST_NODE_MAX_TEXT_SIZE: ${HTTP_REQUEST_NODE_MAX_TEXT_SIZE:-1048576}
HTTP_REQUEST_NODE_SSL_VERIFY: ${HTTP_REQUEST_NODE_SSL_VERIFY:-True}
+ RESPECT_XFORWARD_HEADERS_ENABLED: ${RESPECT_XFORWARD_HEADERS_ENABLED:-false}
SSRF_PROXY_HTTP_URL: ${SSRF_PROXY_HTTP_URL:-http://ssrf_proxy:3128}
SSRF_PROXY_HTTPS_URL: ${SSRF_PROXY_HTTPS_URL:-http://ssrf_proxy:3128}
LOOP_NODE_MAX_COUNT: ${LOOP_NODE_MAX_COUNT:-100}
@@ -356,7 +371,8 @@ x-shared-env: &shared-api-worker-env
MAX_PARALLEL_LIMIT: ${MAX_PARALLEL_LIMIT:-10}
MAX_ITERATIONS_NUM: ${MAX_ITERATIONS_NUM:-99}
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
- PGUSER: ${PGUSER:-${DB_USERNAME}}
+ ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false}
+ POSTGRES_USER: ${POSTGRES_USER:-${DB_USERNAME}}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-${DB_PASSWORD}}
POSTGRES_DB: ${POSTGRES_DB:-${DB_DATABASE}}
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
@@ -412,7 +428,7 @@ x-shared-env: &shared-api-worker-env
NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key}
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3}
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
- NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-15M}
+ NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s}
NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s}
@@ -467,6 +483,7 @@ x-shared-env: &shared-api-worker-env
PLUGIN_PACKAGE_CACHE_PATH: ${PLUGIN_PACKAGE_CACHE_PATH:-plugin_packages}
PLUGIN_MEDIA_CACHE_PATH: ${PLUGIN_MEDIA_CACHE_PATH:-assets}
PLUGIN_STORAGE_OSS_BUCKET: ${PLUGIN_STORAGE_OSS_BUCKET:-}
+ PLUGIN_S3_USE_AWS: ${PLUGIN_S3_USE_AWS:-false}
PLUGIN_S3_USE_AWS_MANAGED_IAM: ${PLUGIN_S3_USE_AWS_MANAGED_IAM:-false}
PLUGIN_S3_ENDPOINT: ${PLUGIN_S3_ENDPOINT:-}
PLUGIN_S3_USE_PATH_STYLE: ${PLUGIN_S3_USE_PATH_STYLE:-false}
@@ -508,7 +525,7 @@ x-shared-env: &shared-api-worker-env
services:
# API service
api:
- image: langgenius/dify-api:1.4.2
+ image: langgenius/dify-api:1.6.0
restart: always
environment:
# Use the shared environment variables.
@@ -537,7 +554,7 @@ services:
# worker service
# The Celery worker for processing the queue.
worker:
- image: langgenius/dify-api:1.4.2
+ image: langgenius/dify-api:1.6.0
restart: always
environment:
# Use the shared environment variables.
@@ -563,7 +580,7 @@ services:
# Frontend web application.
web:
- image: langgenius/dify-web:1.4.2
+ image: langgenius/dify-web:1.6.0
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@@ -573,6 +590,7 @@ services:
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
CSP_WHITELIST: ${CSP_WHITELIST:-}
ALLOW_EMBED: ${ALLOW_EMBED:-false}
+ ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false}
MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai}
MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai}
TOP_K_MAX_VALUE: ${TOP_K_MAX_VALUE:-}
@@ -590,7 +608,7 @@ services:
image: postgres:15-alpine
restart: always
environment:
- PGUSER: ${PGUSER:-postgres}
+ POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-difyai123456}
POSTGRES_DB: ${POSTGRES_DB:-dify}
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
@@ -648,7 +666,7 @@ services:
# plugin daemon
plugin_daemon:
- image: langgenius/dify-plugin-daemon:0.1.2-local
+ image: langgenius/dify-plugin-daemon:0.1.3-local
restart: always
environment:
# Use the shared environment variables.
@@ -674,6 +692,7 @@ services:
PLUGIN_MEDIA_CACHE_PATH: ${PLUGIN_MEDIA_CACHE_PATH:-assets}
PLUGIN_STORAGE_OSS_BUCKET: ${PLUGIN_STORAGE_OSS_BUCKET:-}
S3_USE_AWS_MANAGED_IAM: ${PLUGIN_S3_USE_AWS_MANAGED_IAM:-false}
+ S3_USE_AWS: ${PLUGIN_S3_USE_AWS:-false}
S3_ENDPOINT: ${PLUGIN_S3_ENDPOINT:-}
S3_USE_PATH_STYLE: ${PLUGIN_S3_USE_PATH_STYLE:-false}
AWS_ACCESS_KEY: ${PLUGIN_AWS_ACCESS_KEY:-}
@@ -770,7 +789,7 @@ services:
NGINX_SSL_CERT_KEY_FILENAME: ${NGINX_SSL_CERT_KEY_FILENAME:-dify.key}
NGINX_SSL_PROTOCOLS: ${NGINX_SSL_PROTOCOLS:-TLSv1.1 TLSv1.2 TLSv1.3}
NGINX_WORKER_PROCESSES: ${NGINX_WORKER_PROCESSES:-auto}
- NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-15M}
+ NGINX_CLIENT_MAX_BODY_SIZE: ${NGINX_CLIENT_MAX_BODY_SIZE:-100M}
NGINX_KEEPALIVE_TIMEOUT: ${NGINX_KEEPALIVE_TIMEOUT:-65}
NGINX_PROXY_READ_TIMEOUT: ${NGINX_PROXY_READ_TIMEOUT:-3600s}
NGINX_PROXY_SEND_TIMEOUT: ${NGINX_PROXY_SEND_TIMEOUT:-3600s}
@@ -940,7 +959,7 @@ services:
# OceanBase vector database
oceanbase:
- image: oceanbase/oceanbase-ce:4.3.5.1-101000042025031818
+ image: oceanbase/oceanbase-ce:4.3.5-lts
container_name: oceanbase
profiles:
- oceanbase
@@ -955,9 +974,15 @@ services:
OB_TENANT_PASSWORD: ${OCEANBASE_VECTOR_PASSWORD:-difyai123456}
OB_CLUSTER_NAME: ${OCEANBASE_CLUSTER_NAME:-difyai}
OB_SERVER_IP: 127.0.0.1
- MODE: MINI
+ MODE: mini
ports:
- "${OCEANBASE_VECTOR_PORT:-2881}:2881"
+ healthcheck:
+ test: [ 'CMD-SHELL', 'obclient -h127.0.0.1 -P2881 -uroot@test -p$${OB_TENANT_PASSWORD} -e "SELECT 1;"' ]
+ interval: 10s
+ retries: 30
+ start_period: 30s
+ timeout: 10s
# Oracle vector database
oracle:
@@ -1116,6 +1141,18 @@ services:
ports:
- ${MYSCALE_PORT:-8123}:${MYSCALE_PORT:-8123}
+ # Matrixone vector store.
+ matrixone:
+ hostname: matrixone
+ image: matrixorigin/matrixone:2.1.1
+ profiles:
+ - matrixone
+ restart: always
+ volumes:
+ - ./volumes/matrixone/data:/mo-data
+ ports:
+ - ${MATRIXONE_PORT:-6001}:${MATRIXONE_PORT:-6001}
+
# https://www.elastic.co/guide/en/elasticsearch/reference/current/settings.html
# https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#docker-prod-prerequisites
elasticsearch:
diff --git a/docker/middleware.env.example b/docker/middleware.env.example
index 66037f281c..2eba62f594 100644
--- a/docker/middleware.env.example
+++ b/docker/middleware.env.example
@@ -1,7 +1,7 @@
# ------------------------------
# Environment Variables for db Service
# ------------------------------
-PGUSER=postgres
+POSTGRES_USER=postgres
# The password for the default postgres user.
POSTGRES_PASSWORD=difyai123456
# The name of the default postgres database.
@@ -133,6 +133,7 @@ PLUGIN_MEDIA_CACHE_PATH=assets
PLUGIN_STORAGE_OSS_BUCKET=
# Plugin oss s3 credentials
PLUGIN_S3_USE_AWS_MANAGED_IAM=false
+PLUGIN_S3_USE_AWS=false
PLUGIN_S3_ENDPOINT=
PLUGIN_S3_USE_PATH_STYLE=false
PLUGIN_AWS_ACCESS_KEY=
diff --git a/docker/nginx/conf.d/default.conf.template b/docker/nginx/conf.d/default.conf.template
index a458412d1e..48d7da8cf5 100644
--- a/docker/nginx/conf.d/default.conf.template
+++ b/docker/nginx/conf.d/default.conf.template
@@ -39,7 +39,10 @@ server {
proxy_pass http://web:3000;
include proxy.conf;
}
-
+ location /mcp {
+ proxy_pass http://api:5001;
+ include proxy.conf;
+ }
# placeholder for acme challenge location
${ACME_CHALLENGE_LOCATION}
diff --git a/images/GitHub_README_if.png b/images/GitHub_README_if.png
index 2a4e67264e..10c9d87b08 100644
Binary files a/images/GitHub_README_if.png and b/images/GitHub_README_if.png differ
diff --git a/tests/unit_tests/events/test_provider_update_deadlock_prevention.py b/tests/unit_tests/events/test_provider_update_deadlock_prevention.py
new file mode 100644
index 0000000000..47c175acd7
--- /dev/null
+++ b/tests/unit_tests/events/test_provider_update_deadlock_prevention.py
@@ -0,0 +1,248 @@
+import threading
+from unittest.mock import Mock, patch
+
+from core.app.entities.app_invoke_entities import ChatAppGenerateEntity
+from core.entities.provider_entities import QuotaUnit
+from events.event_handlers.update_provider_when_message_created import (
+ handle,
+ get_update_stats,
+)
+from models.provider import ProviderType
+from sqlalchemy.exc import OperationalError
+
+
+class TestProviderUpdateDeadlockPrevention:
+ """Test suite for deadlock prevention in Provider updates."""
+
+ def setup_method(self):
+ """Setup test fixtures."""
+ self.mock_message = Mock()
+ self.mock_message.answer_tokens = 100
+
+ self.mock_app_config = Mock()
+ self.mock_app_config.tenant_id = "test-tenant-123"
+
+ self.mock_model_conf = Mock()
+ self.mock_model_conf.provider = "openai"
+
+ self.mock_system_config = Mock()
+ self.mock_system_config.current_quota_type = QuotaUnit.TOKENS
+
+ self.mock_provider_config = Mock()
+ self.mock_provider_config.using_provider_type = ProviderType.SYSTEM
+ self.mock_provider_config.system_configuration = self.mock_system_config
+
+ self.mock_provider_bundle = Mock()
+ self.mock_provider_bundle.configuration = self.mock_provider_config
+
+ self.mock_model_conf.provider_model_bundle = self.mock_provider_bundle
+
+ self.mock_generate_entity = Mock(spec=ChatAppGenerateEntity)
+ self.mock_generate_entity.app_config = self.mock_app_config
+ self.mock_generate_entity.model_conf = self.mock_model_conf
+
+ @patch("events.event_handlers.update_provider_when_message_created.db")
+ def test_consolidated_handler_basic_functionality(self, mock_db):
+ """Test that the consolidated handler performs both updates correctly."""
+ # Setup mock query chain
+ mock_query = Mock()
+ mock_db.session.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.order_by.return_value = mock_query
+ mock_query.update.return_value = 1 # 1 row affected
+
+ # Call the handler
+ handle(self.mock_message, application_generate_entity=self.mock_generate_entity)
+
+ # Verify db.session.query was called
+ assert mock_db.session.query.called
+
+ # Verify commit was called
+ mock_db.session.commit.assert_called_once()
+
+ # Verify no rollback was called
+ assert not mock_db.session.rollback.called
+
+ @patch("events.event_handlers.update_provider_when_message_created.db")
+ def test_deadlock_retry_mechanism(self, mock_db):
+ """Test that deadlock errors trigger retry logic."""
+ # Setup mock to raise deadlock error on first attempt, succeed on second
+ mock_query = Mock()
+ mock_db.session.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.order_by.return_value = mock_query
+ mock_query.update.return_value = 1
+
+ # First call raises deadlock, second succeeds
+ mock_db.session.commit.side_effect = [
+ OperationalError("deadlock detected", None, None),
+ None, # Success on retry
+ ]
+
+ # Call the handler
+ handle(self.mock_message, application_generate_entity=self.mock_generate_entity)
+
+ # Verify commit was called twice (original + retry)
+ assert mock_db.session.commit.call_count == 2
+
+ # Verify rollback was called once (after first failure)
+ mock_db.session.rollback.assert_called_once()
+
+ @patch("events.event_handlers.update_provider_when_message_created.db")
+ @patch("events.event_handlers.update_provider_when_message_created.time.sleep")
+ def test_exponential_backoff_timing(self, mock_sleep, mock_db):
+ """Test that retry delays follow exponential backoff pattern."""
+ # Setup mock to fail twice, succeed on third attempt
+ mock_query = Mock()
+ mock_db.session.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.order_by.return_value = mock_query
+ mock_query.update.return_value = 1
+
+ mock_db.session.commit.side_effect = [
+ OperationalError("deadlock detected", None, None),
+ OperationalError("deadlock detected", None, None),
+ None, # Success on third attempt
+ ]
+
+ # Call the handler
+ handle(self.mock_message, application_generate_entity=self.mock_generate_entity)
+
+ # Verify sleep was called twice with increasing delays
+ assert mock_sleep.call_count == 2
+
+ # First delay should be around 0.1s + jitter
+ first_delay = mock_sleep.call_args_list[0][0][0]
+ assert 0.1 <= first_delay <= 0.3
+
+ # Second delay should be around 0.2s + jitter
+ second_delay = mock_sleep.call_args_list[1][0][0]
+ assert 0.2 <= second_delay <= 0.4
+
+ def test_concurrent_handler_execution(self):
+ """Test that multiple handlers can run concurrently without deadlock."""
+ results = []
+ errors = []
+
+ def run_handler():
+ try:
+ with patch(
+ "events.event_handlers.update_provider_when_message_created.db"
+ ) as mock_db:
+ mock_query = Mock()
+ mock_db.session.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.order_by.return_value = mock_query
+ mock_query.update.return_value = 1
+
+ handle(
+ self.mock_message,
+ application_generate_entity=self.mock_generate_entity,
+ )
+ results.append("success")
+ except Exception as e:
+ errors.append(str(e))
+
+ # Run multiple handlers concurrently
+ threads = []
+ for _ in range(5):
+ thread = threading.Thread(target=run_handler)
+ threads.append(thread)
+ thread.start()
+
+ # Wait for all threads to complete
+ for thread in threads:
+ thread.join(timeout=5)
+
+ # Verify all handlers completed successfully
+ assert len(results) == 5
+ assert len(errors) == 0
+
+ def test_performance_stats_tracking(self):
+ """Test that performance statistics are tracked correctly."""
+ # Reset stats
+ stats = get_update_stats()
+ initial_total = stats["total_updates"]
+
+ with patch(
+ "events.event_handlers.update_provider_when_message_created.db"
+ ) as mock_db:
+ mock_query = Mock()
+ mock_db.session.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.order_by.return_value = mock_query
+ mock_query.update.return_value = 1
+
+ # Call handler
+ handle(
+ self.mock_message, application_generate_entity=self.mock_generate_entity
+ )
+
+ # Check that stats were updated
+ updated_stats = get_update_stats()
+ assert updated_stats["total_updates"] == initial_total + 1
+ assert updated_stats["successful_updates"] >= initial_total + 1
+
+ def test_non_chat_entity_ignored(self):
+ """Test that non-chat entities are ignored by the handler."""
+ # Create a non-chat entity
+ mock_non_chat_entity = Mock()
+ mock_non_chat_entity.__class__.__name__ = "NonChatEntity"
+
+ with patch(
+ "events.event_handlers.update_provider_when_message_created.db"
+ ) as mock_db:
+ # Call handler with non-chat entity
+ handle(self.mock_message, application_generate_entity=mock_non_chat_entity)
+
+ # Verify no database operations were performed
+ assert not mock_db.session.query.called
+ assert not mock_db.session.commit.called
+
+ @patch("events.event_handlers.update_provider_when_message_created.db")
+ def test_quota_calculation_tokens(self, mock_db):
+ """Test quota calculation for token-based quotas."""
+ # Setup token-based quota
+ self.mock_system_config.current_quota_type = QuotaUnit.TOKENS
+ self.mock_message.answer_tokens = 150
+
+ mock_query = Mock()
+ mock_db.session.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.order_by.return_value = mock_query
+ mock_query.update.return_value = 1
+
+ # Call handler
+ handle(self.mock_message, application_generate_entity=self.mock_generate_entity)
+
+ # Verify update was called with token count
+ update_calls = mock_query.update.call_args_list
+
+ # Should have at least one call with quota_used update
+ quota_update_found = False
+ for call in update_calls:
+ values = call[0][0] # First argument to update()
+ if "quota_used" in values:
+ quota_update_found = True
+ break
+
+ assert quota_update_found
+
+ @patch("events.event_handlers.update_provider_when_message_created.db")
+ def test_quota_calculation_times(self, mock_db):
+ """Test quota calculation for times-based quotas."""
+ # Setup times-based quota
+ self.mock_system_config.current_quota_type = QuotaUnit.TIMES
+
+ mock_query = Mock()
+ mock_db.session.query.return_value = mock_query
+ mock_query.filter.return_value = mock_query
+ mock_query.order_by.return_value = mock_query
+ mock_query.update.return_value = 1
+
+ # Call handler
+ handle(self.mock_message, application_generate_entity=self.mock_generate_entity)
+
+ # Verify update was called
+ assert mock_query.update.called
+ assert mock_db.session.commit.called
diff --git a/web/.env.example b/web/.env.example
index 78b4f33e8c..37bfc939eb 100644
--- a/web/.env.example
+++ b/web/.env.example
@@ -32,6 +32,9 @@ NEXT_PUBLIC_CSP_WHITELIST=
# Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking
NEXT_PUBLIC_ALLOW_EMBED=
+# Allow rendering unsafe URLs which have "data:" scheme.
+NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME=false
+
# Github Access Token, used for invoking Github API
NEXT_PUBLIC_GITHUB_ACCESS_TOKEN=
# The maximum number of top-k value for RAG.
@@ -56,3 +59,5 @@ NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER=true
NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL=true
NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL=true
+# The maximum number of tree node depth for workflow
+NEXT_PUBLIC_MAX_TREE_DEPTH=50
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx
index 084adceef2..3d572b926a 100644
--- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx
+++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppCard from '@/app/components/app/overview/appCard'
import Loading from '@/app/components/base/loading'
+import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card'
import { ToastContext } from '@/app/components/base/toast'
import {
fetchAppDetail,
@@ -31,6 +32,8 @@ const CardView: FC = ({ appId, isInPanel, className }) => {
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
+ const showMCPCard = isInPanel
+
const updateAppDetail = async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appId })
@@ -117,6 +120,11 @@ const CardView: FC = ({ appId, isInPanel, className }) => {
isInPanel={isInPanel}
onChangeStatus={onChangeApiStatus}
/>
+ {showMCPCard && (
+
+ )}
)
}
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx
index fc97f5e669..e0c09e739e 100644
--- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx
+++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/page.tsx
@@ -15,7 +15,7 @@ const Overview = async (props: IDevelopProps) => {
} = params
return (
-
+
void
chosenProvider: TracingProvider | null
onChooseProvider: (provider: TracingProvider) => void
+ arizeConfig: ArizeConfig | null
+ phoenixConfig: PhoenixConfig | null
langSmithConfig: LangSmithConfig | null
langFuseConfig: LangFuseConfig | null
opikConfig: OpikConfig | null
weaveConfig: WeaveConfig | null
- onConfigUpdated: (provider: TracingProvider, payload: LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig) => void
+ aliyunConfig: AliyunConfig | null
+ onConfigUpdated: (provider: TracingProvider, payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => void
onConfigRemoved: (provider: TracingProvider) => void
}
@@ -38,10 +41,13 @@ const ConfigPopup: FC = ({
onStatusChange,
chosenProvider,
onChooseProvider,
+ arizeConfig,
+ phoenixConfig,
langSmithConfig,
langFuseConfig,
opikConfig,
weaveConfig,
+ aliyunConfig,
onConfigUpdated,
onConfigRemoved,
}) => {
@@ -65,7 +71,7 @@ const ConfigPopup: FC = ({
}
}, [onChooseProvider])
- const handleConfigUpdated = useCallback((payload: LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig) => {
+ const handleConfigUpdated = useCallback((payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => {
onConfigUpdated(currentProvider!, payload)
hideConfigModal()
}, [currentProvider, hideConfigModal, onConfigUpdated])
@@ -75,8 +81,8 @@ const ConfigPopup: FC = ({
hideConfigModal()
}, [currentProvider, hideConfigModal, onConfigRemoved])
- const providerAllConfigured = langSmithConfig && langFuseConfig && opikConfig && weaveConfig
- const providerAllNotConfigured = !langSmithConfig && !langFuseConfig && !opikConfig && !weaveConfig
+ const providerAllConfigured = arizeConfig && phoenixConfig && langSmithConfig && langFuseConfig && opikConfig && weaveConfig && aliyunConfig
+ const providerAllNotConfigured = !arizeConfig && !phoenixConfig && !langSmithConfig && !langFuseConfig && !opikConfig && !weaveConfig && !aliyunConfig
const switchContent = (
= ({
disabled={providerAllNotConfigured}
/>
)
+ const arizePanel = (
+
+ )
+
+ const phoenixPanel = (
+
+ )
+
const langSmithPanel = (
= ({
key="weave-provider-panel"
/>
)
+
+ const aliyunPanel = (
+
+ )
const configuredProviderPanel = () => {
const configuredPanels: JSX.Element[] = []
@@ -152,12 +197,27 @@ const ConfigPopup: FC = ({
if (weaveConfig)
configuredPanels.push(weavePanel)
+ if (arizeConfig)
+ configuredPanels.push(arizePanel)
+
+ if (phoenixConfig)
+ configuredPanels.push(phoenixPanel)
+
+ if (aliyunConfig)
+ configuredPanels.push(aliyunPanel)
+
return configuredPanels
}
const moreProviderPanel = () => {
const notConfiguredPanels: JSX.Element[] = []
+ if (!arizeConfig)
+ notConfiguredPanels.push(arizePanel)
+
+ if (!phoenixConfig)
+ notConfiguredPanels.push(phoenixPanel)
+
if (!langFuseConfig)
notConfiguredPanels.push(langfusePanel)
@@ -170,16 +230,25 @@ const ConfigPopup: FC = ({
if (!weaveConfig)
notConfiguredPanels.push(weavePanel)
+ if (!aliyunConfig)
+ notConfiguredPanels.push(aliyunPanel)
+
return notConfiguredPanels
}
const configuredProviderConfig = () => {
+ if (currentProvider === TracingProvider.arize)
+ return arizeConfig
+ if (currentProvider === TracingProvider.phoenix)
+ return phoenixConfig
if (currentProvider === TracingProvider.langSmith)
return langSmithConfig
if (currentProvider === TracingProvider.langfuse)
return langFuseConfig
if (currentProvider === TracingProvider.opik)
return opikConfig
+ if (currentProvider === TracingProvider.aliyun)
+ return aliyunConfig
return weaveConfig
}
@@ -220,22 +289,25 @@ const ConfigPopup: FC = ({
? (
<>
{t(`${I18N_PREFIX}.configProviderTitle.${providerAllConfigured ? 'configured' : 'notConfigured'}`)}
-
+
{langfusePanel}
{langSmithPanel}
{opikPanel}
{weavePanel}
+ {arizePanel}
+ {phoenixPanel}
+ {aliyunPanel}
>
)
: (
<>
{t(`${I18N_PREFIX}.configProviderTitle.configured`)}
-
+
{configuredProviderPanel()}
{t(`${I18N_PREFIX}.configProviderTitle.moreProvider`)}
-
+
{moreProviderPanel()}
>
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts
index 5d3c4076bd..4c81b63ea2 100644
--- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts
+++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/config.ts
@@ -1,8 +1,11 @@
import { TracingProvider } from './type'
export const docURL = {
+ [TracingProvider.arize]: 'https://docs.arize.com/arize',
+ [TracingProvider.phoenix]: 'https://docs.arize.com/phoenix',
[TracingProvider.langSmith]: 'https://docs.smith.langchain.com/',
[TracingProvider.langfuse]: 'https://docs.langfuse.com',
[TracingProvider.opik]: 'https://www.comet.com/docs/opik/tracing/integrations/dify#setup-instructions',
[TracingProvider.weave]: 'https://weave-docs.wandb.ai/',
+ [TracingProvider.aliyun]: 'https://help.aliyun.com/zh/arms/tracing-analysis/untitled-document-1750672984680',
}
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx
index 76e90ecf19..8bf18904be 100644
--- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx
+++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/panel.tsx
@@ -7,12 +7,12 @@ import {
import { useTranslation } from 'react-i18next'
import { usePathname } from 'next/navigation'
import { useBoolean } from 'ahooks'
-import type { LangFuseConfig, LangSmithConfig, OpikConfig, WeaveConfig } from './type'
+import type { AliyunConfig, ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type'
import { TracingProvider } from './type'
import TracingIcon from './tracing-icon'
import ConfigButton from './config-button'
import cn from '@/utils/classnames'
-import { LangfuseIcon, LangsmithIcon, OpikIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing'
+import { AliyunIcon, ArizeIcon, LangfuseIcon, LangsmithIcon, OpikIcon, PhoenixIcon, WeaveIcon } from '@/app/components/base/icons/src/public/tracing'
import Indicator from '@/app/components/header/indicator'
import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps'
import type { TracingStatus } from '@/models/app'
@@ -62,24 +62,33 @@ const Panel: FC = () => {
}
const inUseTracingProvider: TracingProvider | null = tracingStatus?.tracing_provider || null
- const InUseProviderIcon
- = inUseTracingProvider === TracingProvider.langSmith
- ? LangsmithIcon
- : inUseTracingProvider === TracingProvider.langfuse
- ? LangfuseIcon
- : inUseTracingProvider === TracingProvider.opik
- ? OpikIcon
- : inUseTracingProvider === TracingProvider.weave
- ? WeaveIcon
- : LangsmithIcon
+ const providerIconMap: Record
> = {
+ [TracingProvider.arize]: ArizeIcon,
+ [TracingProvider.phoenix]: PhoenixIcon,
+ [TracingProvider.langSmith]: LangsmithIcon,
+ [TracingProvider.langfuse]: LangfuseIcon,
+ [TracingProvider.opik]: OpikIcon,
+ [TracingProvider.weave]: WeaveIcon,
+ [TracingProvider.aliyun]: AliyunIcon,
+ }
+ const InUseProviderIcon = inUseTracingProvider ? providerIconMap[inUseTracingProvider] : undefined
+ const [arizeConfig, setArizeConfig] = useState(null)
+ const [phoenixConfig, setPhoenixConfig] = useState(null)
const [langSmithConfig, setLangSmithConfig] = useState(null)
const [langFuseConfig, setLangFuseConfig] = useState(null)
const [opikConfig, setOpikConfig] = useState(null)
const [weaveConfig, setWeaveConfig] = useState(null)
- const hasConfiguredTracing = !!(langSmithConfig || langFuseConfig || opikConfig || weaveConfig)
+ const [aliyunConfig, setAliyunConfig] = useState(null)
+ const hasConfiguredTracing = !!(langSmithConfig || langFuseConfig || opikConfig || weaveConfig || arizeConfig || phoenixConfig || aliyunConfig)
const fetchTracingConfig = async () => {
+ const { tracing_config: arizeConfig, has_not_configured: arizeHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.arize })
+ if (!arizeHasNotConfig)
+ setArizeConfig(arizeConfig as ArizeConfig)
+ const { tracing_config: phoenixConfig, has_not_configured: phoenixHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.phoenix })
+ if (!phoenixHasNotConfig)
+ setPhoenixConfig(phoenixConfig as PhoenixConfig)
const { tracing_config: langSmithConfig, has_not_configured: langSmithHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.langSmith })
if (!langSmithHasNotConfig)
setLangSmithConfig(langSmithConfig as LangSmithConfig)
@@ -92,12 +101,19 @@ const Panel: FC = () => {
const { tracing_config: weaveConfig, has_not_configured: weaveHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.weave })
if (!weaveHasNotConfig)
setWeaveConfig(weaveConfig as WeaveConfig)
+ const { tracing_config: aliyunConfig, has_not_configured: aliyunHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.aliyun })
+ if (!aliyunHasNotConfig)
+ setAliyunConfig(aliyunConfig as AliyunConfig)
}
const handleTracingConfigUpdated = async (provider: TracingProvider) => {
// call api to hide secret key value
const { tracing_config } = await doFetchTracingConfig({ appId, provider })
- if (provider === TracingProvider.langSmith)
+ if (provider === TracingProvider.arize)
+ setArizeConfig(tracing_config as ArizeConfig)
+ else if (provider === TracingProvider.phoenix)
+ setPhoenixConfig(tracing_config as PhoenixConfig)
+ else if (provider === TracingProvider.langSmith)
setLangSmithConfig(tracing_config as LangSmithConfig)
else if (provider === TracingProvider.langfuse)
setLangFuseConfig(tracing_config as LangFuseConfig)
@@ -105,10 +121,16 @@ const Panel: FC = () => {
setOpikConfig(tracing_config as OpikConfig)
else if (provider === TracingProvider.weave)
setWeaveConfig(tracing_config as WeaveConfig)
+ else if (provider === TracingProvider.aliyun)
+ setAliyunConfig(tracing_config as AliyunConfig)
}
const handleTracingConfigRemoved = (provider: TracingProvider) => {
- if (provider === TracingProvider.langSmith)
+ if (provider === TracingProvider.arize)
+ setArizeConfig(null)
+ else if (provider === TracingProvider.phoenix)
+ setPhoenixConfig(null)
+ else if (provider === TracingProvider.langSmith)
setLangSmithConfig(null)
else if (provider === TracingProvider.langfuse)
setLangFuseConfig(null)
@@ -116,6 +138,8 @@ const Panel: FC = () => {
setOpikConfig(null)
else if (provider === TracingProvider.weave)
setWeaveConfig(null)
+ else if (provider === TracingProvider.aliyun)
+ setAliyunConfig(null)
if (provider === inUseTracingProvider) {
handleTracingStatusChange({
enabled: false,
@@ -170,10 +194,13 @@ const Panel: FC = () => {
onStatusChange={handleTracingEnabledChange}
chosenProvider={inUseTracingProvider}
onChooseProvider={handleChooseProvider}
+ arizeConfig={arizeConfig}
+ phoenixConfig={phoenixConfig}
langSmithConfig={langSmithConfig}
langFuseConfig={langFuseConfig}
opikConfig={opikConfig}
weaveConfig={weaveConfig}
+ aliyunConfig={aliyunConfig}
onConfigUpdated={handleTracingConfigUpdated}
onConfigRemoved={handleTracingConfigRemoved}
controlShowPopup={controlShowPopup}
@@ -205,10 +232,13 @@ const Panel: FC = () => {
onStatusChange={handleTracingEnabledChange}
chosenProvider={inUseTracingProvider}
onChooseProvider={handleChooseProvider}
+ arizeConfig={arizeConfig}
+ phoenixConfig={phoenixConfig}
langSmithConfig={langSmithConfig}
langFuseConfig={langFuseConfig}
opikConfig={opikConfig}
weaveConfig={weaveConfig}
+ aliyunConfig={aliyunConfig}
onConfigUpdated={handleTracingConfigUpdated}
onConfigRemoved={handleTracingConfigRemoved}
controlShowPopup={controlShowPopup}
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx
index b6c97add48..318f1f61d6 100644
--- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx
+++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx
@@ -4,7 +4,7 @@ import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import Field from './field'
-import type { LangFuseConfig, LangSmithConfig, OpikConfig, WeaveConfig } from './type'
+import type { AliyunConfig, ArizeConfig, LangFuseConfig, LangSmithConfig, OpikConfig, PhoenixConfig, WeaveConfig } from './type'
import { TracingProvider } from './type'
import { docURL } from './config'
import {
@@ -22,15 +22,28 @@ import Divider from '@/app/components/base/divider'
type Props = {
appId: string
type: TracingProvider
- payload?: LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | null
+ payload?: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | null
onRemoved: () => void
onCancel: () => void
- onSaved: (payload: LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig) => void
+ onSaved: (payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig) => void
onChosen: (provider: TracingProvider) => void
}
const I18N_PREFIX = 'app.tracing.configProvider'
+const arizeConfigTemplate = {
+ api_key: '',
+ space_id: '',
+ project: '',
+ endpoint: '',
+}
+
+const phoenixConfigTemplate = {
+ api_key: '',
+ project: '',
+ endpoint: '',
+}
+
const langSmithConfigTemplate = {
api_key: '',
project: '',
@@ -58,6 +71,12 @@ const weaveConfigTemplate = {
host: '',
}
+const aliyunConfigTemplate = {
+ app_name: '',
+ license_key: '',
+ endpoint: '',
+}
+
const ProviderConfigModal: FC = ({
appId,
type,
@@ -71,11 +90,17 @@ const ProviderConfigModal: FC = ({
const isEdit = !!payload
const isAdd = !isEdit
const [isSaving, setIsSaving] = useState(false)
- const [config, setConfig] = useState((() => {
+ const [config, setConfig] = useState((() => {
if (isEdit)
return payload
- if (type === TracingProvider.langSmith)
+ if (type === TracingProvider.arize)
+ return arizeConfigTemplate
+
+ else if (type === TracingProvider.phoenix)
+ return phoenixConfigTemplate
+
+ else if (type === TracingProvider.langSmith)
return langSmithConfigTemplate
else if (type === TracingProvider.langfuse)
@@ -84,6 +109,9 @@ const ProviderConfigModal: FC = ({
else if (type === TracingProvider.opik)
return opikConfigTemplate
+ else if (type === TracingProvider.aliyun)
+ return aliyunConfigTemplate
+
return weaveConfigTemplate
})())
const [isShowRemoveConfirm, {
@@ -115,6 +143,24 @@ const ProviderConfigModal: FC = ({
const checkValid = useCallback(() => {
let errorMessage = ''
+ if (type === TracingProvider.arize) {
+ const postData = config as ArizeConfig
+ if (!postData.api_key)
+ errorMessage = t('common.errorMsg.fieldRequired', { field: 'API Key' })
+ if (!postData.space_id)
+ errorMessage = t('common.errorMsg.fieldRequired', { field: 'Space ID' })
+ if (!errorMessage && !postData.project)
+ errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.project`) })
+ }
+
+ if (type === TracingProvider.phoenix) {
+ const postData = config as PhoenixConfig
+ if (!postData.api_key)
+ errorMessage = t('common.errorMsg.fieldRequired', { field: 'API Key' })
+ if (!errorMessage && !postData.project)
+ errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.project`) })
+ }
+
if (type === TracingProvider.langSmith) {
const postData = config as LangSmithConfig
if (!postData.api_key)
@@ -146,6 +192,16 @@ const ProviderConfigModal: FC = ({
errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.project`) })
}
+ if (type === TracingProvider.aliyun) {
+ const postData = config as AliyunConfig
+ if (!errorMessage && !postData.app_name)
+ errorMessage = t('common.errorMsg.fieldRequired', { field: 'App Name' })
+ if (!errorMessage && !postData.license_key)
+ errorMessage = t('common.errorMsg.fieldRequired', { field: 'License Key' })
+ if (!errorMessage && !postData.endpoint)
+ errorMessage = t('common.errorMsg.fieldRequired', { field: 'Endpoint' })
+ }
+
return errorMessage
}, [config, t, type])
const handleSave = useCallback(async () => {
@@ -195,6 +251,93 @@ const ProviderConfigModal: FC = ({
+ {type === TracingProvider.arize && (
+ <>
+
+
+
+
+ >
+ )}
+ {type === TracingProvider.phoenix && (
+ <>
+
+
+
+ >
+ )}
+ {type === TracingProvider.aliyun && (
+ <>
+
+
+
+ >
+ )}
{type === TracingProvider.weave && (
<>
{
return ({
+ [TracingProvider.arize]: ArizeIconBig,
+ [TracingProvider.phoenix]: PhoenixIconBig,
[TracingProvider.langSmith]: LangsmithIconBig,
[TracingProvider.langfuse]: LangfuseIconBig,
[TracingProvider.opik]: OpikIconBig,
[TracingProvider.weave]: WeaveIconBig,
+ [TracingProvider.aliyun]: AliyunIconBig,
})[type]
}
diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type.ts b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type.ts
index ed468caf65..78bca41ad2 100644
--- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type.ts
+++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type.ts
@@ -1,8 +1,24 @@
export enum TracingProvider {
+ arize = 'arize',
+ phoenix = 'phoenix',
langSmith = 'langsmith',
langfuse = 'langfuse',
opik = 'opik',
weave = 'weave',
+ aliyun = 'aliyun',
+}
+
+export type ArizeConfig = {
+ api_key: string
+ space_id: string
+ project: string
+ endpoint: string
+}
+
+export type PhoenixConfig = {
+ api_key: string
+ project: string
+ endpoint: string
}
export type LangSmithConfig = {
@@ -31,3 +47,9 @@ export type WeaveConfig = {
endpoint: string
host: string
}
+
+export type AliyunConfig = {
+ app_name: string
+ license_key: string
+ endpoint: string
+}
diff --git a/web/app/(commonLayout)/apps/AppCard.tsx b/web/app/(commonLayout)/apps/AppCard.tsx
index 31b9ed87c2..f50cc10520 100644
--- a/web/app/(commonLayout)/apps/AppCard.tsx
+++ b/web/app/(commonLayout)/apps/AppCard.tsx
@@ -36,6 +36,7 @@ import AccessControl from '@/app/components/app/app-access-control'
import { AccessMode } from '@/models/access-control'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { formatTime } from '@/utils/time'
+import { useGetUserCanAccessApp } from '@/service/access-control'
export type AppCardProps = {
app: App
@@ -190,6 +191,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
}, [onRefresh, mutateApps, setShowAccessControl])
const Operations = (props: HtmlContentProps) => {
+ const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp } = useGetUserCanAccessApp({ appId: app?.id, enabled: (!!props?.open && systemFeatures.webapp_auth.enabled) })
const onMouseLeave = async () => {
props.onClose?.()
}
@@ -267,10 +269,14 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
>
)}
-
-
+ {
+ (isGettingUserCanAccessApp || !userCanAccessApp?.result) ? null : <>
+
+
+ >
+ }
{
systemFeatures.webapp_auth.enabled && isCurrentWorkspaceEditor && <>
@@ -333,7 +339,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
{app.author_name}
·
-
{EditTimeText}
+
{EditTimeText}