From 092db650f7d22826ca5e218146184beb0b3eda17 Mon Sep 17 00:00:00 2001 From: Kishan Parmar <135701940+kishanprmr@users.noreply.github.com> Date: Mon, 24 Aug 2026 11:58:09 +0530 Subject: [PATCH 1/5] chore(pieces): add outputSchema to OpenAI, Grok (xAI), Gemini, SMTP, ClickUp, and Flow Helper actions (#15008) --- bun.lock | 408 +++++++++--------- .../pieces/community/clickup/package.json | 2 +- .../src/lib/actions/tags/update-space-tag.ts | 2 + .../clickup-get-bulk-tasks-time-in-status.ts | 2 + .../clickup/src/lib/output-schemas.ts | 54 +++ .../pieces/community/flow-helper/package.json | 2 +- .../flow-helper/src/lib/actions/get-run-id.ts | 2 + .../flow-helper/src/lib/actions/stop-flow.ts | 2 + .../flow-helper/src/lib/output-schemas.ts | 15 + .../community/google-gemini/package.json | 2 +- .../src/lib/actions/create-video.action.ts | 2 + .../google-gemini/src/lib/output-schemas.ts | 11 + .../pieces/community/grok-xai/package.json | 2 +- .../grok-xai/src/lib/actions/ask-grok.ts | 2 + .../src/lib/actions/categorize-text.ts | 2 + .../grok-xai/src/lib/actions/extract-data.ts | 2 + .../src/lib/actions/generate-image.ts | 4 +- .../grok-xai/src/lib/output-schemas.ts | 98 +++++ packages/pieces/community/openai/package.json | 2 +- .../src/lib/actions/analyze-sentiment.ts | 2 + .../openai/src/lib/actions/ask-assistant.ts | 2 + .../openai/src/lib/actions/classify-text.ts | 2 + .../src/lib/actions/create-embedding.ts | 2 + .../openai/src/lib/actions/delete-file.ts | 2 + .../openai/src/lib/actions/edit-image.ts | 2 + .../openai/src/lib/actions/find-file.ts | 2 + .../openai/src/lib/actions/generate-image.ts | 2 + .../openai/src/lib/actions/list-files.ts | 2 + .../openai/src/lib/actions/list-models.ts | 2 + .../src/lib/actions/search-embeddings.ts | 2 + .../openai/src/lib/actions/transcriptions.ts | 2 + .../openai/src/lib/actions/translation.ts | 2 + .../openai/src/lib/actions/upload-file.ts | 2 + .../openai/src/lib/output-schemas.ts | 204 +++++++++ packages/pieces/core/smtp/package.json | 2 +- .../core/smtp/src/lib/actions/send-email.ts | 2 + .../core/smtp/src/lib/output-schemas.ts | 23 + 37 files changed, 664 insertions(+), 211 deletions(-) create mode 100644 packages/pieces/community/flow-helper/src/lib/output-schemas.ts create mode 100644 packages/pieces/community/grok-xai/src/lib/output-schemas.ts create mode 100644 packages/pieces/community/openai/src/lib/output-schemas.ts create mode 100644 packages/pieces/core/smtp/src/lib/output-schemas.ts diff --git a/bun.lock b/bun.lock index 2c97db8ce16d..4ce5defb55ef 100644 --- a/bun.lock +++ b/bun.lock @@ -226,7 +226,7 @@ }, "packages/pieces/community/activecampaign": { "name": "@activepieces/piece-activecampaign", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -239,7 +239,7 @@ }, "packages/pieces/community/activepieces": { "name": "@activepieces/piece-activepieces", - "version": "0.4.7", + "version": "0.4.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -252,7 +252,7 @@ }, "packages/pieces/community/actualbudget": { "name": "@activepieces/piece-actualbudget", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -267,7 +267,7 @@ }, "packages/pieces/community/acuity-scheduling": { "name": "@activepieces/piece-acuity-scheduling", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -280,7 +280,7 @@ }, "packages/pieces/community/acumbamail": { "name": "@activepieces/piece-acumbamail", - "version": "0.2.6", + "version": "0.2.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -294,7 +294,7 @@ }, "packages/pieces/community/add-event": { "name": "@activepieces/piece-add-event", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -307,7 +307,7 @@ }, "packages/pieces/community/afforai": { "name": "@activepieces/piece-afforai", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -320,7 +320,7 @@ }, "packages/pieces/community/agentx": { "name": "@activepieces/piece-agentx", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -363,7 +363,7 @@ }, "packages/pieces/community/aianswer": { "name": "@activepieces/piece-aianswer", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -376,7 +376,7 @@ }, "packages/pieces/community/aidbase": { "name": "@activepieces/piece-aidbase", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -389,7 +389,7 @@ }, "packages/pieces/community/aiprise": { "name": "@activepieces/piece-aiprise", - "version": "0.1.4", + "version": "0.1.5", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -402,7 +402,7 @@ }, "packages/pieces/community/air-ops": { "name": "@activepieces/piece-air-ops", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -415,7 +415,7 @@ }, "packages/pieces/community/aircall": { "name": "@activepieces/piece-aircall", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -428,7 +428,7 @@ }, "packages/pieces/community/airparser": { "name": "@activepieces/piece-airparser", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -471,7 +471,7 @@ }, "packages/pieces/community/alai": { "name": "@activepieces/piece-alai", - "version": "0.1.5", + "version": "0.1.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -484,7 +484,7 @@ }, "packages/pieces/community/algolia": { "name": "@activepieces/piece-algolia", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -497,7 +497,7 @@ }, "packages/pieces/community/alt-text-ai": { "name": "@activepieces/piece-alt-text-ai", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -510,7 +510,7 @@ }, "packages/pieces/community/alttextify": { "name": "@activepieces/piece-alttextify", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -525,7 +525,7 @@ }, "packages/pieces/community/amazon-bedrock": { "name": "@activepieces/piece-amazon-bedrock", - "version": "0.2.2", + "version": "0.2.3", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -544,7 +544,7 @@ }, "packages/pieces/community/amazon-s3": { "name": "@activepieces/piece-amazon-s3", - "version": "0.6.6", + "version": "0.6.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -566,7 +566,7 @@ }, "packages/pieces/community/amazon-secrets-manager": { "name": "@activepieces/piece-amazon-secrets-manager", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -581,7 +581,7 @@ }, "packages/pieces/community/amazon-ses": { "name": "@activepieces/piece-amazon-ses", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -595,7 +595,7 @@ }, "packages/pieces/community/amazon-sns": { "name": "@activepieces/piece-amazon-sns", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -609,7 +609,7 @@ }, "packages/pieces/community/amazon-sqs": { "name": "@activepieces/piece-amazon-sqs", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -623,7 +623,7 @@ }, "packages/pieces/community/amazon-textract": { "name": "@activepieces/piece-amazon-textract", - "version": "0.3.5", + "version": "0.3.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -637,7 +637,7 @@ }, "packages/pieces/community/aminos": { "name": "@activepieces/piece-aminos", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -663,7 +663,7 @@ }, "packages/pieces/community/anyhook-graphql": { "name": "@activepieces/piece-anyhook-graphql", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -676,7 +676,7 @@ }, "packages/pieces/community/anyhook-websocket": { "name": "@activepieces/piece-anyhook-websocket", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -703,7 +703,7 @@ }, "packages/pieces/community/apitable": { "name": "@activepieces/piece-apitable", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -717,7 +717,7 @@ }, "packages/pieces/community/apitemplate-io": { "name": "@activepieces/piece-apitemplate-io", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -730,7 +730,7 @@ }, "packages/pieces/community/apollo": { "name": "@activepieces/piece-apollo", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -743,7 +743,7 @@ }, "packages/pieces/community/appfollow": { "name": "@activepieces/piece-appfollow", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -757,7 +757,7 @@ }, "packages/pieces/community/asana": { "name": "@activepieces/piece-asana", - "version": "0.4.7", + "version": "0.4.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -784,7 +784,7 @@ }, "packages/pieces/community/ask-handle": { "name": "@activepieces/piece-ask-handle", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -797,7 +797,7 @@ }, "packages/pieces/community/asknews": { "name": "@activepieces/piece-asknews", - "version": "0.0.8", + "version": "0.0.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -810,7 +810,7 @@ }, "packages/pieces/community/assembled": { "name": "@activepieces/piece-assembled", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -823,7 +823,7 @@ }, "packages/pieces/community/assemblyai": { "name": "@activepieces/piece-assemblyai", - "version": "1.1.8", + "version": "1.1.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -844,7 +844,7 @@ }, "packages/pieces/community/attio": { "name": "@activepieces/piece-attio", - "version": "0.1.21", + "version": "0.1.22", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -857,7 +857,7 @@ }, "packages/pieces/community/autocalls": { "name": "@activepieces/piece-autocalls", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -871,7 +871,7 @@ }, "packages/pieces/community/avian": { "name": "@activepieces/piece-avian", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -886,7 +886,7 @@ }, "packages/pieces/community/avoma": { "name": "@activepieces/piece-avoma", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -899,7 +899,7 @@ }, "packages/pieces/community/aws-bedrock": { "name": "@activepieces/piece-aws-bedrock", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -918,7 +918,7 @@ }, "packages/pieces/community/azure-ad": { "name": "@activepieces/piece-azure-ad", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -932,7 +932,7 @@ }, "packages/pieces/community/azure-blob-storage": { "name": "@activepieces/piece-azure-blob-storage", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -947,7 +947,7 @@ }, "packages/pieces/community/azure-communication-services": { "name": "@activepieces/piece-azure-communication-services", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -961,7 +961,7 @@ }, "packages/pieces/community/azure-devops": { "name": "@activepieces/piece-azure-devops", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -974,7 +974,7 @@ }, "packages/pieces/community/azure-openai": { "name": "@activepieces/piece-azure-openai", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -990,7 +990,7 @@ }, "packages/pieces/community/backblaze": { "name": "@activepieces/piece-backblaze", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1004,7 +1004,7 @@ }, "packages/pieces/community/bamboohr": { "name": "@activepieces/piece-bamboohr", - "version": "0.2.11", + "version": "0.2.12", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1018,7 +1018,7 @@ }, "packages/pieces/community/bannerbear": { "name": "@activepieces/piece-bannerbear", - "version": "0.4.8", + "version": "0.4.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1031,7 +1031,7 @@ }, "packages/pieces/community/barcode-lookup": { "name": "@activepieces/piece-barcode-lookup", - "version": "0.0.8", + "version": "0.0.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1044,7 +1044,7 @@ }, "packages/pieces/community/baremetrics": { "name": "@activepieces/piece-baremetrics", - "version": "0.0.8", + "version": "0.0.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1057,7 +1057,7 @@ }, "packages/pieces/community/base44": { "name": "@activepieces/piece-base44", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1071,7 +1071,7 @@ }, "packages/pieces/community/baserow": { "name": "@activepieces/piece-baserow", - "version": "0.9.6", + "version": "0.9.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1084,7 +1084,7 @@ }, "packages/pieces/community/beamer": { "name": "@activepieces/piece-beamer", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1097,7 +1097,7 @@ }, "packages/pieces/community/beebole": { "name": "@activepieces/piece-beebole", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1110,7 +1110,7 @@ }, "packages/pieces/community/beehiiv": { "name": "@activepieces/piece-beehiiv", - "version": "0.3.4", + "version": "0.3.5", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1123,7 +1123,7 @@ }, "packages/pieces/community/bettermode": { "name": "@activepieces/piece-bettermode", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1137,7 +1137,7 @@ }, "packages/pieces/community/bexio": { "name": "@activepieces/piece-bexio", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1152,7 +1152,7 @@ }, "packages/pieces/community/bigcommerce": { "name": "@activepieces/piece-bigcommerce", - "version": "0.1.11", + "version": "0.1.12", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1165,7 +1165,7 @@ }, "packages/pieces/community/bigin-by-zoho": { "name": "@activepieces/piece-bigin-by-zoho", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1178,7 +1178,7 @@ }, "packages/pieces/community/bika": { "name": "@activepieces/piece-bika", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1191,7 +1191,7 @@ }, "packages/pieces/community/billplz": { "name": "@activepieces/piece-billplz", - "version": "0.0.8", + "version": "0.0.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1204,7 +1204,7 @@ }, "packages/pieces/community/binance": { "name": "@activepieces/piece-binance", - "version": "0.4.6", + "version": "0.4.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1217,7 +1217,7 @@ }, "packages/pieces/community/bitly": { "name": "@activepieces/piece-bitly", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1230,7 +1230,7 @@ }, "packages/pieces/community/bland-ai": { "name": "@activepieces/piece-bland-ai", - "version": "0.1.4", + "version": "0.1.5", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1243,7 +1243,7 @@ }, "packages/pieces/community/blockscout": { "name": "@activepieces/piece-blockscout", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1256,7 +1256,7 @@ }, "packages/pieces/community/bluesky": { "name": "@activepieces/piece-bluesky", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1271,7 +1271,7 @@ }, "packages/pieces/community/bocha-search": { "name": "@activepieces/piece-bocha-search", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1284,7 +1284,7 @@ }, "packages/pieces/community/bokio": { "name": "@activepieces/piece-bokio", - "version": "0.0.8", + "version": "0.0.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1298,7 +1298,7 @@ }, "packages/pieces/community/bolna": { "name": "@activepieces/piece-bolna", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1311,7 +1311,7 @@ }, "packages/pieces/community/bonjoro": { "name": "@activepieces/piece-bonjoro", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1325,7 +1325,7 @@ }, "packages/pieces/community/bookedin": { "name": "@activepieces/piece-bookedin", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1338,7 +1338,7 @@ }, "packages/pieces/community/box": { "name": "@activepieces/piece-box", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1351,7 +1351,7 @@ }, "packages/pieces/community/brave-search": { "name": "@activepieces/piece-brave-search", - "version": "0.0.6", + "version": "0.0.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1364,7 +1364,7 @@ }, "packages/pieces/community/brilliant-directories": { "name": "@activepieces/piece-brilliant-directories", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1377,7 +1377,7 @@ }, "packages/pieces/community/browse-ai": { "name": "@activepieces/piece-browse-ai", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1390,7 +1390,7 @@ }, "packages/pieces/community/browserless": { "name": "@activepieces/piece-browserless", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1403,7 +1403,7 @@ }, "packages/pieces/community/bubble": { "name": "@activepieces/piece-bubble", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1416,7 +1416,7 @@ }, "packages/pieces/community/buffer": { "name": "@activepieces/piece-buffer", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1429,7 +1429,7 @@ }, "packages/pieces/community/bumpups": { "name": "@activepieces/piece-bumpups", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1442,7 +1442,7 @@ }, "packages/pieces/community/bursty-ai": { "name": "@activepieces/piece-bursty-ai", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1455,7 +1455,7 @@ }, "packages/pieces/community/buttondown": { "name": "@activepieces/piece-buttondown", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1468,7 +1468,7 @@ }, "packages/pieces/community/cal-com": { "name": "@activepieces/piece-cal-com", - "version": "0.4.8", + "version": "0.4.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1481,7 +1481,7 @@ }, "packages/pieces/community/calendly": { "name": "@activepieces/piece-calendly", - "version": "0.4.7", + "version": "0.4.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1494,7 +1494,7 @@ }, "packages/pieces/community/camb-ai": { "name": "@activepieces/piece-camb-ai", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1508,7 +1508,7 @@ }, "packages/pieces/community/campaign-monitor": { "name": "@activepieces/piece-campaign-monitor", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1521,7 +1521,7 @@ }, "packages/pieces/community/canny": { "name": "@activepieces/piece-canny", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1534,7 +1534,7 @@ }, "packages/pieces/community/canva": { "name": "@activepieces/piece-canva", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1547,7 +1547,7 @@ }, "packages/pieces/community/capsule-crm": { "name": "@activepieces/piece-capsule-crm", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1560,7 +1560,7 @@ }, "packages/pieces/community/captain-data": { "name": "@activepieces/piece-captain-data", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1573,7 +1573,7 @@ }, "packages/pieces/community/carbone": { "name": "@activepieces/piece-carbone", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1587,7 +1587,7 @@ }, "packages/pieces/community/cartloom": { "name": "@activepieces/piece-cartloom", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1600,7 +1600,7 @@ }, "packages/pieces/community/cashfree-payments": { "name": "@activepieces/piece-cashfree-payments", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1613,7 +1613,7 @@ }, "packages/pieces/community/certopus": { "name": "@activepieces/piece-certopus", - "version": "0.2.7", + "version": "0.2.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1626,7 +1626,7 @@ }, "packages/pieces/community/chain-aware": { "name": "@activepieces/piece-chain-aware", - "version": "0.0.8", + "version": "0.0.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1639,7 +1639,7 @@ }, "packages/pieces/community/chainalysis-api": { "name": "@activepieces/piece-chainalysis-api", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1652,7 +1652,7 @@ }, "packages/pieces/community/chaindesk": { "name": "@activepieces/piece-chaindesk", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1666,7 +1666,7 @@ }, "packages/pieces/community/chargebee": { "name": "@activepieces/piece-chargebee", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1679,7 +1679,7 @@ }, "packages/pieces/community/chargekeep": { "name": "@activepieces/piece-chargekeep", - "version": "0.3.6", + "version": "0.3.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1692,7 +1692,7 @@ }, "packages/pieces/community/chartly": { "name": "@activepieces/piece-chartly", - "version": "0.0.8", + "version": "0.0.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1705,7 +1705,7 @@ }, "packages/pieces/community/chat-aid": { "name": "@activepieces/piece-chat-aid", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1719,7 +1719,7 @@ }, "packages/pieces/community/chat-data": { "name": "@activepieces/piece-chat-data", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1734,7 +1734,7 @@ }, "packages/pieces/community/chatbase": { "name": "@activepieces/piece-chatbase", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1747,7 +1747,7 @@ }, "packages/pieces/community/chatfly": { "name": "@activepieces/piece-chatfly", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1760,7 +1760,7 @@ }, "packages/pieces/community/chatling": { "name": "@activepieces/piece-chatling", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1774,7 +1774,7 @@ }, "packages/pieces/community/chatnode": { "name": "@activepieces/piece-chatnode", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1787,7 +1787,7 @@ }, "packages/pieces/community/chatsistant": { "name": "@activepieces/piece-chatsistant", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1800,7 +1800,7 @@ }, "packages/pieces/community/chatwoot": { "name": "@activepieces/piece-chatwoot", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1813,7 +1813,7 @@ }, "packages/pieces/community/checkout": { "name": "@activepieces/piece-checkout", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1826,7 +1826,7 @@ }, "packages/pieces/community/chess-com": { "name": "@activepieces/piece-chess-com", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1840,7 +1840,7 @@ }, "packages/pieces/community/circle": { "name": "@activepieces/piece-circle", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1854,7 +1854,7 @@ }, "packages/pieces/community/clarifai": { "name": "@activepieces/piece-clarifai", - "version": "0.3.8", + "version": "0.3.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1886,7 +1886,7 @@ }, "packages/pieces/community/clearout": { "name": "@activepieces/piece-clearout", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1900,7 +1900,7 @@ }, "packages/pieces/community/clearoutphone": { "name": "@activepieces/piece-clearoutphone", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1913,7 +1913,7 @@ }, "packages/pieces/community/clicdata": { "name": "@activepieces/piece-clicdata", - "version": "0.0.8", + "version": "0.0.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1939,7 +1939,7 @@ }, "packages/pieces/community/clicksend": { "name": "@activepieces/piece-clicksend", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -1952,7 +1952,7 @@ }, "packages/pieces/community/clickup": { "name": "@activepieces/piece-clickup", - "version": "0.7.12", + "version": "0.7.13", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -2050,7 +2050,7 @@ }, "packages/pieces/community/coda": { "name": "@activepieces/piece-coda", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -2150,7 +2150,7 @@ }, "packages/pieces/community/connectuc": { "name": "@activepieces/piece-connectuc", - "version": "0.0.10", + "version": "0.0.11", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -2463,7 +2463,7 @@ }, "packages/pieces/community/deepgram": { "name": "@activepieces/piece-deepgram", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -2491,7 +2491,7 @@ }, "packages/pieces/community/deepseek": { "name": "@activepieces/piece-deepseek", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -2506,7 +2506,7 @@ }, "packages/pieces/community/deftform": { "name": "@activepieces/piece-deftform", - "version": "0.0.6", + "version": "0.0.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -2532,7 +2532,7 @@ }, "packages/pieces/community/descript": { "name": "@activepieces/piece-descript", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -2844,7 +2844,7 @@ }, "packages/pieces/community/eden-ai": { "name": "@activepieces/piece-eden-ai", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -2886,7 +2886,7 @@ }, "packages/pieces/community/elevenlabs": { "name": "@activepieces/piece-elevenlabs", - "version": "0.2.9", + "version": "0.2.10", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -3127,7 +3127,7 @@ }, "packages/pieces/community/filetopdf": { "name": "@activepieces/piece-filetopdf", - "version": "0.0.6", + "version": "0.0.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -3220,7 +3220,7 @@ }, "packages/pieces/community/flow-helper": { "name": "@activepieces/piece-flow-helper", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -3421,7 +3421,7 @@ }, "packages/pieces/community/freshdesk": { "name": "@activepieces/piece-freshdesk", - "version": "0.2.7", + "version": "0.2.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -3620,7 +3620,7 @@ }, "packages/pieces/community/github": { "name": "@activepieces/piece-github", - "version": "0.8.10", + "version": "0.8.11", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -3637,7 +3637,7 @@ }, "packages/pieces/community/gitlab": { "name": "@activepieces/piece-gitlab", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -3713,7 +3713,7 @@ }, "packages/pieces/community/google-bigquery": { "name": "@activepieces/piece-google-bigquery", - "version": "0.0.6", + "version": "0.0.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -3756,7 +3756,7 @@ }, "packages/pieces/community/google-contacts": { "name": "@activepieces/piece-google-contacts", - "version": "0.4.9", + "version": "0.4.10", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -3824,7 +3824,7 @@ }, "packages/pieces/community/google-gemini": { "name": "@activepieces/piece-google-gemini", - "version": "0.1.15", + "version": "0.1.16", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -3910,7 +3910,7 @@ }, "packages/pieces/community/google-slides": { "name": "@activepieces/piece-google-slides", - "version": "0.1.10", + "version": "0.1.11", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -4086,7 +4086,7 @@ }, "packages/pieces/community/grist": { "name": "@activepieces/piece-grist", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -4100,7 +4100,7 @@ }, "packages/pieces/community/grok-xai": { "name": "@activepieces/piece-grok-xai", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -4235,7 +4235,7 @@ }, "packages/pieces/community/heygen": { "name": "@activepieces/piece-heygen", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -4275,7 +4275,7 @@ }, "packages/pieces/community/housecall-pro": { "name": "@activepieces/piece-housecall-pro", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -4319,7 +4319,7 @@ }, "packages/pieces/community/hugging-face": { "name": "@activepieces/piece-hugging-face", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -4387,7 +4387,7 @@ }, "packages/pieces/community/iloveapi": { "name": "@activepieces/piece-iloveapi", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -4401,7 +4401,7 @@ }, "packages/pieces/community/image-router": { "name": "@activepieces/piece-image-router", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -4415,7 +4415,7 @@ }, "packages/pieces/community/imap": { "name": "@activepieces/piece-imap", - "version": "0.4.8", + "version": "0.4.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -4595,7 +4595,7 @@ }, "packages/pieces/community/jina-ai": { "name": "@activepieces/piece-jina-ai", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -4723,7 +4723,7 @@ }, "packages/pieces/community/kapso": { "name": "@activepieces/piece-kapso", - "version": "0.1.4", + "version": "0.1.5", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -4793,7 +4793,7 @@ }, "packages/pieces/community/klaviyo": { "name": "@activepieces/piece-klaviyo", - "version": "0.1.4", + "version": "0.1.5", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -4968,7 +4968,7 @@ }, "packages/pieces/community/lemlist": { "name": "@activepieces/piece-lemlist", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -4981,7 +4981,7 @@ }, "packages/pieces/community/lemon-squeezy": { "name": "@activepieces/piece-lemon-squeezy", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -5074,7 +5074,7 @@ }, "packages/pieces/community/linear": { "name": "@activepieces/piece-linear", - "version": "0.5.0", + "version": "0.5.1", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -5182,7 +5182,7 @@ }, "packages/pieces/community/localai": { "name": "@activepieces/piece-localai", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -5340,7 +5340,7 @@ }, "packages/pieces/community/mailchimp": { "name": "@activepieces/piece-mailchimp", - "version": "0.5.7", + "version": "0.5.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -5383,7 +5383,7 @@ }, "packages/pieces/community/maileroo": { "name": "@activepieces/piece-maileroo", - "version": "0.2.4", + "version": "0.2.5", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -5449,7 +5449,7 @@ }, "packages/pieces/community/mastodon": { "name": "@activepieces/piece-mastodon", - "version": "0.5.7", + "version": "0.5.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -5745,7 +5745,7 @@ }, "packages/pieces/community/microsoft-onedrive": { "name": "@activepieces/piece-microsoft-onedrive", - "version": "0.4.5", + "version": "0.4.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -5794,7 +5794,7 @@ }, "packages/pieces/community/microsoft-outlook-calendar": { "name": "@activepieces/piece-microsoft-outlook-calendar", - "version": "0.2.7", + "version": "0.2.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -5885,7 +5885,7 @@ }, "packages/pieces/community/microsoft-todo": { "name": "@activepieces/piece-microsoft-todo", - "version": "0.3.7", + "version": "0.3.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -6311,7 +6311,7 @@ }, "packages/pieces/community/ntfy": { "name": "@activepieces/piece-ntfy", - "version": "0.2.7", + "version": "0.2.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -6350,7 +6350,7 @@ }, "packages/pieces/community/odoo": { "name": "@activepieces/piece-odoo", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -6433,7 +6433,7 @@ }, "packages/pieces/community/oneclickimpact": { "name": "@activepieces/piece-oneclickimpact", - "version": "0.0.8", + "version": "0.0.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -6462,7 +6462,7 @@ }, "packages/pieces/community/open-phone": { "name": "@activepieces/piece-open-phone", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -6489,7 +6489,7 @@ }, "packages/pieces/community/openai": { "name": "@activepieces/piece-openai", - "version": "0.10.4", + "version": "0.10.5", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -6736,7 +6736,7 @@ }, "packages/pieces/community/pdf-co": { "name": "@activepieces/piece-pdf-co", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -6790,7 +6790,7 @@ }, "packages/pieces/community/peekshot": { "name": "@activepieces/piece-peekshot", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -6940,7 +6940,7 @@ }, "packages/pieces/community/placid": { "name": "@activepieces/piece-placid", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -7243,7 +7243,7 @@ }, "packages/pieces/community/pushbullet": { "name": "@activepieces/piece-pushbullet", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -7256,7 +7256,7 @@ }, "packages/pieces/community/pushover": { "name": "@activepieces/piece-pushover", - "version": "0.2.6", + "version": "0.2.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -7322,7 +7322,7 @@ }, "packages/pieces/community/queue": { "name": "@activepieces/piece-queue", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -7550,7 +7550,7 @@ }, "packages/pieces/community/reddit": { "name": "@activepieces/piece-reddit", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -7602,7 +7602,7 @@ }, "packages/pieces/community/resend": { "name": "@activepieces/piece-resend", - "version": "0.4.5", + "version": "0.4.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -7641,7 +7641,7 @@ }, "packages/pieces/community/retable": { "name": "@activepieces/piece-retable", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -7707,7 +7707,7 @@ }, "packages/pieces/community/robolly": { "name": "@activepieces/piece-robolly", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -7763,7 +7763,7 @@ }, "packages/pieces/community/runware": { "name": "@activepieces/piece-runware", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -7913,7 +7913,7 @@ }, "packages/pieces/community/scrapeless": { "name": "@activepieces/piece-scrapeless", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -7955,7 +7955,7 @@ }, "packages/pieces/community/send-it": { "name": "@activepieces/piece-send-it", - "version": "0.1.4", + "version": "0.1.5", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -7968,7 +7968,7 @@ }, "packages/pieces/community/sender": { "name": "@activepieces/piece-sender", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -7981,7 +7981,7 @@ }, "packages/pieces/community/sendfox": { "name": "@activepieces/piece-sendfox", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -8077,7 +8077,7 @@ }, "packages/pieces/community/serp-api": { "name": "@activepieces/piece-serp-api", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -8173,7 +8173,7 @@ }, "packages/pieces/community/short-io": { "name": "@activepieces/piece-short-io", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -8402,7 +8402,7 @@ }, "packages/pieces/community/smartsuite": { "name": "@activepieces/piece-smartsuite", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -8470,7 +8470,7 @@ }, "packages/pieces/community/socialkit": { "name": "@activepieces/piece-socialkit", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -8536,7 +8536,7 @@ }, "packages/pieces/community/spotify": { "name": "@activepieces/piece-spotify", - "version": "0.5.3", + "version": "0.5.4", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -8736,7 +8736,7 @@ }, "packages/pieces/community/systeme-io": { "name": "@activepieces/piece-systeme-io", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -8828,7 +8828,7 @@ }, "packages/pieces/community/tavily": { "name": "@activepieces/piece-tavily", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -8841,7 +8841,7 @@ }, "packages/pieces/community/teable": { "name": "@activepieces/piece-teable", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -8907,7 +8907,7 @@ }, "packages/pieces/community/telnyx": { "name": "@activepieces/piece-telnyx", - "version": "0.0.6", + "version": "0.0.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -9053,7 +9053,7 @@ }, "packages/pieces/community/todoist": { "name": "@activepieces/piece-todoist", - "version": "0.4.10", + "version": "0.4.11", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -9067,7 +9067,7 @@ }, "packages/pieces/community/toggl-track": { "name": "@activepieces/piece-toggl-track", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -9080,7 +9080,7 @@ }, "packages/pieces/community/totalcms": { "name": "@activepieces/piece-totalcms", - "version": "0.1.8", + "version": "0.1.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -9177,7 +9177,7 @@ }, "packages/pieces/community/twitch": { "name": "@activepieces/piece-twitch", - "version": "0.0.9", + "version": "0.0.10", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -9191,7 +9191,7 @@ }, "packages/pieces/community/twitter": { "name": "@activepieces/piece-twitter", - "version": "0.3.6", + "version": "0.3.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -9353,7 +9353,7 @@ }, "packages/pieces/community/vapi": { "name": "@activepieces/piece-vapi", - "version": "0.0.5", + "version": "0.0.6", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -9367,7 +9367,7 @@ }, "packages/pieces/community/vbout": { "name": "@activepieces/piece-vbout", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -9624,7 +9624,7 @@ }, "packages/pieces/community/webflow": { "name": "@activepieces/piece-webflow", - "version": "0.2.9", + "version": "0.2.10", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -9822,7 +9822,7 @@ }, "packages/pieces/community/wordpress": { "name": "@activepieces/piece-wordpress", - "version": "0.4.8", + "version": "0.4.9", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -9933,7 +9933,7 @@ }, "packages/pieces/community/youcanbookme": { "name": "@activepieces/piece-youcanbookme", - "version": "0.1.7", + "version": "0.1.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10003,7 +10003,7 @@ }, "packages/pieces/community/zendesk": { "name": "@activepieces/piece-zendesk", - "version": "0.2.11", + "version": "0.2.12", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10044,7 +10044,7 @@ }, "packages/pieces/community/zerobounce": { "name": "@activepieces/piece-zerobounce", - "version": "0.1.6", + "version": "0.1.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10140,7 +10140,7 @@ }, "packages/pieces/community/zoho-mail": { "name": "@activepieces/piece-zoho-mail", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10169,7 +10169,7 @@ }, "packages/pieces/community/zoom": { "name": "@activepieces/piece-zoom", - "version": "0.7.1", + "version": "0.7.2", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10349,7 +10349,7 @@ }, "packages/pieces/core/graphql": { "name": "@activepieces/piece-graphql", - "version": "0.0.18", + "version": "0.0.19", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10382,7 +10382,7 @@ }, "packages/pieces/core/image-helper": { "name": "@activepieces/piece-image-helper", - "version": "0.1.20", + "version": "0.1.21", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10450,7 +10450,7 @@ }, "packages/pieces/core/qrcode": { "name": "@activepieces/piece-qrcode", - "version": "0.0.17", + "version": "0.0.18", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10480,7 +10480,7 @@ }, "packages/pieces/core/sftp": { "name": "@activepieces/piece-sftp", - "version": "0.5.6", + "version": "0.5.7", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10500,7 +10500,7 @@ }, "packages/pieces/core/smtp": { "name": "@activepieces/piece-smtp", - "version": "0.4.7", + "version": "0.4.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/pieces/community/clickup/package.json b/packages/pieces/community/clickup/package.json index 2e2ed4093489..adea61905a8d 100644 --- a/packages/pieces/community/clickup/package.json +++ b/packages/pieces/community/clickup/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-clickup", - "version": "0.7.12", + "version": "0.7.13", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/clickup/src/lib/actions/tags/update-space-tag.ts b/packages/pieces/community/clickup/src/lib/actions/tags/update-space-tag.ts index 2f0ff18aebb1..ad65097ec346 100644 --- a/packages/pieces/community/clickup/src/lib/actions/tags/update-space-tag.ts +++ b/packages/pieces/community/clickup/src/lib/actions/tags/update-space-tag.ts @@ -6,6 +6,7 @@ import { import { HttpMethod, getAccessTokenOrThrow } from '@activepieces/pieces-common'; import { clickupCommon, callClickUpApi, listTags } from '../../common'; import { clickupAuth } from '../../auth'; +import { updateSpaceTagOutputSchema } from '../../output-schemas'; export const clickupUpdateSpaceTag = createAction({ auth: clickupAuth, @@ -65,6 +66,7 @@ export const clickupUpdateSpaceTag = createAction({ required: false, }), }, + outputSchema: updateSpaceTagOutputSchema, async run(configValue) { const { space_id, tag_name, new_name, tag_fg, tag_bg } = configValue.propsValue; diff --git a/packages/pieces/community/clickup/src/lib/actions/tasks/clickup-get-bulk-tasks-time-in-status.ts b/packages/pieces/community/clickup/src/lib/actions/tasks/clickup-get-bulk-tasks-time-in-status.ts index f845c4e27c4d..dfa4f56c7396 100644 --- a/packages/pieces/community/clickup/src/lib/actions/tasks/clickup-get-bulk-tasks-time-in-status.ts +++ b/packages/pieces/community/clickup/src/lib/actions/tasks/clickup-get-bulk-tasks-time-in-status.ts @@ -4,6 +4,7 @@ import qs from 'qs'; import { callClickUpApi } from '../../common'; import { clickupAuth } from '../../auth'; +import { getBulkTasksTimeInStatusOutputSchema } from '../../output-schemas'; export const clickupGetBulkTasksTimeInStatusAi = createAction({ auth: clickupAuth, @@ -24,6 +25,7 @@ export const clickupGetBulkTasksTimeInStatusAi = createAction({ required: true, }), }, + outputSchema: getBulkTasksTimeInStatusOutputSchema, async run(configValue) { const taskIds = configValue.propsValue.task_ids as string[]; if (!taskIds || taskIds.length === 0) { diff --git a/packages/pieces/community/clickup/src/lib/output-schemas.ts b/packages/pieces/community/clickup/src/lib/output-schemas.ts index 303d8e36d616..f5607db22dda 100644 --- a/packages/pieces/community/clickup/src/lib/output-schemas.ts +++ b/packages/pieces/community/clickup/src/lib/output-schemas.ts @@ -1334,6 +1334,20 @@ export const spaceTagsOutputSchema: OutputSchema = { fields: [{ key: 'tags', label: 'Tags', labelKey: 'name', listItems: tagFields }], }; +export const updateSpaceTagOutputSchema: OutputSchema = { + fields: [ + { + key: 'tag', + label: 'Tag', + children: [ + { key: 'name', label: 'Name' }, + { key: 'fg_color', label: 'Foreground Color' }, + { key: 'bg_color', label: 'Background Color' }, + ], + }, + ], +}; + export const taskWrapperOutputSchema: OutputSchema = { fields: [{ key: 'task', label: 'Task', children: taskFields }], }; @@ -1381,6 +1395,46 @@ export const listTimeEntriesOutputSchema: OutputSchema = { ], }; +const timeInStatusFields: OutputSchemaField[] = [ + { key: 'by_minute', label: 'Minutes', format: 'number' }, + { key: 'since', label: 'Since' }, +]; + +export const getBulkTasksTimeInStatusOutputSchema: OutputSchema = { + fields: [ + { + key: 'tasks', + label: 'Tasks', + value: '', + dynamicKey: true, + labelKey: 'current_status.status', + children: [ + { + key: 'current_status', + label: 'Current Status', + children: [ + { key: 'status', label: 'Status' }, + { key: 'color', label: 'Color' }, + { key: 'total_time', label: 'Total Time', children: timeInStatusFields }, + ], + }, + { + key: 'status_history', + label: 'Status History', + labelKey: 'status', + listItems: [ + { key: 'status', label: 'Status' }, + { key: 'color', label: 'Color' }, + { key: 'type', label: 'Type' }, + { key: 'total_time', label: 'Total Time', children: timeInStatusFields }, + { key: 'orderindex', label: 'Order Index', format: 'number' }, + ], + }, + ], + }, + ], +}; + const triggerEventField: OutputSchemaField = { key: 'event', label: 'Event', value: 'event' }; const triggerTeamWebhookFields: OutputSchemaField[] = [ diff --git a/packages/pieces/community/flow-helper/package.json b/packages/pieces/community/flow-helper/package.json index ee8aa324bc63..4d66fb398143 100644 --- a/packages/pieces/community/flow-helper/package.json +++ b/packages/pieces/community/flow-helper/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-flow-helper", - "version": "0.1.7", + "version": "0.1.8", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/flow-helper/src/lib/actions/get-run-id.ts b/packages/pieces/community/flow-helper/src/lib/actions/get-run-id.ts index 69819a578a25..6d9a9c066258 100644 --- a/packages/pieces/community/flow-helper/src/lib/actions/get-run-id.ts +++ b/packages/pieces/community/flow-helper/src/lib/actions/get-run-id.ts @@ -1,4 +1,5 @@ import { createAction } from '@activepieces/pieces-framework'; +import { getRunIdActionOutputSchema } from '../output-schemas'; export const getRunId = createAction({ audience: 'both', @@ -9,6 +10,7 @@ export const getRunId = createAction({ description: '', aiMetadata: { description: 'Returns the identifier of the flow run that is currently executing, along with a direct link to that run in the Activepieces UI. Use it when a later step needs to reference or report the run itself (e.g. embedding a run link in a Slack alert or a support ticket); it only ever describes the in-progress run and cannot look up a different one. Takes no inputs; read-only and idempotent.', idempotent: true }, props: {}, + outputSchema: getRunIdActionOutputSchema, async run(context) { const publicUrlWithoutApi = context.server.publicUrl.replace('/api', ''); return { diff --git a/packages/pieces/community/flow-helper/src/lib/actions/stop-flow.ts b/packages/pieces/community/flow-helper/src/lib/actions/stop-flow.ts index 98d7cbdd0181..597d8c6dfa67 100644 --- a/packages/pieces/community/flow-helper/src/lib/actions/stop-flow.ts +++ b/packages/pieces/community/flow-helper/src/lib/actions/stop-flow.ts @@ -1,4 +1,5 @@ import { createAction} from '@activepieces/pieces-framework'; +import { stopFlowActionOutputSchema } from '../output-schemas'; export const stopFlow = createAction({ audience: 'both', @@ -8,6 +9,7 @@ export const stopFlow = createAction({ description: 'Stops the flow immediately this step is reached.', aiMetadata: { description: 'Ends the current flow run at this step and marks it as stopped rather than failed, so no later steps execute. Use it for an early exit when a condition means there is nothing left to do; prefer Fail Flow when the exit should be recorded as an error instead. Takes no inputs and stops the run without a custom webhook response; idempotent, since it drives the run to the same stopped end state and creates nothing.', idempotent: true }, props: {}, + outputSchema: stopFlowActionOutputSchema, async run(context) { context.run.stop(); diff --git a/packages/pieces/community/flow-helper/src/lib/output-schemas.ts b/packages/pieces/community/flow-helper/src/lib/output-schemas.ts new file mode 100644 index 000000000000..763471844c5b --- /dev/null +++ b/packages/pieces/community/flow-helper/src/lib/output-schemas.ts @@ -0,0 +1,15 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +export const getRunIdActionOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Run ID' }, + { key: 'url', label: 'Run URL', format: 'url' }, + ], +}; + +export const stopFlowActionOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'message', label: 'Message' }, + ], +}; diff --git a/packages/pieces/community/google-gemini/package.json b/packages/pieces/community/google-gemini/package.json index 90a94f06d422..c42c2ec5067d 100644 --- a/packages/pieces/community/google-gemini/package.json +++ b/packages/pieces/community/google-gemini/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-google-gemini", - "version": "0.1.15", + "version": "0.1.16", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/google-gemini/src/lib/actions/create-video.action.ts b/packages/pieces/community/google-gemini/src/lib/actions/create-video.action.ts index 9ce2e095b0a7..74807f3fa3f1 100644 --- a/packages/pieces/community/google-gemini/src/lib/actions/create-video.action.ts +++ b/packages/pieces/community/google-gemini/src/lib/actions/create-video.action.ts @@ -4,6 +4,7 @@ import { googleGeminiAuth } from '../auth'; import { getGeminiVideoModelOptions } from '../common/common'; import { GoogleGenAI } from '@google/genai'; import mime from 'mime-types'; +import { createVideoActionOutputSchema } from '../output-schemas'; export const createVideoAction = createAction({ audience: 'both', @@ -120,6 +121,7 @@ export const createVideoAction = createAction({ }, }), }, + outputSchema: createVideoActionOutputSchema, async run(context) { const { model, diff --git a/packages/pieces/community/google-gemini/src/lib/output-schemas.ts b/packages/pieces/community/google-gemini/src/lib/output-schemas.ts index 824c158e7a69..7c01bfc8c538 100644 --- a/packages/pieces/community/google-gemini/src/lib/output-schemas.ts +++ b/packages/pieces/community/google-gemini/src/lib/output-schemas.ts @@ -129,3 +129,14 @@ export const textToSpeechActionOutputSchema: OutputSchema = { }, ], }; + +export const createVideoActionOutputSchema: OutputSchema = { + fields: [ + { + key: 'videoFile', + label: 'Video File URL', + value: '', + format: 'url', + }, + ], +}; diff --git a/packages/pieces/community/grok-xai/package.json b/packages/pieces/community/grok-xai/package.json index c2a6fa1a75cd..00837fbaeab5 100644 --- a/packages/pieces/community/grok-xai/package.json +++ b/packages/pieces/community/grok-xai/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-grok-xai", - "version": "0.1.7", + "version": "0.1.8", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", diff --git a/packages/pieces/community/grok-xai/src/lib/actions/ask-grok.ts b/packages/pieces/community/grok-xai/src/lib/actions/ask-grok.ts index 5492b99c5185..5fe473074299 100644 --- a/packages/pieces/community/grok-xai/src/lib/actions/ask-grok.ts +++ b/packages/pieces/community/grok-xai/src/lib/actions/ask-grok.ts @@ -16,6 +16,7 @@ import { XaiResponse, AskGrokResult } from '../common/utils'; +import { askGrokActionOutputSchema } from '../output-schemas'; import * as z from 'zod/mini' export const askGrok = createAction({ @@ -205,6 +206,7 @@ export const askGrok = createAction({ required: false, }), }, + outputSchema: askGrokActionOutputSchema, async run({ auth, propsValue, store }) { await propsValidation.validateZod(propsValue, { temperature: z.optional(z.number().check(z.minimum(0), z.maximum(2))), diff --git a/packages/pieces/community/grok-xai/src/lib/actions/categorize-text.ts b/packages/pieces/community/grok-xai/src/lib/actions/categorize-text.ts index 074502fb695e..1973ea091213 100644 --- a/packages/pieces/community/grok-xai/src/lib/actions/categorize-text.ts +++ b/packages/pieces/community/grok-xai/src/lib/actions/categorize-text.ts @@ -16,6 +16,7 @@ import { XaiResponse, CategorizationResult } from '../common/utils'; +import { categorizeTextActionOutputSchema } from '../output-schemas'; import * as z from 'zod/mini' interface Category { @@ -98,6 +99,7 @@ export const categorizeText = createAction({ }, }), }, + outputSchema: categorizeTextActionOutputSchema, async run({ auth, propsValue }) { await propsValidation.validateZod(propsValue, { temperature: z.optional(z.number().check(z.minimum(0), z.maximum(2))), diff --git a/packages/pieces/community/grok-xai/src/lib/actions/extract-data.ts b/packages/pieces/community/grok-xai/src/lib/actions/extract-data.ts index 368f8f6f0fd2..9c8379ea58f0 100644 --- a/packages/pieces/community/grok-xai/src/lib/actions/extract-data.ts +++ b/packages/pieces/community/grok-xai/src/lib/actions/extract-data.ts @@ -16,6 +16,7 @@ import { XaiResponse, ExtractionResult } from '../common/utils'; +import { extractDataFromTextActionOutputSchema } from '../output-schemas'; import * as z from 'zod/mini' interface ExtractDataField { @@ -121,6 +122,7 @@ export const extractDataFromText = createAction({ description: 'Include confidence scores for extracted fields.', }), }, + outputSchema: extractDataFromTextActionOutputSchema, async run({ auth, propsValue }) { await propsValidation.validateZod(propsValue, { temperature: z.optional(z.number().check(z.minimum(0), z.maximum(2))), diff --git a/packages/pieces/community/grok-xai/src/lib/actions/generate-image.ts b/packages/pieces/community/grok-xai/src/lib/actions/generate-image.ts index 6cd672684632..0c14e7b43341 100644 --- a/packages/pieces/community/grok-xai/src/lib/actions/generate-image.ts +++ b/packages/pieces/community/grok-xai/src/lib/actions/generate-image.ts @@ -10,9 +10,10 @@ import { } from '@activepieces/pieces-common'; import { grokAuth } from '../common/auth'; import { XAI_BASE_URL } from '../common/constants'; -import { +import { createModelProperty } from '../common/utils'; +import { generateImageActionOutputSchema } from '../output-schemas'; import * as z from 'zod/mini' export const generateImage = createAction({ @@ -54,6 +55,7 @@ export const generateImage = createAction({ }, }), }, + outputSchema: generateImageActionOutputSchema, async run({ auth, propsValue }) { await propsValidation.validateZod(propsValue, { numberOfImages: z.optional(z.number().check(z.minimum(1), z.maximum(10))), diff --git a/packages/pieces/community/grok-xai/src/lib/output-schemas.ts b/packages/pieces/community/grok-xai/src/lib/output-schemas.ts new file mode 100644 index 000000000000..23358673e1bd --- /dev/null +++ b/packages/pieces/community/grok-xai/src/lib/output-schemas.ts @@ -0,0 +1,98 @@ +import { OutputSchema, OutputSchemaField } from '@activepieces/pieces-framework'; + +const usageFields: OutputSchemaField[] = [ + { key: 'prompt_tokens', label: 'Prompt Tokens', format: 'number' }, + { key: 'completion_tokens', label: 'Completion Tokens', format: 'number' }, + { key: 'total_tokens', label: 'Total Tokens', format: 'number' }, + { key: 'num_sources_used', label: 'Sources Used', format: 'number' }, +]; + +const toolCallFields: OutputSchemaField[] = [ + { key: 'id', label: 'Tool Call ID' }, + { key: 'type', label: 'Type' }, + { + key: 'function', + label: 'Function', + children: [ + { key: 'name', label: 'Name' }, + { key: 'arguments', label: 'Arguments (JSON)' }, + ], + }, +]; + +export const askGrokActionOutputSchema: OutputSchema = { + fields: [ + { key: 'content', label: 'Content' }, + { key: 'reasoning_content', label: 'Reasoning Content' }, + { key: 'refusal', label: 'Refusal' }, + { key: 'role', label: 'Role' }, + { key: 'finish_reason', label: 'Finish Reason' }, + { key: 'model', label: 'Model' }, + { key: 'id', label: 'Response ID' }, + { key: 'created', label: 'Created (Unix Seconds)' }, + { key: 'tool_calls', label: 'Tool Calls', labelKey: 'function.name', listItems: toolCallFields }, + { key: 'citations', label: 'Citations' }, + { key: 'usage', label: 'Usage', children: usageFields }, + ], +}; + +export const categorizeTextActionOutputSchema: OutputSchema = { + fields: [ + { key: 'categories', label: 'Categories' }, + { key: 'primary_category', label: 'Primary Category' }, + { key: 'total_categories_assigned', label: 'Total Categories Assigned', format: 'number' }, + { key: 'multiple_categories', label: 'Multiple Categories Allowed', format: 'boolean' }, + { key: 'reasoning', label: 'Reasoning' }, + { key: 'reasoning_content', label: 'Reasoning Content' }, + { key: 'confidence_scores', label: 'Confidence Scores', dynamicKey: true }, + { key: 'avg_confidence', label: 'Average Confidence', format: 'number' }, + { key: 'max_confidence', label: 'Max Confidence', format: 'number' }, + { key: 'min_confidence', label: 'Min Confidence', format: 'number' }, + { key: 'model', label: 'Model' }, + { key: 'finish_reason', label: 'Finish Reason' }, + { key: 'citations', label: 'Citations' }, + { key: 'usage', label: 'Usage', children: usageFields }, + ], +}; + +export const extractDataFromTextActionOutputSchema: OutputSchema = { + fields: [ + { key: 'extracted_data', label: 'Extracted Data', dynamicKey: true }, + { key: 'extraction_success', label: 'Extraction Success', format: 'boolean' }, + { key: 'extraction_notes', label: 'Extraction Notes' }, + { key: 'fields_extracted', label: 'Fields Extracted', format: 'number' }, + { key: 'fields_requested', label: 'Fields Requested', format: 'number' }, + { key: 'completion_rate', label: 'Completion Rate', format: 'number' }, + { key: 'required_fields_found', label: 'Required Fields Found', format: 'number' }, + { key: 'required_fields_missing', label: 'Required Fields Missing', format: 'number' }, + { key: 'confidence_scores', label: 'Confidence Scores', dynamicKey: true }, + { key: 'avg_confidence', label: 'Average Confidence', format: 'number' }, + { key: 'max_confidence', label: 'Max Confidence', format: 'number' }, + { key: 'min_confidence', label: 'Min Confidence', format: 'number' }, + { key: 'reasoning_content', label: 'Reasoning Content' }, + { key: 'model', label: 'Model' }, + { key: 'finish_reason', label: 'Finish Reason' }, + { key: 'citations', label: 'Citations' }, + { key: 'usage', label: 'Usage', children: usageFields }, + ], +}; + +export const generateImageActionOutputSchema: OutputSchema = { + fields: [ + { + key: 'images', + label: 'Images', + labelKey: 'revised_prompt', + listItems: [ + { key: 'index', label: 'Index', format: 'number' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'b64_json', label: 'Base64 JSON' }, + { key: 'revised_prompt', label: 'Revised Prompt' }, + ], + }, + { key: 'total_images', label: 'Total Images', format: 'number' }, + { key: 'prompt_used', label: 'Prompt Used' }, + { key: 'model_used', label: 'Model Used' }, + { key: 'response_format', label: 'Response Format' }, + ], +}; diff --git a/packages/pieces/community/openai/package.json b/packages/pieces/community/openai/package.json index 4998ae1c20f1..d8885717a0c1 100644 --- a/packages/pieces/community/openai/package.json +++ b/packages/pieces/community/openai/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-openai", - "version": "0.10.4", + "version": "0.10.5", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/openai/src/lib/actions/analyze-sentiment.ts b/packages/pieces/community/openai/src/lib/actions/analyze-sentiment.ts index 94ad86481e9a..e8c8bb4ae1ec 100644 --- a/packages/pieces/community/openai/src/lib/actions/analyze-sentiment.ts +++ b/packages/pieces/community/openai/src/lib/actions/analyze-sentiment.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import OpenAI from 'openai'; import { openaiAuth } from '../auth'; +import { analyzeSentimentActionOutputSchema } from '../output-schemas'; const sentiments = ['positive', 'negative', 'neutral'] as const; @@ -35,6 +36,7 @@ export const analyzeSentiment = createAction({ required: true, }), }, + outputSchema: analyzeSentimentActionOutputSchema, async run(context) { const openai = new OpenAI({ apiKey: context.auth.secret_text }); const { model, text } = context.propsValue; diff --git a/packages/pieces/community/openai/src/lib/actions/ask-assistant.ts b/packages/pieces/community/openai/src/lib/actions/ask-assistant.ts index 86305d3e4462..1d92d7ec857b 100644 --- a/packages/pieces/community/openai/src/lib/actions/ask-assistant.ts +++ b/packages/pieces/community/openai/src/lib/actions/ask-assistant.ts @@ -8,6 +8,7 @@ import { openaiAuth } from '../auth'; import { sleep } from '../common/common'; import * as z from 'zod/mini' import { propsValidation } from '@activepieces/pieces-common'; +import { askAssistantActionOutputSchema } from '../output-schemas'; export const askAssistant = createAction({ audience: 'both', @@ -67,6 +68,7 @@ export const askAssistant = createAction({ required: false, }), }, + outputSchema: askAssistantActionOutputSchema, async run({ auth, propsValue, store }) { await propsValidation.validateZod(propsValue, { memoryKey: z.optional(z.string().check(z.maxLength(128))), diff --git a/packages/pieces/community/openai/src/lib/actions/classify-text.ts b/packages/pieces/community/openai/src/lib/actions/classify-text.ts index 5e3d7277a825..37457a7600fc 100644 --- a/packages/pieces/community/openai/src/lib/actions/classify-text.ts +++ b/packages/pieces/community/openai/src/lib/actions/classify-text.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import OpenAI from 'openai'; import { openaiAuth } from '../auth'; +import { classifyTextActionOutputSchema } from '../output-schemas'; export const classifyText = createAction({ audience: 'both', @@ -31,6 +32,7 @@ export const classifyText = createAction({ required: true, }), }, + outputSchema: classifyTextActionOutputSchema, async run(context) { const openai = new OpenAI({ apiKey: context.auth.secret_text }); const { model, input } = context.propsValue; diff --git a/packages/pieces/community/openai/src/lib/actions/create-embedding.ts b/packages/pieces/community/openai/src/lib/actions/create-embedding.ts index 4cb18bbbc74c..9672e2c8ce50 100644 --- a/packages/pieces/community/openai/src/lib/actions/create-embedding.ts +++ b/packages/pieces/community/openai/src/lib/actions/create-embedding.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import OpenAI from 'openai'; import { openaiAuth } from '../auth'; +import { createEmbeddingActionOutputSchema } from '../output-schemas'; export const createEmbedding = createAction({ audience: 'both', @@ -43,6 +44,7 @@ export const createEmbedding = createAction({ required: false, }), }, + outputSchema: createEmbeddingActionOutputSchema, async run(context) { const openai = new OpenAI({ apiKey: context.auth.secret_text }); const { model, input, dimensions, user } = context.propsValue; diff --git a/packages/pieces/community/openai/src/lib/actions/delete-file.ts b/packages/pieces/community/openai/src/lib/actions/delete-file.ts index b260ba39892e..72cae269cd8c 100644 --- a/packages/pieces/community/openai/src/lib/actions/delete-file.ts +++ b/packages/pieces/community/openai/src/lib/actions/delete-file.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import OpenAI from 'openai'; import { openaiAuth } from '../auth'; +import { deleteFileActionOutputSchema } from '../output-schemas'; export const deleteFile = createAction({ audience: 'both', @@ -17,6 +18,7 @@ export const deleteFile = createAction({ required: true, }), }, + outputSchema: deleteFileActionOutputSchema, async run(context) { const openai = new OpenAI({ apiKey: context.auth.secret_text }); const { fileId } = context.propsValue; diff --git a/packages/pieces/community/openai/src/lib/actions/edit-image.ts b/packages/pieces/community/openai/src/lib/actions/edit-image.ts index 5a369bf1f1b0..e741bb7d4d2b 100644 --- a/packages/pieces/community/openai/src/lib/actions/edit-image.ts +++ b/packages/pieces/community/openai/src/lib/actions/edit-image.ts @@ -4,6 +4,7 @@ import { randomBytes } from 'node:crypto'; import { kebabCase } from '@activepieces/pieces-framework'; import mime from 'mime-types'; import { openaiAuth } from '../auth'; +import { editImageActionOutputSchema } from '../output-schemas'; export const editImage = createAction({ audience: 'both', @@ -59,6 +60,7 @@ export const editImage = createAction({ }, }), }, + outputSchema: editImageActionOutputSchema, async run(context) { const openai = new OpenAI({ apiKey: context.auth.secret_text }); const { image, prompt, mask, size, quality } = context.propsValue; diff --git a/packages/pieces/community/openai/src/lib/actions/find-file.ts b/packages/pieces/community/openai/src/lib/actions/find-file.ts index fb0110b77f1f..b7b75047ce03 100644 --- a/packages/pieces/community/openai/src/lib/actions/find-file.ts +++ b/packages/pieces/community/openai/src/lib/actions/find-file.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import OpenAI from 'openai'; import { openaiAuth } from '../auth'; +import { findFileActionOutputSchema } from '../output-schemas'; export const findFile = createAction({ audience: 'both', @@ -31,6 +32,7 @@ export const findFile = createAction({ }, }), }, + outputSchema: findFileActionOutputSchema, async run(context) { const openai = new OpenAI({ apiKey: context.auth.secret_text }); const { fileName, purpose } = context.propsValue; diff --git a/packages/pieces/community/openai/src/lib/actions/generate-image.ts b/packages/pieces/community/openai/src/lib/actions/generate-image.ts index 011b9c3c82c8..edfd2153e6a7 100644 --- a/packages/pieces/community/openai/src/lib/actions/generate-image.ts +++ b/packages/pieces/community/openai/src/lib/actions/generate-image.ts @@ -4,6 +4,7 @@ import { kebabCase } from '@activepieces/pieces-framework'; import { randomBytes } from 'node:crypto'; import OpenAI from 'openai'; import { openaiAuth } from '../auth'; +import { generateImageActionOutputSchema } from '../output-schemas'; export const generateImage = createAction({ audience: 'both', @@ -84,6 +85,7 @@ export const generateImage = createAction({ }, }), }, + outputSchema: generateImageActionOutputSchema, async run(context) { const openai = new OpenAI({ apiKey: context.auth.secret_text }); const { quality, resolution, model, prompt } = context.propsValue; diff --git a/packages/pieces/community/openai/src/lib/actions/list-files.ts b/packages/pieces/community/openai/src/lib/actions/list-files.ts index 8505eed39f31..b3287ec3dec0 100644 --- a/packages/pieces/community/openai/src/lib/actions/list-files.ts +++ b/packages/pieces/community/openai/src/lib/actions/list-files.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import OpenAI from 'openai'; import { openaiAuth } from '../auth'; +import { listFilesActionOutputSchema } from '../output-schemas'; export const listFiles = createAction({ audience: 'both', @@ -32,6 +33,7 @@ export const listFiles = createAction({ defaultValue: 100, }), }, + outputSchema: listFilesActionOutputSchema, async run(context) { const openai = new OpenAI({ apiKey: context.auth.secret_text }); const { purpose, limit } = context.propsValue; diff --git a/packages/pieces/community/openai/src/lib/actions/list-models.ts b/packages/pieces/community/openai/src/lib/actions/list-models.ts index d1423f740317..a6046f186301 100644 --- a/packages/pieces/community/openai/src/lib/actions/list-models.ts +++ b/packages/pieces/community/openai/src/lib/actions/list-models.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import OpenAI from 'openai'; import { openaiAuth } from '../auth'; +import { listModelsActionOutputSchema } from '../output-schemas'; export const listModels = createAction({ audience: 'both', @@ -19,6 +20,7 @@ export const listModels = createAction({ required: false, }), }, + outputSchema: listModelsActionOutputSchema, async run(context) { const openai = new OpenAI({ apiKey: context.auth.secret_text }); const { contains } = context.propsValue; diff --git a/packages/pieces/community/openai/src/lib/actions/search-embeddings.ts b/packages/pieces/community/openai/src/lib/actions/search-embeddings.ts index 53b888365c3e..0fc9b56101cb 100644 --- a/packages/pieces/community/openai/src/lib/actions/search-embeddings.ts +++ b/packages/pieces/community/openai/src/lib/actions/search-embeddings.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import OpenAI from 'openai'; import { openaiAuth } from '../auth'; +import { searchEmbeddingsActionOutputSchema } from '../output-schemas'; export const searchEmbeddings = createAction({ audience: 'both', @@ -42,6 +43,7 @@ export const searchEmbeddings = createAction({ required: false, }), }, + outputSchema: searchEmbeddingsActionOutputSchema, async run(context) { const openai = new OpenAI({ apiKey: context.auth.secret_text }); const { model, query, topK } = context.propsValue; diff --git a/packages/pieces/community/openai/src/lib/actions/transcriptions.ts b/packages/pieces/community/openai/src/lib/actions/transcriptions.ts index e791f02b25a4..52f9894ed310 100644 --- a/packages/pieces/community/openai/src/lib/actions/transcriptions.ts +++ b/packages/pieces/community/openai/src/lib/actions/transcriptions.ts @@ -8,6 +8,7 @@ import { openaiAuth } from '../auth'; import FormData from 'form-data'; import mime from 'mime-types'; import { Languages, baseUrl } from '../common/common'; +import { transcribeActionOutputSchema } from '../output-schemas'; export const transcribeAction = createAction({ audience: 'both', @@ -33,6 +34,7 @@ export const transcribeAction = createAction({ defaultValue: 'en', }), }, + outputSchema: transcribeActionOutputSchema, run: async (context) => { const fileData = context.propsValue.audio; const mimeType = mime.lookup(fileData.extension ? fileData.extension : ''); diff --git a/packages/pieces/community/openai/src/lib/actions/translation.ts b/packages/pieces/community/openai/src/lib/actions/translation.ts index 1408cb4d379e..058a41a05c2f 100644 --- a/packages/pieces/community/openai/src/lib/actions/translation.ts +++ b/packages/pieces/community/openai/src/lib/actions/translation.ts @@ -8,6 +8,7 @@ import { openaiAuth } from '../auth'; import FormData from 'form-data'; import mime from 'mime-types'; import { baseUrl } from '../common/common'; +import { translateActionOutputSchema } from '../output-schemas'; export const translateAction = createAction({ audience: 'both', @@ -24,6 +25,7 @@ export const translateAction = createAction({ description: 'Audio file to translate', }), }, + outputSchema: translateActionOutputSchema, run: async (context) => { const fileData = context.propsValue.audio; const mimeType = mime.lookup(fileData.extension ? fileData.extension : ''); diff --git a/packages/pieces/community/openai/src/lib/actions/upload-file.ts b/packages/pieces/community/openai/src/lib/actions/upload-file.ts index 97ed711426bb..7837bb63ab71 100644 --- a/packages/pieces/community/openai/src/lib/actions/upload-file.ts +++ b/packages/pieces/community/openai/src/lib/actions/upload-file.ts @@ -3,6 +3,7 @@ import OpenAI, { toFile } from 'openai'; import type { FilePurpose } from 'openai/resources/files'; import mime from 'mime-types'; import { openaiAuth } from '../auth'; +import { uploadFileActionOutputSchema } from '../output-schemas'; const allowedPurposes: readonly FilePurpose[] = [ 'assistants', @@ -51,6 +52,7 @@ export const uploadFile = createAction({ required: false, }), }, + outputSchema: uploadFileActionOutputSchema, async run(context) { const openai = new OpenAI({ apiKey: context.auth.secret_text }); const { file, purpose, fileName } = context.propsValue; diff --git a/packages/pieces/community/openai/src/lib/output-schemas.ts b/packages/pieces/community/openai/src/lib/output-schemas.ts new file mode 100644 index 000000000000..97a7ee304345 --- /dev/null +++ b/packages/pieces/community/openai/src/lib/output-schemas.ts @@ -0,0 +1,204 @@ +import { OutputSchema, OutputSchemaField } from '@activepieces/pieces-framework'; + +const fileFields: OutputSchemaField[] = [ + { key: 'id', label: 'File ID' }, + { key: 'filename', label: 'Filename' }, + { key: 'bytes', label: 'Size', format: 'filesize' }, + { key: 'purpose', label: 'Purpose' }, + { key: 'status', label: 'Status' }, + { key: 'status_details', label: 'Status Details' }, + { key: 'created_at', label: 'Created At (Unix Seconds)' }, + { key: 'expires_at', label: 'Expires At (Unix Seconds)' }, +]; + +const imageUsageFields: OutputSchemaField[] = [ + { key: 'input_tokens', label: 'Input Tokens', format: 'number' }, + { key: 'output_tokens', label: 'Output Tokens', format: 'number' }, + { key: 'total_tokens', label: 'Total Tokens', format: 'number' }, +]; + +const savedImageFields: OutputSchemaField[] = [ + { key: 'url', label: 'Image URL', format: 'image' }, + { key: 'fileName', label: 'File Name' }, +]; + +const assistantMessageFields: OutputSchemaField[] = [ + { key: 'id', label: 'Message ID' }, + { key: 'role', label: 'Role' }, + { key: 'created_at', label: 'Created At (Unix Seconds)' }, + { key: 'assistant_id', label: 'Assistant ID' }, + { key: 'thread_id', label: 'Thread ID' }, + { key: 'run_id', label: 'Run ID' }, + { + key: 'content', + label: 'Content', + labelKey: 'type', + listItems: [ + { key: 'type', label: 'Type' }, + { key: 'text', label: 'Text', children: [{ key: 'value', label: 'Value' }] }, + ], + }, +]; + +export const analyzeSentimentActionOutputSchema: OutputSchema = { + fields: [ + { key: 'sentiment', label: 'Sentiment' }, + { key: 'confidence', label: 'Confidence', format: 'number' }, + { key: 'explanation', label: 'Explanation' }, + ], +}; + +export const classifyTextActionOutputSchema: OutputSchema = { + fields: [ + { key: 'flagged', label: 'Flagged', format: 'boolean' }, + { key: 'categories', label: 'Categories', dynamicKey: true }, + { key: 'category_scores', label: 'Category Scores', dynamicKey: true }, + { key: 'model', label: 'Model' }, + { key: 'id', label: 'Moderation ID' }, + ], +}; + +export const createEmbeddingActionOutputSchema: OutputSchema = { + fields: [ + { key: 'model', label: 'Model' }, + { key: 'embedding', label: 'Embedding' }, + { + key: 'usage', + label: 'Usage', + children: [ + { key: 'prompt_tokens', label: 'Prompt Tokens', format: 'number' }, + { key: 'total_tokens', label: 'Total Tokens', format: 'number' }, + ], + }, + ], +}; + +export const searchEmbeddingsActionOutputSchema: OutputSchema = { + fields: [ + { + key: 'bestMatch', + label: 'Best Match', + children: [ + { key: 'document', label: 'Document' }, + { key: 'index', label: 'Index', format: 'number' }, + { key: 'score', label: 'Score', format: 'number' }, + ], + }, + { + key: 'results', + label: 'Results', + labelKey: 'document', + listItems: [ + { key: 'document', label: 'Document' }, + { key: 'index', label: 'Index', format: 'number' }, + { key: 'score', label: 'Score', format: 'number' }, + ], + }, + { + key: 'usage', + label: 'Usage', + children: [{ key: 'total_tokens', label: 'Total Tokens', format: 'number' }], + }, + ], +}; + +export const deleteFileActionOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'File ID' }, + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + ], +}; + +export const findFileActionOutputSchema: OutputSchema = { + fields: [ + { key: 'found', label: 'Found', format: 'boolean' }, + { key: 'count', label: 'Match Count', format: 'number' }, + { key: 'file', label: 'File', children: fileFields }, + { key: 'files', label: 'Files', labelKey: 'filename', listItems: fileFields }, + ], +}; + +export const listFilesActionOutputSchema: OutputSchema = { + fields: [ + { key: 'files', label: 'Files', labelKey: 'filename', listItems: fileFields }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const uploadFileActionOutputSchema: OutputSchema = { fields: fileFields }; + +export const listModelsActionOutputSchema: OutputSchema = { + fields: [ + { key: 'count', label: 'Count', format: 'number' }, + { + key: 'models', + label: 'Models', + labelKey: 'id', + listItems: [ + { key: 'id', label: 'Model ID' }, + { key: 'created', label: 'Created (Unix Seconds)' }, + { key: 'owned_by', label: 'Owned By' }, + ], + }, + ], +}; + +export const generateImageActionOutputSchema: OutputSchema = { + fields: [ + { key: 'created', label: 'Created At (Unix Seconds)' }, + { key: 'background', label: 'Background' }, + { key: 'output_format', label: 'Output Format' }, + { key: 'quality', label: 'Quality' }, + { key: 'size', label: 'Size' }, + { key: 'usage', label: 'Usage', children: imageUsageFields }, + { + key: 'images', + label: 'Images', + labelKey: 'fileName', + listItems: [...savedImageFields, { key: 'revised_prompt', label: 'Revised Prompt' }], + }, + ], +}; + +export const editImageActionOutputSchema: OutputSchema = { + fields: [ + { key: 'created', label: 'Created At (Unix Seconds)' }, + { key: 'background', label: 'Background' }, + { key: 'output_format', label: 'Output Format' }, + { key: 'quality', label: 'Quality' }, + { key: 'size', label: 'Size' }, + { key: 'usage', label: 'Usage', children: imageUsageFields }, + { key: 'images', label: 'Images', labelKey: 'fileName', listItems: savedImageFields }, + ], +}; + +export const transcribeActionOutputSchema: OutputSchema = { + fields: [ + { key: 'text', label: 'Text' }, + { + key: 'usage', + label: 'Usage', + children: [ + { key: 'type', label: 'Type' }, + { key: 'seconds', label: 'Seconds', format: 'number' }, + ], + }, + ], +}; + +export const translateActionOutputSchema: OutputSchema = { + fields: [{ key: 'text', label: 'Text' }], +}; + +export const askAssistantActionOutputSchema: OutputSchema = { + itemLabel: '{role}', + fields: [ + { + key: 'messages', + label: 'Messages', + value: '', + labelKey: 'role', + listItems: assistantMessageFields, + }, + ], +}; diff --git a/packages/pieces/core/smtp/package.json b/packages/pieces/core/smtp/package.json index 1bb3696c51d6..7f444c4464bd 100644 --- a/packages/pieces/core/smtp/package.json +++ b/packages/pieces/core/smtp/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-smtp", - "version": "0.4.7", + "version": "0.4.8", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/core/smtp/src/lib/actions/send-email.ts b/packages/pieces/core/smtp/src/lib/actions/send-email.ts index 573bc23c5d61..b2c9c31e8b69 100644 --- a/packages/pieces/core/smtp/src/lib/actions/send-email.ts +++ b/packages/pieces/core/smtp/src/lib/actions/send-email.ts @@ -3,6 +3,7 @@ import { smtpAuth } from '../..'; import { smtpCommon } from '../common'; import { Attachment, Headers } from 'nodemailer/lib/mailer'; import mime from 'mime-types'; +import { sendEmailActionOutputSchema } from '../output-schemas'; export const sendEmail = createAction({ audience: 'both', @@ -84,6 +85,7 @@ export const sendEmail = createAction({ } }), }, + outputSchema: sendEmailActionOutputSchema, run: async ({ auth, propsValue }) => { const transporter = smtpCommon.createSMTPTransport(auth.props); diff --git a/packages/pieces/core/smtp/src/lib/output-schemas.ts b/packages/pieces/core/smtp/src/lib/output-schemas.ts new file mode 100644 index 000000000000..0c2806c496ce --- /dev/null +++ b/packages/pieces/core/smtp/src/lib/output-schemas.ts @@ -0,0 +1,23 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +export const sendEmailActionOutputSchema: OutputSchema = { + fields: [ + { key: 'accepted', label: 'Accepted Recipients' }, + { key: 'rejected', label: 'Rejected Recipients' }, + { key: 'pending', label: 'Pending Recipients' }, + { key: 'messageId', label: 'Message ID' }, + { key: 'response', label: 'SMTP Response' }, + { + key: 'envelope', + label: 'Envelope', + children: [ + { key: 'from', label: 'From' }, + { key: 'to', label: 'To' }, + ], + }, + { key: 'envelopeTime', label: 'Envelope Time (ms)', format: 'duration' }, + { key: 'messageTime', label: 'Message Time (ms)', format: 'duration' }, + { key: 'messageSize', label: 'Message Size', format: 'filesize' }, + { key: 'ehlo', label: 'Server Capabilities (EHLO)' }, + ], +}; From 3dacdac48f6f00ccb484782e427dc2c65f0162b2 Mon Sep 17 00:00:00 2001 From: Louai Boumediene <92324961+Louai-Zokerburg@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:57:39 +0100 Subject: [PATCH 2/5] feat(ai-providers): multiple keys per provider with model and project scopes (#14738) --- .../knowledge/ai-intelligence/ai-providers.md | 10 +- ...d-scope-and-splits-reads-by-trust-level.md | 21 + .../providers-redesign-before-routing.md | 21 + .../src/lib/workers/worker-contract.ts | 1 + .../src/lib/management/ai-providers/index.ts | 21 + .../api/src/app/ai/ai-provider-controller.ts | 65 ++- .../api/src/app/ai/ai-provider-entity.ts | 38 +- .../api/src/app/ai/ai-provider-service.ts | 351 ++++++++----- .../1830000000000-AddAiProviderScopes.ts | 107 ++++ .../src/app/database/postgres-connection.ts | 2 + .../ee/agent/agent-conversation-controller.ts | 14 +- .../api/src/app/ee/agent/agent-draft-ai.ts | 6 +- .../api/src/app/ee/agent/agent-helpers.ts | 87 ++- .../api/src/app/ee/agent/agent-memory-ai.ts | 2 +- .../src/app/ee/agent/agent-rpc-handlers.ts | 47 +- .../src/app/ee/agent/agent-run-controller.ts | 2 +- .../src/app/ee/agent/chat-analytics-sync.ts | 12 +- .../src/app/ee/agent/chat-usage-tracker.ts | 6 +- .../src/app/mcp/tools/ap-list-ai-models.ts | 4 +- .../api/src/app/mcp/tools/ap-setup-guide.ts | 2 +- .../src/app/tool-search/resolve-embedder.ts | 2 +- .../server/api/test/helpers/mocks/index.ts | 24 +- .../ce/ai-provider/ai-provider.test.ts | 497 +++++++++++++++++- .../ce/ai-provider/azure-models.test.ts | 38 +- .../ee/agent/agent-credit-gate.test.ts | 64 ++- .../agent/agent-fast-model-provider.test.ts | 9 +- .../ee/agent/agent-model-resolution.test.ts | 17 + .../app/ee/agent/agent-rpc-handlers.test.ts | 15 + .../engine/test/handler/flow-rerun.test.ts | 70 +-- .../jobs/ee/agent/agent-worker-tools.ts | 12 +- .../jobs/ee/agent/execute-agent-run.ts | 7 +- .../src/app/builder/pieces-selector/index.tsx | 2 +- .../app/routes/platform/setup/ai/index.tsx | 2 +- .../web/src/features/agents/ai-model/hooks.ts | 20 +- .../src/features/agents/ai-model/index.tsx | 2 +- .../platform-admin/api/ai-provider-api.ts | 21 +- .../platform-admin/hooks/ai-provider-hooks.ts | 25 +- 37 files changed, 1380 insertions(+), 266 deletions(-) create mode 100644 brain/knowledge/decisions/000027-ai-provider-resolution-takes-a-required-scope-and-splits-reads-by-trust-level.md create mode 100644 brain/knowledge/decisions/providers-redesign-before-routing.md create mode 100644 packages/server/api/src/app/database/migration/postgres/1830000000000-AddAiProviderScopes.ts diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md index 07f1b1a058fa..e57fd801f4a9 100644 --- a/brain/knowledge/ai-intelligence/ai-providers.md +++ b/brain/knowledge/ai-intelligence/ai-providers.md @@ -7,7 +7,7 @@ icon: 🧠 Lets platform admins configure one or more LLM backends for AI pieces in flows. Also auto-provisions an "Activepieces" provider (backed by OpenRouter) whose credit balance and auto-top-up are metered by Autumn billing. EE/Cloud only (not registered in CE). ### Entities & services -- **AIProvider** — platform-scoped: `displayName`, `platformId` (unique with `provider`), `provider` (AIProviderName enum), `auth` (EncryptedObject, AES-256 at rest), `config` (JSON), `enabledForChat`. +- **AIProvider** — platform-scoped: `displayName`, `platformId` (indexed with `provider`, NOT unique — a platform can hold multiple keys per provider since the 2026-08 providers redesign), `provider` (AIProviderName enum), `auth` (EncryptedObject, AES-256 at rest), `config` (JSON), `enabledForChat`, plus per-key scoping: `modelScope` (`all|selected`) + `modelIds[]`, and `projectScope` (`all|selected|except`) + `projectIds[]` (GIN-indexed). - Backend under `packages/server/api/src/app/ai/`; shared schemas in `core/shared/.../ai-providers/`. - Supported providers (10): `openai`, `anthropic`, `google`, `azure`, `openrouter`, `bedrock`, `mistral`, `cloudflare-gateway`, `custom` (OpenAI-compatible, e.g. Ollama/LM Studio), `activepieces` (auto-provisioned via OpenRouter). @@ -30,6 +30,12 @@ Lets platform admins configure one or more LLM backends for AI pieces in flows. ### Gotchas +- **Multi-key resolution is deterministic, not configurable.** When several keys of one provider are eligible for a project, `resolveEligibleRow` picks by most specific `projectScope` (`selected` > `except` > `all`), newest `created` breaking ties — there is no priority/default field (decision: [providers-redesign-before-routing](../decisions/providers-redesign-before-routing.md)). The ACTIVEPIECES provider stays a singleton — `create()` rejects it (`aiProvider.activepiecesIsManaged`). +- **Every resolver takes a required `ProviderScope`; there is no "no project" default** (decision: [000027](../decisions/000027-ai-provider-resolution-takes-a-required-scope-and-splits-reads-by-trust-level.md)). `getConfigOrThrow` / `getChatProvider` / `getChatProviderName` / `listModels` all take `scope: { type: 'project', projectId } | { type: 'platform' }`. This is deliberate and load-bearing: the first cut made `projectId` optional and treated its absence as "every key is eligible", so each new call site that forgot to thread it silently bypassed project scoping — the agent piece/knowledge-base tool handlers, the chat model picker, and the `configId` model lookup each reopened the same hole in turn. Making the argument required turns an omission into a compile error, and `{ type: 'platform' }` at a call site is a reviewable claim rather than an accident. Only three consumers are legitimately platform-wide: the tool-search embedder, chat memory extraction, and the managed-ACTIVEPIECES singleton. +- **The last fail-open scope lived in the helper that *builds* the scope, not in the resolvers.** Chat resolves the project a turn runs in (`selectRunProject`) before it resolves a credential, and that project is nullable — a user who can no longer see any project gets `null`. The first cut turned `null` into `{ type: 'platform' }`, which hands the run every key on the platform and is exactly the hole a required `ProviderScope` was meant to close. `agentHelpers.runScopeOrThrow` refuses the run instead; the analytics and billing paths, which want the provider's *name* and not a credential, take the nullable project id themselves (`resolveChatProviderName`) and report no provider for a conversation that has none. Rule of thumb: a nullable id feeding a scope constructor is the shape to look for, not a missing argument. +- **The managed ACTIVEPIECES row is a singleton the database enforces, not the code.** `create()` rejecting it only covers the admin route; the row is *also* auto-provisioned from `listVisibleRows` on every list, so two concurrent `GET /v1/ai-providers` both missed the `existsBy` and inserted once the unique `(platformId, provider)` index was dropped. `idx_ai_provider_platform_id_managed` — unique on `(platformId)` `WHERE provider = 'activepieces'` — keeps it single, and the insert carries `ON CONFLICT DO NOTHING` so the losing racer is a no-op rather than a 500. Preferred over a distributed lock because the invariant holds even for a writer that never takes one. +- **Reads are split by trust level, and mixing them back together is how the scope bypass keeps returning.** Runtime/project reads are `GET /v1/ai-providers?projectId=` (deduped to one entry per provider, `{provider, name, enabledForChat}` only) and `GET /v1/ai-providers/:provider/models?projectId=` — both `securityAccess.project([USER, ENGINE], undefined, QUERY)`, so an ENGINE principal supplies its own project and a USER must name a project it belongs to, and both always apply the resolved key's `modelScope` allow-list. Admin reads are `GET /v1/ai-providers/configs` and `GET /v1/ai-providers/configs/:id/models` — `platformAdminOnly`, addressing an exact row and returning the *unfiltered* model list because that is what an admin picks the allow-list from. Never widen the project routes to accept a config id, and never hand `projectIds`/`modelIds` to a project caller: those are other projects' identifiers. +- **`mockAndSaveAIProvider` uses `save`, not upsert** — the old `(platformId, provider)` ON CONFLICT target died with the unique index; seeding the same provider twice now creates two keys, which is usually what a test wants. - ACTIVEPIECES auto-provision needs `OPENROUTER_PROVISION_KEY` env var set AND `aiCreditsEnabled` true. - **A failed `enrichWithKeysIfNeeded()` is self-sustaining, and it takes chat down with it.** `createKey` runs on the chat hot path — `chatHelpers.resolveChatProvider` → `getChatProvider` calls it whenever the platform's managed ACTIVEPIECES row has no `apiKey` — and the `save` happens *after* the OpenRouter call, so a failure persists nothing and the next chat turn calls `createKey` again. There is also no distributed lock or cache, so concurrent turns for one platform each mint a live key and only the last is saved; the orphans keep spending provisioning quota. Seen in prod 2026-07-30: `keys-modify-api-rpd-v2` 429 (OpenRouter's key create/modify bucket, 10k/day on the provision key — a *separate* limit from inference), which killed every chat turn for the affected platform in `getChatConfig` before the first token, with no recovery until the bucket reset at 00:00 UTC. - **`openrouter-api.ts` uses raw `fetch`** — no timeout, no retry, no `tryCatch`, and it bypasses the repo's `safeHttp` rule for outbound HTTP in `packages/server/api`. A `getKey` 408 from OpenRouter escapes the admin `increaseAiCredits` path as an unhandled rejection. @@ -37,7 +43,7 @@ Lets platform admins configure one or more LLM backends for AI pieces in flows. - **Read the chat model list through `aiProviderUtils.getCuratedChatModels({ provider })`.** It is the one accessor the server resolver (`chatHelpers.resolveModelIdForProvider`) and the chat dropdown share, so the two cannot drift; it returns `{ id, label }` or `undefined` — never an empty list, so callers may treat a result as non-empty. Labels come from the hardcoded `CHAT_MODEL_LABELS` (falling back to the id) rather than `AIProviderModel.name`, because the live `listModels` response cannot supply one for every provider: anthropic returns `display_name` and google `displayName`, but OpenAI's `/v1/models` returns ids only. - **`conversation.modelName` carries either a tier id or a real model id** — it is a free string with no discriminator. A legacy tier id resolves to the tier's equivalent model when the provider ships it, else the provider's first curated model, so old conversations keep working after a provider switch. Note `premium` maps to opus 4.8, which the native anthropic list does not carry, so a legacy `premium` on anthropic lands on Sonnet. - Chat-provider resolution is **first `enabledForChat` row wins**, *not* "prefer ACTIVEPIECES". All three branches of `findAvailableChatProviderRow` reduce to that: when the managed provider is visible the function returns `chatProviders[0]` whatever it is, so a platform with `[openai, activepieces]` both chat-enabled resolves to **openai**. The client mirror is `aiProviderQueries.useChatProvider()` (`providers.find((p) => p.enabledForChat)`) — always read the resolved chat provider through it rather than re-deriving the rule inline. Both sides lean on an unordered `findBy()`: there is no `ORDER BY`, so "first" is not guaranteed stable when several providers are chat-enabled. -- `aiProviderService.listProviders` is **not a pure read**: it inserts the ACTIVEPIECES provider row when `aiCreditsEnabled && !activepiecesExists`. A `GET /v1/ai-providers` can therefore create a row. It already applies the hidden-provider filter (`plan.embeddingEnabled` hides the managed provider), which is why the client can trust its output without re-checking flags. It is `publicPlatform([USER, ENGINE])` — any authenticated user may list; only create/update/delete are `platformAdminOnly`. +- Listing providers is **not a pure read**: both `listConfigs` and `listForProject` go through `listVisibleRows`, which inserts the ACTIVEPIECES provider row when `aiCreditsEnabled && !activepiecesExists`. A `GET /v1/ai-providers` can therefore create a row. It also applies the hidden-provider filter (`plan.embeddingEnabled` hides the managed provider), which is why the client can trust its output without re-checking flags. - Managed-chat credit cost per turn is `tier.creditWeight + billableToolCalls` (`fast` 2 / `smart` 10 / `premium` 20, from `ACTIVEPIECES_CHAT_TIERS`), and BYOK collapses the weight to `CHAT_BYOK_CREDIT_WEIGHT` (1) regardless of tier — so never show tier weights to a BYOK platform. `CHAT_BYOK_CREDIT_WEIGHT` / `CHAT_CREDITS_PER_TOOL_CALL` live in `@activepieces/shared` so the billed number and the number shown in the model picker come from one place. **Every credit-cost surface must render from `ACTIVEPIECES_CHAT_TIERS`, never from a local copy** — the billing Credits FAQ (`credits-info-dialog.tsx`) hardcoded its own 2/10/20 table and drifted the moment the tiers were relabelled, so it still shows Fast/Smart/Premium against the real Fast/Expert/Heavy. - Azure model listing (`azureProvider.listModels`) is pinned to the retired data-plane api-version `2023-03-15-preview` — newer versions 404 and break `validateConnection`; the configured `apiVersion` only affects inference via `@ai-sdk/azure` (GIT-1310). - **`@activepieces/ai-providers` is `ai@7`, so only `ai@7` code may call it — a piece must build its own language models.** The shared `createLanguageModel` factory pins `ai@7` / `@ai-sdk/openai@4` / `@openrouter@3`, while `packages/pieces/community/ai` (and the engine that runs pieces) sit on `ai@6` / `@ai-sdk/openai@3` / `@openrouter@2`. #14446 pointed the piece at the factory anyway, which broke it two ways: `tsc` rejects the result (`specificationVersion "v4" is not assignable to "v2"`, `LanguageModelV4` vs the piece's `LanguageModelV2 | V3`), and at runtime the piece's own `ai@6` `generateText` would refuse a v4 model. CI hid it because the pieces build only runs when a PR's diff touches `packages/pieces/**`, and a post-merge run on main diffs `HEAD` against `origin/main` — empty, so no piece is ever built there. Fixed by giving the piece back a local `buildLanguageModel` switch built on its own SDKs; the factory is server-side only until the pieces move to AI SDK 7. Cloudflare Gateway is the one provider the factory refuses (`throw`) because its routing is caller-specific; each caller builds that one itself. diff --git a/brain/knowledge/decisions/000027-ai-provider-resolution-takes-a-required-scope-and-splits-reads-by-trust-level.md b/brain/knowledge/decisions/000027-ai-provider-resolution-takes-a-required-scope-and-splits-reads-by-trust-level.md new file mode 100644 index 000000000000..116d21d09efb --- /dev/null +++ b/brain/knowledge/decisions/000027-ai-provider-resolution-takes-a-required-scope-and-splits-reads-by-trust-level.md @@ -0,0 +1,21 @@ +--- +status: accepted +--- + +# AI provider resolution takes a required scope, and its reads split by trust level + +## Decision + +Every AI-provider resolver takes a **required** `ProviderScope` — `{ type: 'project', projectId } | { type: 'platform' }` — with no default and no optional `projectId`. The read endpoints split by who is asking: `GET /v1/ai-providers?projectId=` and `GET /v1/ai-providers/:provider/models?projectId=` are project-scoped (`securityAccess.project([USER, ENGINE], undefined, QUERY)`) and always apply the resolved key's model allow-list, while `GET /v1/ai-providers/configs` and `GET /v1/ai-providers/configs/:id/models` are `platformAdminOnly`, address an exact row, and return the unfiltered model list. The project list is deduped to one entry per provider and carries only `{ provider, name, enabledForChat }`. + +## Context + +Per-key project and model scoping arrived with the multi-key redesign (see [providers-redesign-before-routing](providers-redesign-before-routing.md)). The first cut made `projectId` optional on `resolveEligibleRow` because USER principals carry no project, and treated its absence as "every key on the platform is eligible". Over four review rounds the same finding was filed at four different locations — the MCP and chat paths, the agent piece and knowledge-base tool handlers, the chat/agent model picker, and the `configId` lookup — because each new caller that omitted the argument silently got platform-wide access. Separately, one `/models` route served the engine, ordinary project users and the platform admin console, so each fix aimed at one consumer opened a hole for another: addressing a key by name resolved the wrong row under multi-key, and adding a `configId` query param let any member reach a configuration excluded from their project. + +## Why + +A fail-open default in a credential resolver cannot be fixed by patching call sites, because the defect is the default and every future caller re-creates it. Making the argument required converts an omission into a compile error, and spelling `{ type: 'platform' }` turns the wide case into a claim a reviewer can check rather than an accident — only three consumers legitimately need it (the tool-search embedder, chat memory extraction, and the managed ACTIVEPIECES singleton). The route split follows the same principle at the HTTP boundary: `securityAccess` is per-route, so one route serving two authorization levels forces hand-rolled authz inside the handler, which is exactly the branch that leaked. Two routes let each state its own security config honestly. + +## Consequences + +`GET /v1/ai-providers` narrows: it no longer returns `id`, `config`, `modelIds` or `projectIds`, so a project caller can no longer see other projects' identifiers, and any new consumer wanting a full configuration must be a platform admin going through `/configs`. The AI piece's provider dropdown keeps working unchanged (it authenticates as ENGINE, which supplies its own project) but now sees one entry per provider instead of one per key, and its `AIProviderWithoutSensitiveData[]` annotation is wider than the real response. A project with no eligible key 404s rather than falling back to a platform key — scoping only ever narrows. Model routing, when revived, must keep addressing keys by row id and pass a scope like everyone else. diff --git a/brain/knowledge/decisions/providers-redesign-before-routing.md b/brain/knowledge/decisions/providers-redesign-before-routing.md new file mode 100644 index 000000000000..070cdd69e788 --- /dev/null +++ b/brain/knowledge/decisions/providers-redesign-before-routing.md @@ -0,0 +1,21 @@ +--- +status: accepted +--- + +# Providers redesign ships before model routing + +## Decision + +The 2026-08 replan of "AI Providers — One Experience, Any Provider" swaps milestone order: the AI providers page redesign (multi-key providers, per-key model allow-lists, per-key project scoping, "AI Center" page) is milestone 2; model routing moves to milestone 5. Routing PRs #14563 (backend) and #14587 (setup UI) stay parked open to revive later. + +## Context + +Routing was originally milestone 2 with its engine PR already open. Meanwhile the providers-page prototype (`proto-ai-providers-ui-v2`) converged on a data model where one platform holds multiple keys per provider, each with its own model and project scope. + +## Why + +The providers foundation must land first: routing's slot shape `{provider, modelId}` cannot address a specific key once multiple keys per provider exist, so routing built now would need rework. Routing's own scope also grew (metadata catalog, capability matching, custom tiers) beyond a single milestone slot. + +## Consequences + +Runtime key resolution is deterministic without a priority field: most specific project scope wins (selected > except > all), newest `created` breaks ties. Model routing, when revived, must address keys (rows), not provider names. Model facts (cost/context/speed) stay out of scope until a real catalog exists — pickers are names-only. diff --git a/packages/core/execution/src/lib/workers/worker-contract.ts b/packages/core/execution/src/lib/workers/worker-contract.ts index 9f15162a692f..eaa7ce50417a 100644 --- a/packages/core/execution/src/lib/workers/worker-contract.ts +++ b/packages/core/execution/src/lib/workers/worker-contract.ts @@ -193,6 +193,7 @@ export type UpdateProjectContextRequest = { conversationId: string runId?: string projectId: string | null + provider?: AIProviderName } export type ExecuteAgentToolRequest = { diff --git a/packages/core/shared/src/lib/management/ai-providers/index.ts b/packages/core/shared/src/lib/management/ai-providers/index.ts index ff605b20d624..d7f15fbef1aa 100644 --- a/packages/core/shared/src/lib/management/ai-providers/index.ts +++ b/packages/core/shared/src/lib/management/ai-providers/index.ts @@ -202,15 +202,32 @@ export const AIProvider = z.object({ export type AIProvider = z.infer +export const AiProviderModelScope = z.enum(['all', 'selected']) +export type AiProviderModelScope = z.infer + +export const AiProviderProjectScope = z.enum(['all', 'selected', 'except']) +export type AiProviderProjectScope = z.infer + export const AIProviderWithoutSensitiveData = z.object({ id: z.string(), name: z.string(), provider: z.nativeEnum(AIProviderName), config: AIProviderConfig, enabledForChat: z.boolean(), + modelScope: AiProviderModelScope, + modelIds: z.array(z.string()), + projectScope: AiProviderProjectScope, + projectIds: z.array(z.string()), }) export type AIProviderWithoutSensitiveData = z.infer +export const ProjectAIProvider = z.object({ + provider: z.nativeEnum(AIProviderName), + name: z.string(), + enabledForChat: z.boolean(), +}) +export type ProjectAIProvider = z.infer + export const AIProviderModel = z.object({ id: z.string(), name: z.string(), @@ -227,6 +244,10 @@ export const UpdateAIProviderRequest = z.object({ config: AIProviderConfig.optional(), auth: AIProviderAuthConfig.optional(), enabledForChat: z.boolean().optional(), + modelScope: AiProviderModelScope.optional(), + modelIds: z.array(z.string()).optional(), + projectScope: AiProviderProjectScope.optional(), + projectIds: z.array(z.string()).optional(), }) export type UpdateAIProviderRequest = z.infer diff --git a/packages/server/api/src/app/ai/ai-provider-controller.ts b/packages/server/api/src/app/ai/ai-provider-controller.ts index 9239669a07fb..12e47fa6d521 100644 --- a/packages/server/api/src/app/ai/ai-provider-controller.ts +++ b/packages/server/api/src/app/ai/ai-provider-controller.ts @@ -3,14 +3,26 @@ import { AIProviderModel, CreateAIProviderRequest, PrincipalType, UpdateAIProvid import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' import { z } from 'zod' +import { ProjectResourceType } from '../core/security/authorization/common' import { securityAccess } from '../core/security/authorization/fastify-security' import { assertCreditsAndAppSumoNotExceeded } from '../platform/billing-provider' import { aiProviderService } from './ai-provider-service' export const aiProviderController: FastifyPluginAsyncZod = async (app) => { - app.get('/', ListAIProviders, async (request) => { - const platformId = request.principal.platform.id - return aiProviderService(app.log).listProviders(platformId) + app.get('/', ListAIProvidersForProject, async (request) => { + return aiProviderService(app.log).listForProject({ + platformId: request.principal.platform.id, + projectId: request.projectId, + }) + }) + app.get('/configs', ListAIProviderConfigs, async (request) => { + return aiProviderService(app.log).listConfigs(request.principal.platform.id) + }) + app.get('/configs/:id/models', ListModelsForConfig, async (request) => { + return aiProviderService(app.log).listModelsForConfig({ + platformId: request.principal.platform.id, + configId: request.params.id, + }) }) app.get('/:provider/config', GetAIProviderConfig, async (request) => { const platformId = request.principal.platform.id @@ -18,11 +30,18 @@ export const aiProviderController: FastifyPluginAsyncZod = async (app) => { if (provider === AIProviderName.ACTIVEPIECES) { await assertCreditsAndAppSumoNotExceeded({ platformId, log: app.log }) } - return aiProviderService(app.log).getConfigOrThrow({ platformId, provider }) + return aiProviderService(app.log).getConfigOrThrow({ + platformId, + provider, + scope: { type: 'project', projectId: request.principal.projectId }, + }) }) app.get('/:provider/models', ListModels, async (request) => { - const platformId = request.principal.platform.id - return aiProviderService(app.log).listModels(platformId, request.params.provider) + return aiProviderService(app.log).listModels({ + platformId: request.principal.platform.id, + provider: request.params.provider, + scope: { type: 'project', projectId: request.projectId }, + }) }) app.post('/', CreateAIProvider, async (request) => { const platformId = request.principal.platform.id @@ -39,9 +58,34 @@ export const aiProviderController: FastifyPluginAsyncZod = async (app) => { }) } -const ListAIProviders = { +const ListAIProvidersForProject = { + config: { + security: securityAccess.project([PrincipalType.USER, PrincipalType.ENGINE], undefined, { type: ProjectResourceType.QUERY }), + }, + schema: { + querystring: z.object({ + projectId: z.string().optional(), + }), + }, +} + +const ListAIProviderConfigs = { + config: { + security: securityAccess.platformAdminOnly([PrincipalType.USER]), + }, +} + +const ListModelsForConfig = { config: { - security: securityAccess.publicPlatform([PrincipalType.USER, PrincipalType.ENGINE]), + security: securityAccess.platformAdminOnly([PrincipalType.USER]), + }, + schema: { + params: z.object({ + id: z.string(), + }), + response: { + [StatusCodes.OK]: z.array(AIProviderModel), + }, }, } @@ -58,12 +102,15 @@ const GetAIProviderConfig = { const ListModels = { config: { - security: securityAccess.publicPlatform([PrincipalType.USER, PrincipalType.ENGINE]), + security: securityAccess.project([PrincipalType.USER, PrincipalType.ENGINE], undefined, { type: ProjectResourceType.QUERY }), }, schema: { params: z.object({ provider: z.nativeEnum(AIProviderName), }), + querystring: z.object({ + projectId: z.string().optional(), + }), response: { [StatusCodes.OK]: z.array(AIProviderModel), }, diff --git a/packages/server/api/src/app/ai/ai-provider-entity.ts b/packages/server/api/src/app/ai/ai-provider-entity.ts index 191d33855a13..e054a681d30b 100644 --- a/packages/server/api/src/app/ai/ai-provider-entity.ts +++ b/packages/server/api/src/app/ai/ai-provider-entity.ts @@ -1,5 +1,5 @@ import { AIProviderName, BaseModelSchema } from '@activepieces/core-utils' -import { AIProviderConfig, Platform } from '@activepieces/shared' +import { AIProviderConfig, AiProviderModelScope, AiProviderProjectScope, Platform } from '@activepieces/shared' import { EntitySchema } from 'typeorm' import { z } from 'zod' import { ApIdSchema, BaseColumnSchemaPart } from '../database/database-common' @@ -13,6 +13,10 @@ const AIProviderEncrypted = z.object({ auth: EncryptedObject, config: AIProviderConfig, enabledForChat: z.boolean().default(false), + modelScope: AiProviderModelScope.default('all'), + modelIds: z.array(z.string()).default([]), + projectScope: AiProviderProjectScope.default('all'), + projectIds: z.array(z.string()).default([]), }) type AIProviderEncrypted = z.infer @@ -50,13 +54,45 @@ export const AIProviderEntity = new EntitySchema({ nullable: false, default: false, }, + modelScope: { + type: String, + nullable: false, + default: 'all', + }, + modelIds: { + type: String, + array: true, + nullable: false, + default: '{}', + }, + projectScope: { + type: String, + nullable: false, + default: 'all', + }, + projectIds: { + type: String, + array: true, + nullable: false, + default: '{}', + }, }, indices: [ { name: 'idx_ai_provider_platform_id_provider', columns: ['platformId', 'provider'], + }, + { + name: 'idx_ai_provider_platform_id_managed', + columns: ['platformId'], + where: `provider = '${AIProviderName.ACTIVEPIECES}'`, unique: true, }, + { + name: 'idx_ai_provider_project_ids_gin', + columns: ['projectIds'], + synchronize: false, + }, ], relations: { platform: { diff --git a/packages/server/api/src/app/ai/ai-provider-service.ts b/packages/server/api/src/app/ai/ai-provider-service.ts index a75bb499fe52..d9ce4a379d40 100644 --- a/packages/server/api/src/app/ai/ai-provider-service.ts +++ b/packages/server/api/src/app/ai/ai-provider-service.ts @@ -1,5 +1,5 @@ import { ActivepiecesError, AIProviderName, apId, ErrorCode, isNil, PlatformId, spreadIfDefined } from '@activepieces/core-utils' -import { ActivePiecesProviderAuthConfig, AIProviderAuthConfig, AIProviderConfig, AIProviderModel, AIProviderWithoutSensitiveData, BaseAIProviderAuthConfig, BedrockProviderAuthConfig, BedrockProviderConfig, CreateAIProviderRequest, GetProviderConfigResponse, UpdateAIProviderRequest } from '@activepieces/shared' +import { ActivePiecesProviderAuthConfig, AIProviderAuthConfig, AIProviderConfig, AIProviderModel, AiProviderProjectScope, AIProviderWithoutSensitiveData, CreateAIProviderRequest, GetProviderConfigResponse, ProjectAIProvider, UpdateAIProviderRequest } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import cron from 'node-cron' import { repoFactory } from '../core/db/repo-factory' @@ -25,70 +25,56 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ }) }, - async listProviders(platformId: PlatformId): Promise { - const aiCreditsEnabled = flagService(log).aiCreditsEnabled() - const activepiecesExists = await aiProviderRepo().existsBy({ - platformId, - provider: AIProviderName.ACTIVEPIECES, - }) - - if (aiCreditsEnabled && !activepiecesExists) { - const hasChatProvider = await aiProviderRepo().existsBy({ platformId, enabledForChat: true }) - await aiProviderRepo().save({ - id: apId(), - auth: await encryptUtils.encryptObject({}), - config: {}, - provider: AIProviderName.ACTIVEPIECES, - displayName: 'Activepieces', - platformId, - enabledForChat: !hasChatProvider, - }) - } - const configuredProviders = await aiProviderRepo().findBy({ platformId }) - - const hasActivepiecesProvider = configuredProviders.some((p) => p.provider === AIProviderName.ACTIVEPIECES) - const hideActivepiecesProvider = hasActivepiecesProvider && await isActivepiecesAiProviderHidden({ platformId, log }) - - return configuredProviders - .filter((p) => !(hideActivepiecesProvider && p.provider === AIProviderName.ACTIVEPIECES)) - .map((p): AIProviderWithoutSensitiveData => ({ - id: p.id, - name: p.displayName, - provider: p.provider, - config: p.config, - enabledForChat: p.enabledForChat ?? false, - })) + async listConfigs(platformId: PlatformId): Promise { + const rows = await listVisibleRows({ platformId, log }) + return rows.map(toConfigResponse) }, - async listModels(platformId: PlatformId, provider: AIProviderName): Promise { - const { config, auth } = await this.getConfigOrThrow({ platformId, provider }) - - const cacheKey = `${provider}-${getAuthCacheFingerprint({ provider, auth, config })}` - if (modelsCache.has(cacheKey) && !('models' in config)) { - return modelsCache.get(cacheKey)! - } - - const data = await aiProviders[provider].listModels(auth, config) + async listForProject({ platformId, projectId }: { platformId: PlatformId, projectId: string }): Promise { + const rows = await listVisibleRows({ platformId, log }) + const eligible = rows.filter((row) => rowAllowsScope({ row, scope: { type: 'project', projectId } })) + const chatEnabledProviders = new Set(eligible.filter((row) => row.enabledForChat === true).map((row) => row.provider)) + return rankRows(eligible).reduce((acc, row) => ( + acc.some((entry) => entry.provider === row.provider) + ? acc + : [...acc, { provider: row.provider, name: row.displayName, enabledForChat: chatEnabledProviders.has(row.provider) }] + ), []) + }, - modelsCache.set(cacheKey, data.map(model => ({ - id: model.id, - name: model.name, - type: model.type, - }))) + async listModels({ platformId, provider, scope }: { platformId: PlatformId, provider: AIProviderName, scope: ProviderScope }): Promise { + const aiProvider = await resolveEligibleRow({ platformId, provider, scope }) + const models = await fetchModels({ aiProvider, platformId }) + return aiProvider.modelScope === 'selected' + ? models.filter((model) => aiProvider.modelIds.includes(model.id)) + : models + }, - return modelsCache.get(cacheKey)! + async listModelsForConfig({ platformId, configId }: { platformId: PlatformId, configId: string }): Promise { + const aiProvider = await getRowByIdOrThrow({ platformId, configId }) + return fetchModels({ aiProvider, platformId }) }, - async create(platformId: PlatformId, request: CreateAIProviderRequest): Promise { + async create(platformId: PlatformId, request: CreateAIProviderRequest): Promise { + if (request.provider === AIProviderName.ACTIVEPIECES) { + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { message: 'aiProvider.activepiecesIsManaged' }, + }) + } await this.validateProviderCredentials(request.provider, request.auth, request.config) - await aiProviderRepo().save({ + const saved = await aiProviderRepo().save({ id: apId(), auth: await encryptUtils.encryptObject(request.auth), config: request.config, provider: request.provider, displayName: request.displayName, platformId, + modelScope: 'all', + modelIds: [], + projectScope: 'all', + projectIds: [], }) + return toConfigResponse(saved) }, async update(platformId: PlatformId, providerId: string, request: UpdateAIProviderRequest): Promise { const aiProvider = await aiProviderRepo().findOneBy({ @@ -116,8 +102,8 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ if (!isNil(request.auth)) { await this.validateProviderCredentials(aiProvider.provider, request.auth, config) } - else { - const { auth } = await this.getConfigOrThrow({ platformId, provider: aiProvider.provider }) + else if (!isNil(request.config)) { + const auth = await decryptRowAuth({ aiProvider, platformId }) await this.validateProviderCredentials(aiProvider.provider, auth, config) } @@ -126,6 +112,10 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ ...spreadIfDefined('auth', encryptedAuth), ...spreadIfDefined('config', request.config), ...spreadIfDefined('enabledForChat', request.enabledForChat), + ...spreadIfDefined('modelScope', request.modelScope), + ...spreadIfDefined('modelIds', request.modelIds), + ...spreadIfDefined('projectScope', request.projectScope), + ...spreadIfDefined('projectIds', request.projectIds), displayName: request.displayName, } @@ -140,29 +130,30 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ } }, - async getChatProviderName({ platformId }: { platformId: PlatformId }): Promise { - const chatProvider = await findAvailableChatProviderRow({ platformId, log }) + async getChatProviderName({ platformId, scope }: { platformId: PlatformId, scope: ProviderScope }): Promise { + const chatProvider = await findAvailableChatProviderRow({ platformId, scope, log }) return chatProvider?.provider ?? null }, - async getChatProvider({ platformId }: { platformId: PlatformId }): Promise { - const chatProvider = await findAvailableChatProviderRow({ platformId, log }) + async getChatProvider({ platformId, scope }: { platformId: PlatformId, scope: ProviderScope }): Promise { + const chatProvider = await findAvailableChatProviderRow({ platformId, scope, log }) if (isNil(chatProvider)) { return null } - let auth = await encryptUtils.decryptObject(chatProvider.auth) - if (chatProvider.provider === AIProviderName.ACTIVEPIECES) { - const doesHaveKeys = !isNil(auth) && 'apiKey' in auth && !isNil(auth.apiKey) && auth.apiKey !== '' - if (!doesHaveKeys) { - const enriched = await enrichWithKeysIfNeeded(chatProvider, platformId) - auth = enriched.auth - } - } + const auth = await decryptRowAuth({ aiProvider: chatProvider, platformId }) return { provider: chatProvider.provider, auth, config: chatProvider.config, platformId } }, - async exists({ platformId, provider }: { platformId: PlatformId, provider: AIProviderName }): Promise { - return aiProviderRepo().existsBy({ platformId, provider }) + async keyServesScope({ platformId, provider, resolvedFor, target }: { platformId: PlatformId, provider?: AIProviderName, resolvedFor: ProviderScope, target: ProviderScope }): Promise { + const candidates = await findRunKeyCandidates({ platformId, provider, scope: resolvedFor, log }) + return candidates.every((row) => target.type === 'platform' + ? row.projectScope === 'all' + : rowAllowsScope({ row, scope: target })) + }, + + async exists({ platformId, provider, scope }: { platformId: PlatformId, provider: AIProviderName, scope: ProviderScope }): Promise { + const rows = await aiProviderRepo().findBy({ platformId, provider }) + return rows.some((row) => rowAllowsScope({ row, scope })) }, async delete(platformId: PlatformId, providerId: string): Promise { @@ -192,71 +183,179 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ }) } }, - async getConfigOrThrow({ platformId, provider }: GetOrCreateActivepiecesConfigResponse): Promise { - const aiProvider = await aiProviderRepo().findOneBy({ - platformId, - provider, - }) - if (isNil(aiProvider)) { - throw new ActivepiecesError({ - code: ErrorCode.ENTITY_NOT_FOUND, - params: { - entityId: provider, - entityType: 'AIProvider', - }, - }, `the ${provider} AI provider is not configured on this platform`) - } + async getConfigOrThrow({ platformId, provider, scope }: { platformId: PlatformId, provider: AIProviderName, scope: ProviderScope }): Promise { + const aiProvider = await resolveEligibleRow({ platformId, provider, scope }) + const auth = await decryptRowAuth({ aiProvider, platformId }) + return { provider: aiProvider.provider, auth, config: aiProvider.config, platformId } + }, + async getOrCreateActivePiecesProviderAuthConfig(platformId: PlatformId): Promise { + await ensureManagedProviderRow({ platformId }) + const { auth } = await this.getConfigOrThrow({ platformId, provider: AIProviderName.ACTIVEPIECES, scope: { type: 'platform' } }) + return auth as ActivePiecesProviderAuthConfig + }, +}) - let auth = await encryptUtils.decryptObject(aiProvider.auth) +async function shouldHideActivepiecesAiProvider({ platformId, log }: { platformId: PlatformId, log: FastifyBaseLogger }): Promise { + const { plan } = await platformService(log).getOneWithPlanOrThrow(platformId) + return plan.embeddingEnabled +} - if (aiProvider.provider === AIProviderName.ACTIVEPIECES) { - const doesHaveKeys = !isNil(auth) && 'apiKey' in auth && !isNil(auth.apiKey) && auth.apiKey !== '' - if (!doesHaveKeys) { - const { auth: activePiecesAuth } = await enrichWithKeysIfNeeded(aiProvider, platformId) +const PROJECT_SCOPE_SPECIFICITY: Record = { + selected: 0, + except: 1, + all: 2, +} - auth = activePiecesAuth - } +function rowAllowsScope({ row, scope }: { row: AIProviderSchema, scope: ProviderScope }): boolean { + if (scope.type === 'platform') { + return true + } + switch (row.projectScope) { + case 'selected': + return row.projectIds.includes(scope.projectId) + case 'except': + return !row.projectIds.includes(scope.projectId) + default: + return true + } +} +function rankRows(rows: AIProviderSchema[]): AIProviderSchema[] { + return [...rows].sort((a, b) => { + const specificityDelta = PROJECT_SCOPE_SPECIFICITY[a.projectScope] - PROJECT_SCOPE_SPECIFICITY[b.projectScope] + if (specificityDelta !== 0) { + return specificityDelta } + return new Date(b.created).getTime() - new Date(a.created).getTime() + }) +} +function toConfigResponse(row: AIProviderSchema): AIProviderWithoutSensitiveData { + return { + id: row.id, + name: row.displayName, + provider: row.provider, + config: row.config, + enabledForChat: row.enabledForChat ?? false, + modelScope: row.modelScope, + modelIds: row.modelIds, + projectScope: row.projectScope, + projectIds: row.projectIds, + } +} - return { provider: aiProvider.provider, auth, config: aiProvider.config, platformId } - }, - async getOrCreateActivePiecesProviderAuthConfig(platformId: PlatformId): Promise { - const aiProvider = await aiProviderRepo().findOneBy({ - platformId, +async function ensureManagedProviderRow({ platformId }: { platformId: PlatformId }): Promise { + const exists = await aiProviderRepo().existsBy({ platformId, provider: AIProviderName.ACTIVEPIECES }) + if (exists) { + return + } + const hasChatProvider = await aiProviderRepo().existsBy({ platformId, enabledForChat: true }) + // Two concurrent readers both miss the existsBy above, so the row is inserted with + // ON CONFLICT DO NOTHING and idx_ai_provider_platform_id_managed - a partial unique + // index on (platformId) WHERE provider = 'activepieces' - decides which one lands. + await aiProviderRepo().createQueryBuilder() + .insert() + .values({ + id: apId(), + auth: await encryptUtils.encryptObject({}), + config: {}, provider: AIProviderName.ACTIVEPIECES, + displayName: 'Activepieces', + platformId, + enabledForChat: !hasChatProvider, + modelScope: 'all', + modelIds: [], + projectScope: 'all', + projectIds: [], }) - if (isNil(aiProvider)) { - const hasChatProvider = await aiProviderRepo().existsBy({ platformId, enabledForChat: true }) - await aiProviderRepo().save({ - id: apId(), - auth: await encryptUtils.encryptObject({}), - config: {}, - provider: AIProviderName.ACTIVEPIECES, - displayName: 'Activepieces', - platformId, - enabledForChat: !hasChatProvider, - }) - } + .orIgnore() + .execute() +} - const { auth } = await this.getConfigOrThrow({ platformId, provider: AIProviderName.ACTIVEPIECES }) - return auth as ActivePiecesProviderAuthConfig - }, -}) +async function listVisibleRows({ platformId, log }: { platformId: PlatformId, log: FastifyBaseLogger }): Promise { + if (flagService(log).aiCreditsEnabled()) { + await ensureManagedProviderRow({ platformId }) + } + const rows = await aiProviderRepo().findBy({ platformId }) + const hasActivepiecesRow = rows.some((row) => row.provider === AIProviderName.ACTIVEPIECES) + const hideActivepieces = hasActivepiecesRow && await isActivepiecesAiProviderHidden({ platformId, log }) + return rows.filter((row) => !(hideActivepieces && row.provider === AIProviderName.ACTIVEPIECES)) +} -async function shouldHideActivepiecesAiProvider({ platformId, log }: { platformId: PlatformId, log: FastifyBaseLogger }): Promise { - const { plan } = await platformService(log).getOneWithPlanOrThrow(platformId) - return plan.embeddingEnabled +async function findRunKeyCandidates({ platformId, provider, scope, log }: { platformId: PlatformId, provider?: AIProviderName, scope: ProviderScope, log: FastifyBaseLogger }): Promise { + const chatRow = await findAvailableChatProviderRow({ platformId, scope, log }) + const namedRow = isNil(provider) ? null : await findEligibleRow({ platformId, provider, scope }) + return [chatRow, namedRow].reduce((acc, row) => ( + isNil(row) || acc.some((seen) => seen.id === row.id) ? acc : [...acc, row] + ), []) } -type GetOrCreateActivepiecesConfigResponse = { - platformId: PlatformId - provider: AIProviderName +async function findEligibleRow({ platformId, provider, scope }: { platformId: PlatformId, provider: AIProviderName, scope: ProviderScope }): Promise { + const rows = await aiProviderRepo().findBy({ platformId, provider }) + const eligible = rows.filter((row) => rowAllowsScope({ row, scope })) + return rankRows(eligible)[0] ?? null } -async function findAvailableChatProviderRow({ platformId, log }: { platformId: PlatformId, log: FastifyBaseLogger }): Promise { - const chatProviders = await aiProviderRepo().findBy({ platformId, enabledForChat: true }) +async function resolveEligibleRow({ platformId, provider, scope }: { platformId: PlatformId, provider: AIProviderName, scope: ProviderScope }): Promise { + const winner = await findEligibleRow({ platformId, provider, scope }) + if (isNil(winner)) { + throw new ActivepiecesError({ + code: ErrorCode.ENTITY_NOT_FOUND, + params: { + entityId: provider, + entityType: 'AIProvider', + }, + }, scope.type === 'platform' + ? `the ${provider} AI provider is not configured on this platform` + : `no ${provider} AI provider key is available to this project`) + } + return winner +} + +async function getRowByIdOrThrow({ platformId, configId }: { platformId: PlatformId, configId: string }): Promise { + const aiProvider = await aiProviderRepo().findOneBy({ id: configId, platformId }) + if (isNil(aiProvider)) { + throw new ActivepiecesError({ + code: ErrorCode.ENTITY_NOT_FOUND, + params: { + entityId: configId, + entityType: 'AIProvider', + }, + }) + } + return aiProvider +} + +async function fetchModels({ aiProvider, platformId }: { aiProvider: AIProviderSchema, platformId: PlatformId }): Promise { + const { provider, config } = aiProvider + const auth = await decryptRowAuth({ aiProvider, platformId }) + const cacheKey = getModelsCacheKey({ provider, auth, config }) + if (!modelsCache.has(cacheKey) || 'models' in config) { + const data = await aiProviders[provider].listModels(auth, config) + modelsCache.set(cacheKey, data.map(model => ({ + id: model.id, + name: model.name, + type: model.type, + }))) + } + return modelsCache.get(cacheKey)! +} + +async function decryptRowAuth({ aiProvider, platformId }: { aiProvider: AIProviderSchema, platformId: PlatformId }): Promise { + const auth = await encryptUtils.decryptObject(aiProvider.auth) + if (aiProvider.provider === AIProviderName.ACTIVEPIECES) { + const doesHaveKeys = !isNil(auth) && 'apiKey' in auth && !isNil(auth.apiKey) && auth.apiKey !== '' + if (!doesHaveKeys) { + const { auth: activePiecesAuth } = await enrichWithKeysIfNeeded(aiProvider, platformId) + return activePiecesAuth + } + } + return auth +} + +async function findAvailableChatProviderRow({ platformId, scope, log }: { platformId: PlatformId, scope: ProviderScope, log: FastifyBaseLogger }): Promise { + const allChatProviders = await aiProviderRepo().findBy({ platformId, enabledForChat: true }) + const chatProviders = allChatProviders.filter((row) => rowAllowsScope({ row, scope })) if (!chatProviders.some((chatProvider) => chatProvider.provider === AIProviderName.ACTIVEPIECES)) { return chatProviders[0] ?? null } @@ -290,16 +389,10 @@ async function enrichWithKeysIfNeeded(aiProvider: AIProviderSchema, platformId: } -function getAuthCacheFingerprint({ provider, auth, config }: { provider: AIProviderName, auth: AIProviderAuthConfig, config: AIProviderConfig }): string { - switch (provider) { - case AIProviderName.BEDROCK: { - const { accessKeyId, secretAccessKey } = auth as BedrockProviderAuthConfig - const { region } = config as BedrockProviderConfig - return `${accessKeyId}-${secretAccessKey}-${region}` - } - default: { - const { apiKey } = auth as BaseAIProviderAuthConfig - return apiKey - } - } +function getModelsCacheKey({ provider, auth, config }: { provider: AIProviderName, auth: AIProviderAuthConfig, config: AIProviderConfig }): string { + return `${provider}-${JSON.stringify(auth)}-${JSON.stringify(config)}` } + +export type ProviderScope = + | { type: 'project', projectId: string } + | { type: 'platform' } diff --git a/packages/server/api/src/app/database/migration/postgres/1830000000000-AddAiProviderScopes.ts b/packages/server/api/src/app/database/migration/postgres/1830000000000-AddAiProviderScopes.ts new file mode 100644 index 000000000000..34ec434c89e4 --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1830000000000-AddAiProviderScopes.ts @@ -0,0 +1,107 @@ +import { QueryRunner } from 'typeorm' +import { system } from '../../../helper/system/system' +import { AppSystemProp } from '../../../helper/system/system-props' +import { DatabaseType } from '../../database-type' +import { Migration } from '../../migration' + +// transaction = false because CREATE INDEX CONCURRENTLY is illegal inside a +// transaction block. The column adds are instant (defaults on modern Postgres) +// and each statement runs autocommit. +// +// breaking = true because down() restores the UNIQUE (platformId, provider) index +// that up() drops so a platform can hold several keys per provider: once a second +// key exists the old shape no longer fits the data, so the rollback is one-way. +export class AddAiProviderScopes1830000000000 implements Migration { + name = 'AddAiProviderScopes1830000000000' + breaking = true + release = '0.88.3' + transaction = false + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS "idx_ai_provider_platform_id_provider" + `) + await queryRunner.query(` + ALTER TABLE "ai_provider" + ADD COLUMN IF NOT EXISTS "modelScope" character varying NOT NULL DEFAULT 'all', + ADD COLUMN IF NOT EXISTS "modelIds" character varying array NOT NULL DEFAULT '{}', + ADD COLUMN IF NOT EXISTS "projectScope" character varying NOT NULL DEFAULT 'all', + ADD COLUMN IF NOT EXISTS "projectIds" character varying array NOT NULL DEFAULT '{}' + `) + if (isPGlite()) { + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_ai_provider_platform_id_provider" + ON "ai_provider" ("platformId", "provider") + `) + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "idx_ai_provider_platform_id_managed" + ON "ai_provider" ("platformId") WHERE "provider" = 'activepieces' + `) + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_ai_provider_project_ids_gin" + ON "ai_provider" USING GIN ("projectIds") + `) + } + else { + // CONCURRENTLY avoids a ShareLock that would block writes on the + // existing "ai_provider" table for the duration of each build. A build + // that dies part-way leaves an INVALID index behind, which IF NOT EXISTS + // would happily skip on the next attempt, so drop that first. + await createIndexConcurrently(queryRunner, { + name: 'idx_ai_provider_platform_id_provider', + ddl: `CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_ai_provider_platform_id_provider" + ON "ai_provider" ("platformId", "provider")`, + }) + await createIndexConcurrently(queryRunner, { + name: 'idx_ai_provider_platform_id_managed', + ddl: `CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "idx_ai_provider_platform_id_managed" + ON "ai_provider" ("platformId") WHERE "provider" = 'activepieces'`, + }) + await createIndexConcurrently(queryRunner, { + name: 'idx_ai_provider_project_ids_gin', + ddl: `CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_ai_provider_project_ids_gin" + ON "ai_provider" USING GIN ("projectIds")`, + }) + } + } + + // No CONCURRENTLY here: TypeORM always wraps down() in a transaction, and + // concurrent index builds/drops are illegal inside one. + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS "idx_ai_provider_project_ids_gin" + `) + await queryRunner.query(` + DROP INDEX IF EXISTS "idx_ai_provider_platform_id_managed" + `) + await queryRunner.query(` + ALTER TABLE "ai_provider" + DROP COLUMN "modelScope", + DROP COLUMN "modelIds", + DROP COLUMN "projectScope", + DROP COLUMN "projectIds" + `) + await queryRunner.query(` + DROP INDEX IF EXISTS "idx_ai_provider_platform_id_provider" + `) + await queryRunner.query(` + CREATE UNIQUE INDEX "idx_ai_provider_platform_id_provider" + ON "ai_provider" ("platformId", "provider") + `) + } +} + +async function createIndexConcurrently(queryRunner: QueryRunner, { name, ddl }: { name: string, ddl: string }): Promise { + const invalid = await queryRunner.query( + `SELECT 1 FROM pg_class c + JOIN pg_index i ON i.indexrelid = c.oid + WHERE c.relname = $1 AND NOT i.indisvalid`, + [name], + ) + if (invalid.length > 0) { + await queryRunner.query(`DROP INDEX CONCURRENTLY IF EXISTS "${name}"`) + } + await queryRunner.query(ddl) +} + +const isPGlite = (): boolean => system.get(AppSystemProp.DB_TYPE) === DatabaseType.PGLITE diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index 1ab49d593ca6..10f7883b3f7a 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -421,6 +421,7 @@ import { AddAgentIdToAgentConversation1826000000000 } from './migration/postgres import { AddVersionToOtp1827000000000 } from './migration/postgres/1827000000000-AddVersionToOtp' import { DropChatbot1828000000000 } from './migration/postgres/1828000000000-DropChatbot' import { AddFilePlatformIdIndex1829000000000 } from './migration/postgres/1829000000000-AddFilePlatformIdIndex' +import { AddAiProviderScopes1830000000000 } from './migration/postgres/1830000000000-AddAiProviderScopes' const getSslConfig = (): boolean | TlsOptions => { const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL) @@ -857,6 +858,7 @@ export const getMigrations = (): (new () => Migration)[] => { AddVersionToOtp1827000000000, DropChatbot1828000000000, AddFilePlatformIdIndex1829000000000, + AddAiProviderScopes1830000000000, ] return migrations } diff --git a/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts b/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts index f2fe76ca7c95..6eac8bb179df 100644 --- a/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts @@ -164,7 +164,19 @@ export const agentConversationController: FastifyPluginAsyncZod = async (app) => }) } - await agentHelpers.assertRunProviderConfigured({ platformId, log, ...spreadIfDefined('provider', agentConfig?.provider ?? undefined) }) + // Resolve the project the run will adopt, so admission asks about the same credentials the + // worker will resolve. A brand-new conversation carries no project until the first turn + // persists one, and checking platform-wide there would admit a message no key can serve. + const runProjectId = agentHelpers.selectRunProject({ + conversationProjectId: conversation.projectId ?? null, + projects: await agentHelpers.getUserProjects({ platformId, userId, log }), + }) + await agentHelpers.assertRunProviderConfigured({ + platformId, + log, + scope: agentHelpers.runScopeOrThrow({ projectId: runProjectId }), + ...spreadIfDefined('provider', agentConfig?.provider ?? undefined), + }) await assertCreditsAndAppSumoNotExceeded({ platformId, log }) await jobQueue(runLog).add({ diff --git a/packages/server/api/src/app/ee/agent/agent-draft-ai.ts b/packages/server/api/src/app/ee/agent/agent-draft-ai.ts index 7f5456985ba9..29b8c252ecab 100644 --- a/packages/server/api/src/app/ee/agent/agent-draft-ai.ts +++ b/packages/server/api/src/app/ee/agent/agent-draft-ai.ts @@ -18,7 +18,7 @@ const DRAFT_SYSTEM_PROMPT = readFileSync(path.resolve('packages/server/api/src/a export const agentDraftAi = (log: FastifyBaseLogger) => ({ async draft({ platformId, projectId, prompt }: DraftParams): Promise { - const { data: resolved, error: modelError } = await tryCatch(() => agentHelpers.resolveTierModel({ platformId, tierId: FAST_TIER_ID, log })) + const { data: resolved, error: modelError } = await tryCatch(() => agentHelpers.resolveTierModel({ platformId, tierId: FAST_TIER_ID, scope: agentHelpers.runScopeOrThrow({ projectId }), log })) if (!isNil(modelError) || isNil(resolved)) { throw new ActivepiecesError({ code: ErrorCode.VALIDATION, @@ -32,7 +32,7 @@ export const agentDraftAi = (log: FastifyBaseLogger) => ({ let attempt = await runDraft({ model: resolved.model, prompt }) let usedModelId = resolved.modelId if (!isNil(attempt.error) && !rejectedCredentials(statusOf(attempt.error))) { - const { data: fallback } = await tryCatch(() => agentHelpers.resolveTierModel({ platformId, tierId: DEFAULT_CHAT_TIER_ID, log })) + const { data: fallback } = await tryCatch(() => agentHelpers.resolveTierModel({ platformId, tierId: DEFAULT_CHAT_TIER_ID, scope: agentHelpers.runScopeOrThrow({ projectId }), log })) if (!isNil(fallback) && fallback.modelId !== resolved.modelId) { log.warn({ from: resolved.modelId, to: fallback.modelId, platform: { id: platformId } }, '[agentDraftAi] Retrying the draft on the model chat runs on') attempt = await runDraft({ model: fallback.model, prompt }) @@ -119,7 +119,7 @@ function parseDraft(raw: string): DraftAgentResponse | null { async function debitDraft({ platformId, projectId, log }: { platformId: PlatformId, projectId: ProjectId, log: FastifyBaseLogger }): Promise { const { error } = await tryCatch(async () => { - const provider = await agentHelpers.resolveChatProviderName({ platformId, log }) + const provider = await agentHelpers.resolveChatProviderName({ platformId, projectId, log }) const value = provider === AIProviderName.ACTIVEPIECES ? agentHelpers.resolveTier({ tierId: FAST_TIER_ID }).creditWeight : CHAT_BYOK_CREDIT_WEIGHT const platformPlan = await platformPlanService(log).getOrCreateForPlatform(platformId) const usage = { diff --git a/packages/server/api/src/app/ee/agent/agent-helpers.ts b/packages/server/api/src/app/ee/agent/agent-helpers.ts index 1ae087782db9..10df0b95c095 100644 --- a/packages/server/api/src/app/ee/agent/agent-helpers.ts +++ b/packages/server/api/src/app/ee/agent/agent-helpers.ts @@ -5,7 +5,7 @@ import { SharedV3ProviderOptions } from '@ai-sdk/provider' import { EmbeddingModel, LanguageModel } from 'ai' import { FastifyBaseLogger } from 'fastify' import { Repository } from 'typeorm' -import { aiProviderService } from '../../ai/ai-provider-service' +import { aiProviderService, ProviderScope } from '../../ai/ai-provider-service' import { repoFactory } from '../../core/db/repo-factory' import { transaction } from '../../core/db/transaction' import { redisConnections } from '../../database/redis-connections' @@ -62,15 +62,55 @@ async function getUserProjects({ platformId, userId, log }: { platformId: string return allProjects.filter((p) => p.type !== ProjectType.PERSONAL || p.ownerId === userId) } -async function resolveRunProvider({ platformId, provider, log }: { platformId: string, provider?: AIProviderName, log: FastifyBaseLogger }): Promise { +// A run always resolves its credential inside a project. Coercing a missing project to platform +// scope would make every key on the platform eligible, ignoring the project restrictions their +// owner set, so a run with nowhere to happen is refused instead. +function runScopeOrThrow({ projectId }: { projectId: string | null }): ProviderScope { + if (isNil(projectId)) { + throw new ActivepiecesError({ + code: ErrorCode.ENTITY_NOT_FOUND, + params: { entityId: 'project', entityType: 'Project' }, + }, 'this chat belongs to no project, so no AI provider key can be resolved for it') + } + return { type: 'project', projectId } +} + +// The project a chat turn runs in: the conversation's own if the user still reaches it, else the +// first they can see. Admission and the run both read it, so a message is accepted against the +// same project whose credentials will serve it. +function selectRunProject({ conversationProjectId, projects }: { conversationProjectId: string | null, projects: Project[] }): string | null { + const stillReachable = !isNil(conversationProjectId) && projects.some((project) => project.id === conversationProjectId) + return stillReachable ? conversationProjectId : projects[0]?.id ?? null +} + +async function assertProjectSwitchKeepsKey({ platformId, provider, fromProjectId, toProjectId, log }: { platformId: string, provider?: AIProviderName, fromProjectId: string | null, toProjectId: string | null, log: FastifyBaseLogger }): Promise { + if (isNil(fromProjectId) || fromProjectId === toProjectId) { + return + } + const serves = await aiProviderService(log).keyServesScope({ + platformId, + ...spreadIfDefined('provider', provider), + resolvedFor: { type: 'project', projectId: fromProjectId }, + target: isNil(toProjectId) ? { type: 'platform' } : { type: 'project', projectId: toProjectId }, + }) + if (serves) { + return + } + throw new ActivepiecesError({ + code: ErrorCode.AUTHORIZATION, + params: { message: 'the AI provider key this run resolved is not available to the project it tried to switch to' }, + }) +} + +async function resolveRunProvider({ platformId, provider, scope, log }: { platformId: string, provider?: AIProviderName, scope: ProviderScope, log: FastifyBaseLogger }): Promise { if (isNil(provider)) { - return resolveChatProvider({ platformId, log }) + return resolveChatProvider({ platformId, scope, log }) } - return aiProviderService(log).getConfigOrThrow({ platformId, provider }) + return aiProviderService(log).getConfigOrThrow({ platformId, provider, scope }) } -async function resolveChatProvider({ platformId, log }: { platformId: string, log: FastifyBaseLogger }): Promise { - const chatProvider = await aiProviderService(log).getChatProvider({ platformId }) +async function resolveChatProvider({ platformId, scope, log }: { platformId: string, scope: ProviderScope, log: FastifyBaseLogger }): Promise { + const chatProvider = await aiProviderService(log).getChatProvider({ platformId, scope }) if (isNil(chatProvider)) { throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, @@ -80,9 +120,9 @@ async function resolveChatProvider({ platformId, log }: { platformId: string, lo return chatProvider } -async function assertRunProviderConfigured({ platformId, provider, log }: { platformId: string, provider?: AIProviderName | null, log: FastifyBaseLogger }): Promise { +async function assertRunProviderConfigured({ platformId, provider, scope, log }: { platformId: string, provider?: AIProviderName | null, scope: ProviderScope, log: FastifyBaseLogger }): Promise { if (isNil(provider)) { - const chatProvider = await aiProviderService(log).getChatProviderName({ platformId }) + const chatProvider = await aiProviderService(log).getChatProviderName({ platformId, scope }) if (isNil(chatProvider)) { throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, @@ -91,12 +131,14 @@ async function assertRunProviderConfigured({ platformId, provider, log }: { plat } return } - const configured = await aiProviderService(log).exists({ platformId, provider }) + const configured = await aiProviderService(log).exists({ platformId, provider, scope }) if (!configured) { throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, params: { entityId: provider, entityType: 'AIProvider' }, - }, `the ${provider} AI provider is not configured on this platform`) + }, scope.type === 'platform' + ? `the ${provider} AI provider is not configured on this platform` + : `no ${provider} AI provider key is available to this project`) } } @@ -141,8 +183,8 @@ function resolveModelIdForAnalytics({ provider, selectedModel }: { provider: AIP return aiProviderUtils.isCuratedChatModelId({ modelId: selectedModel }) ? selectedModel : null } -async function resolveTierModel({ platformId, tierId, provider, log }: { platformId: string, tierId: string, provider?: AIProviderName, log: FastifyBaseLogger }): Promise<{ model: LanguageModel, modelId: string, provider: AIProviderName }> { - const providerConfig = await resolveRunProvider({ platformId, log, ...spreadIfDefined('provider', provider) }) +async function resolveTierModel({ platformId, tierId, provider, scope, log }: { platformId: string, tierId: string, provider?: AIProviderName, scope: ProviderScope, log: FastifyBaseLogger }): Promise<{ model: LanguageModel, modelId: string, provider: AIProviderName }> { + const providerConfig = await resolveRunProvider({ platformId, scope, log, ...spreadIfDefined('provider', provider) }) const modelId = resolveModelIdForProvider({ provider: providerConfig.provider, selectedModel: tierId }) return { model: agentAiUtils.createChatModel({ @@ -156,16 +198,16 @@ async function resolveTierModel({ platformId, tierId, provider, log }: { platfor } } -async function resolveFastModel({ platformId, provider, log }: { platformId: string, provider?: AIProviderName, log: FastifyBaseLogger }): Promise { - return (await resolveTierModel({ platformId, tierId: FAST_TIER_ID, log, ...spreadIfDefined('provider', provider) })).model +async function resolveFastModel({ platformId, provider, scope, log }: { platformId: string, provider?: AIProviderName, scope: ProviderScope, log: FastifyBaseLogger }): Promise { + return (await resolveTierModel({ platformId, tierId: FAST_TIER_ID, scope, log, ...spreadIfDefined('provider', provider) })).model } function resolveFastModelId({ provider }: { provider: AIProviderName }): string { return resolveModelIdForProvider({ provider, selectedModel: FAST_TIER_ID }) } -async function resolveEmbeddingModel({ platformId, provider, log }: { platformId: string, provider?: AIProviderName, log: FastifyBaseLogger }): Promise<{ model: EmbeddingModel, providerOptions: SharedV3ProviderOptions }> { - const providerConfig = await resolveRunProvider({ platformId, log, ...spreadIfDefined('provider', provider) }) +async function resolveEmbeddingModel({ platformId, provider, scope, log }: { platformId: string, provider?: AIProviderName, scope: ProviderScope, log: FastifyBaseLogger }): Promise<{ model: EmbeddingModel, providerOptions: SharedV3ProviderOptions }> { + const providerConfig = await resolveRunProvider({ platformId, scope, log, ...spreadIfDefined('provider', provider) }) return agentAiUtils.createEmbeddingModel({ provider: providerConfig.provider, auth: providerConfig.auth, @@ -173,8 +215,14 @@ async function resolveEmbeddingModel({ platformId, provider, log }: { platformId }) } -async function resolveChatProviderName({ platformId, log }: { platformId: string, log: FastifyBaseLogger }): Promise { - const result = await tryCatch(() => aiProviderService(log).getChatProviderName({ platformId })) +// Analytics and billing label a turn with the provider that served it. A conversation with no +// project has no key to name, and guessing platform-wide would report a credential the run was +// never allowed to use, so the provider stays unknown. +async function resolveChatProviderName({ platformId, projectId, log }: { platformId: string, projectId: string | null, log: FastifyBaseLogger }): Promise { + if (isNil(projectId)) { + return null + } + const result = await tryCatch(() => aiProviderService(log).getChatProviderName({ platformId, scope: { type: 'project', projectId } })) return result.error ? null : result.data } @@ -284,6 +332,9 @@ export const agentHelpers = { resolveRunProvider, resolveEmbeddingModel, resolveChatProviderName, + runScopeOrThrow, + selectRunProject, + assertProjectSwitchKeepsKey, recoverAllStaleStreamingConversations, incrementAndCheckLimit, conversationRepo, diff --git a/packages/server/api/src/app/ee/agent/agent-memory-ai.ts b/packages/server/api/src/app/ee/agent/agent-memory-ai.ts index f0bff6b5f58b..569eb980cb88 100644 --- a/packages/server/api/src/app/ee/agent/agent-memory-ai.ts +++ b/packages/server/api/src/app/ee/agent/agent-memory-ai.ts @@ -53,7 +53,7 @@ async function runMemoryLlm({ platformId, instructions, prompt, schema, log } log: FastifyBaseLogger }): Promise { const { data, error } = await tryCatch(async () => { - const { text: raw } = await generateText({ model: await agentHelpers.resolveFastModel({ platformId, log }), instructions, prompt, telemetry: agentAiUtils.buildTelemetry({ functionId: 'agent-memory' }) }) + const { text: raw } = await generateText({ model: await agentHelpers.resolveFastModel({ platformId, scope: { type: 'platform' }, log }), instructions, prompt, telemetry: agentAiUtils.buildTelemetry({ functionId: 'agent-memory' }) }) return parseJsonObject(raw, schema) }) if (!isNil(error)) { diff --git a/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts b/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts index 6106a3a28fea..ad5d9d6fc9f4 100644 --- a/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts +++ b/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts @@ -82,9 +82,8 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ // must not change how it behaves for everyone else who talks to it. const carriesChatContext = requestedSource !== AgentRunSource.FLOW_STEP && requestedSource !== AgentRunSource.AGENT - const [conversation, providerConfig, userProjects, enabledAiTools] = await Promise.all([ + const [conversation, userProjects, enabledAiTools] = await Promise.all([ loadOrStartConversation({ conversationId, platformId, userId, source: requestedSource, projectId: requestedProjectId, modelName }), - agentHelpers.resolveRunProvider({ platformId, log, ...spreadIfDefined('provider', input.provider) }), agentHelpers.getUserProjects({ platformId, userId, log }), aiToolConfigService(log).getEnabledTools({ platformId }), ]) @@ -105,11 +104,25 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ ? userProjects.filter((p) => p.id === conversation.projectId) : userProjects - const attachmentProjectId = (conversation.projectId && scopedProjects.some((p) => p.id === conversation.projectId)) + const validCandidateProjectId = conversation.projectId && scopedProjects.some((p) => p.id === conversation.projectId) ? conversation.projectId - : scopedProjects[0]?.id - const attachmentRefs = files && files.length > 0 && !isNil(attachmentProjectId) - ? await persistAgentAttachments({ files, projectId: attachmentProjectId, platformId, log }) + : null + // Default to the user's first project when none is chosen so the agent never hits a cold + // "No project selected" on the first data tool. The chat MCP server resolves its project + // from conversation.projectId per request, so persist it below (the user can switch via the + // dropdown / ap_select_project, which overwrites this). + const selectedProjectId = agentHelpers.selectRunProject({ conversationProjectId: conversation.projectId ?? null, projects: scopedProjects }) + + // Settled before the provider is resolved: the turn runs inside this project, so the + // credential has to be chosen for it. Resolving earlier, while the project was still + // unknown, is what let a projectless conversation pick a key scoped away from the project + // it then adopted. A flow step reads its own conversation's project rather than the + // selection above, which narrows to what the owner can still see in chat. + const runProjectId = isFlowStep ? conversation.projectId ?? null : selectedProjectId + const providerConfig = await agentHelpers.resolveRunProvider({ platformId, log, scope: agentHelpers.runScopeOrThrow({ projectId: runProjectId }), ...spreadIfDefined('provider', input.provider) }) + + const attachmentRefs = files && files.length > 0 && !isNil(selectedProjectId) + ? await persistAgentAttachments({ files, projectId: selectedProjectId, platformId, log }) : [] const userContent = await buildUserContentWithFiles({ text: userMessage, files, attachmentNote: buildAttachmentNote(attachmentRefs) }) @@ -136,15 +149,6 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ }) } - const candidateProjectId = conversation.projectId ?? null - const validCandidateProjectId = candidateProjectId && scopedProjects.some((p) => p.id === candidateProjectId) - ? candidateProjectId - : null - // Default to the user's first project when none is chosen so the agent never hits a cold - // "No project selected" on the first data tool. The chat MCP server resolves its project - // from conversation.projectId per request, so persist it (the user can switch via the - // dropdown / ap_select_project, which overwrites this). - const selectedProjectId = validCandidateProjectId ?? scopedProjects[0]?.id ?? null if (!dryRun && isNil(validCandidateProjectId) && !isNil(selectedProjectId)) { await agentHelpers.conversationRepo().update(conversationId, { projectId: selectedProjectId }) } @@ -401,6 +405,15 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ params: { message: 'A flow-step agent run cannot move to another project' }, }) } + if (!isNil(conversation)) { + await agentHelpers.assertProjectSwitchKeepsKey({ + platformId: conversation.platformId, + fromProjectId: conversation.projectId ?? null, + toProjectId: input.projectId, + ...spreadIfDefined('provider', input.provider), + log, + }) + } await updateConversationForRun({ conversationId: input.conversationId, runId: input.runId, updates: { projectId: input.projectId } }) log.info({ conversation: { id: input.conversationId }, project: input.projectId ? { id: input.projectId } : undefined }, '[agentRpc#updateProjectContext] Project context updated') }, @@ -443,7 +456,7 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, params: { message: 'This run is not allowed to run a configured piece tool' } }) } const { projectId, platformId } = conversation - const model = await agentHelpers.resolveFastModel({ platformId, log, ...spreadIfDefined('provider', input.provider) }) + const model = await agentHelpers.resolveFastModel({ platformId, scope: { type: 'project', projectId }, log, ...spreadIfDefined('provider', input.provider) }) const { data: run, error: runError } = await tryCatch(() => pieceToolRunner.runFromInstruction({ model, piece: { pieceName: input.piece.pieceName, actionName: input.piece.actionName, pieceVersion: input.piece.pieceVersion }, @@ -469,7 +482,7 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ } const { projectId, platformId } = conversation await knowledgeBaseService(log).getFileOrThrow({ projectId, id: input.knowledgeBaseFileId }) - const { model, providerOptions } = await agentHelpers.resolveEmbeddingModel({ platformId, log, ...spreadIfDefined('provider', input.provider) }) + const { model, providerOptions } = await agentHelpers.resolveEmbeddingModel({ platformId, scope: { type: 'project', projectId }, log, ...spreadIfDefined('provider', input.provider) }) const { embedding } = await embed({ model, value: input.query, providerOptions }) const results = await knowledgeBaseService(log).search({ projectId, diff --git a/packages/server/api/src/app/ee/agent/agent-run-controller.ts b/packages/server/api/src/app/ee/agent/agent-run-controller.ts index 0eac2e60aa54..ce28c8107fa8 100644 --- a/packages/server/api/src/app/ee/agent/agent-run-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-run-controller.ts @@ -48,7 +48,7 @@ export const agentRunController: FastifyPluginAsyncZod = async (app) => { }) } const flowTools = await resolveFlowTools({ projectId, flowToolRequests, log: request.log }) - await agentHelpers.assertRunProviderConfigured({ platformId: platform.id, provider, log: request.log }) + await agentHelpers.assertRunProviderConfigured({ platformId: platform.id, provider, scope: agentHelpers.runScopeOrThrow({ projectId }), log: request.log }) await assertCreditsAndAppSumoNotExceeded({ platformId: platform.id, log: request.log }) const { ownerId } = await projectService(request.log).getOneOrThrow(projectId) diff --git a/packages/server/api/src/app/ee/agent/chat-analytics-sync.ts b/packages/server/api/src/app/ee/agent/chat-analytics-sync.ts index e78e8dc8f1c5..6f93fe5de5eb 100644 --- a/packages/server/api/src/app/ee/agent/chat-analytics-sync.ts +++ b/packages/server/api/src/app/ee/agent/chat-analytics-sync.ts @@ -127,17 +127,25 @@ async function resolveLicenseKeysByPlatform({ platformIds }: { return map } +function chatProviderCacheKey(conversation: AgentConversation): string { + return `${conversation.platformId}:${conversation.projectId ?? ''}` +} + async function resolveLookups({ conversations, log }: { conversations: AgentConversation[] log: FastifyBaseLogger }): Promise { const uniqueUserIds = [...new Set(conversations.map((c) => c.userId))] const uniquePlatformIds = [...new Set(conversations.map((c) => c.platformId))] + const uniqueScopes = [...new Map(conversations.map((c) => [chatProviderCacheKey(c), c])).values()] const [userEntries, platformNameEntries, providerEntries] = await Promise.all([ Promise.all(uniqueUserIds.map(async (userId): Promise<[string, string | null]> => [userId, await resolveUserEmail({ userId, log })])), Promise.all(uniquePlatformIds.map(async (platformId): Promise<[string, string | null]> => [platformId, await resolvePlatformName({ platformId, log })])), - Promise.all(uniquePlatformIds.map(async (platformId): Promise<[string, AIProviderName | null]> => [platformId, await agentHelpers.resolveChatProviderName({ platformId, log })])), + Promise.all(uniqueScopes.map(async (conversation): Promise<[string, AIProviderName | null]> => [ + chatProviderCacheKey(conversation), + await agentHelpers.resolveChatProviderName({ platformId: conversation.platformId, projectId: conversation.projectId ?? null, log }), + ])), ]) return { @@ -209,7 +217,7 @@ async function toSyncPayload({ conversation, licenseKey, log, userCache, platfor }): Promise> { const userEmail = userCache?.get(conversation.userId) ?? await resolveUserEmail({ userId: conversation.userId, log }) const platformName = platformCache?.get(conversation.platformId) ?? await resolvePlatformName({ platformId: conversation.platformId, log }) - const provider = providerCache?.get(conversation.platformId) ?? await agentHelpers.resolveChatProviderName({ platformId: conversation.platformId, log }) + const provider = providerCache?.get(chatProviderCacheKey(conversation)) ?? await agentHelpers.resolveChatProviderName({ platformId: conversation.platformId, projectId: conversation.projectId ?? null, log }) const messages = agentHistory.resolveMessages({ conversation, log }) diff --git a/packages/server/api/src/app/ee/agent/chat-usage-tracker.ts b/packages/server/api/src/app/ee/agent/chat-usage-tracker.ts index bb6068fac85d..e4e4a713d249 100644 --- a/packages/server/api/src/app/ee/agent/chat-usage-tracker.ts +++ b/packages/server/api/src/app/ee/agent/chat-usage-tracker.ts @@ -19,7 +19,11 @@ export const chatUsageTracker = (log: FastifyBaseLogger) => ({ const turnIndex = messages.filter((message) => message.role === PersistedAgentRole.USER).length const idempotencyScope = runId ?? turnIndex - const provider = await agentHelpers.resolveChatProviderName({ platformId: conversation.platformId, log }) + const provider = await agentHelpers.resolveChatProviderName({ + platformId: conversation.platformId, + projectId: conversation.projectId ?? null, + log, + }) const model = agentHelpers.resolveModelIdForAnalytics({ selectedModel: conversation.modelName ?? null, provider }) const isManagedProvider = provider === AIProviderName.ACTIVEPIECES diff --git a/packages/server/api/src/app/mcp/tools/ap-list-ai-models.ts b/packages/server/api/src/app/mcp/tools/ap-list-ai-models.ts index 9f269fce5bc0..53ea09f74700 100644 --- a/packages/server/api/src/app/mcp/tools/ap-list-ai-models.ts +++ b/packages/server/api/src/app/mcp/tools/ap-list-ai-models.ts @@ -23,7 +23,7 @@ export const apListAiModelsTool = (mcp: ProjectScopedMcpServer, log: FastifyBase const platformId = await mcpUtils.resolvePlatformId({ mcp, log }) const service = aiProviderService(log) - const providers = await service.listProviders(platformId) + const providers = await service.listForProject({ platformId, projectId: mcp.projectId }) if (providers.length === 0) { return { @@ -49,7 +49,7 @@ export const apListAiModelsTool = (mcp: ProjectScopedMcpServer, log: FastifyBase const sections = await Promise.all( filteredProviders.map(async (p) => { try { - const models = await service.listModels(platformId, p.provider) + const models = await service.listModels({ platformId, provider: p.provider, scope: { type: 'project', projectId: mcp.projectId } }) const textModels = models.filter(m => m.type === AIProviderModelType.TEXT) const capped = textModels.slice(0, MAX_MODELS_PER_PROVIDER) structuredProviders.push({ diff --git a/packages/server/api/src/app/mcp/tools/ap-setup-guide.ts b/packages/server/api/src/app/mcp/tools/ap-setup-guide.ts index f4fb44fafd53..5014e7c6b126 100644 --- a/packages/server/api/src/app/mcp/tools/ap-setup-guide.ts +++ b/packages/server/api/src/app/mcp/tools/ap-setup-guide.ts @@ -190,7 +190,7 @@ function formatAuthSteps({ auth, displayName }: { auth: Record, async function aiProviderGuide(mcp: ProjectScopedMcpServer, log: FastifyBaseLogger): Promise<{ content: [{ type: 'text', text: string }] }> { const project = await projectService(log).getOneOrThrow(mcp.projectId) - const providers = await aiProviderService(log).listProviders(project.platformId) + const providers = await aiProviderService(log).listForProject({ platformId: project.platformId, projectId: mcp.projectId }) const lines: string[] = [] diff --git a/packages/server/api/src/app/tool-search/resolve-embedder.ts b/packages/server/api/src/app/tool-search/resolve-embedder.ts index 5f34cf97c5eb..a7960682f1a9 100644 --- a/packages/server/api/src/app/tool-search/resolve-embedder.ts +++ b/packages/server/api/src/app/tool-search/resolve-embedder.ts @@ -25,7 +25,7 @@ export async function resolveEmbedder({ platformId, log }: ResolveEmbedderParams return selectEmbedder(envApiKey) } const { data: config } = await tryCatch(() => - aiProviderService(log).getConfigOrThrow({ platformId, provider: AIProviderName.OPENAI }), + aiProviderService(log).getConfigOrThrow({ platformId, provider: AIProviderName.OPENAI, scope: { type: 'platform' } }), ) if (isNil(config)) { return null diff --git a/packages/server/api/test/helpers/mocks/index.ts b/packages/server/api/test/helpers/mocks/index.ts index d66f027ec15f..95142f21de43 100644 --- a/packages/server/api/test/helpers/mocks/index.ts +++ b/packages/server/api/test/helpers/mocks/index.ts @@ -688,7 +688,7 @@ export const createMockProjectRelease = (projectRelease?: Partial & { enabledForChat?: boolean }): Promise> => { +export const createMockAIProvider = async (aiProvider?: MockAIProviderParams): Promise> => { return { id: aiProvider?.id ?? apId(), created: aiProvider?.created ?? faker.date.recent().toISOString(), @@ -700,17 +700,28 @@ export const createMockAIProvider = async (aiProvider?: Partial & { apiKey: process.env.OPENAI_API_KEY || faker.string.uuid(), }), config: aiProvider?.config ?? {}, - enabledForChat: aiProvider?.enabledForChat ?? aiProvider?.provider === AIProviderName.ACTIVEPIECES, + enabledForChat: aiProvider?.enabledForChat ?? (aiProvider?.provider === AIProviderName.ACTIVEPIECES), + modelScope: aiProvider?.modelScope ?? 'all', + modelIds: aiProvider?.modelIds ?? [], + projectScope: aiProvider?.projectScope ?? 'all', + projectIds: aiProvider?.projectIds ?? [], } } -export const mockAndSaveAIProvider = async (params?: Partial & { enabledForChat?: boolean }): Promise> => { +export const mockAndSaveAIProvider = async (params?: MockAIProviderParams): Promise> => { const mockAIProvider = await createMockAIProvider(params) - await databaseConnection().getRepository('ai_provider').upsert(mockAIProvider, ['platformId', 'provider']) + await databaseConnection().getRepository('ai_provider').save(mockAIProvider) return mockAIProvider } +type MockOtpWithCode = { + otp: OtpModel + code: string +} + +type MockAIProviderParams = Partial & Partial> + export const mockPieceMetadata = async (mockLog: FastifyBaseLogger): Promise => { const { mockPlatform } = await mockAndSaveBasicSetup() const mockPieceMetadata = createMockPieceMetadata({ @@ -790,8 +801,3 @@ type MockBasicSetupParams = { platform?: Partial project?: Partial } - -type MockOtpWithCode = { - otp: OtpModel - code: string -} diff --git a/packages/server/api/test/integration/ce/ai-provider/ai-provider.test.ts b/packages/server/api/test/integration/ce/ai-provider/ai-provider.test.ts index 0755fd460ffb..df5b3938ec77 100644 --- a/packages/server/api/test/integration/ce/ai-provider/ai-provider.test.ts +++ b/packages/server/api/test/integration/ce/ai-provider/ai-provider.test.ts @@ -1,12 +1,13 @@ import { AIProviderName, apId } from '@activepieces/core-utils' -import { DefaultProjectRole, PrincipalType } from '@activepieces/shared' +import { AIProviderModelType, DefaultProjectRole, PrincipalType, ProviderModelConfig } from '@activepieces/shared' import { FastifyInstance } from 'fastify' import { StatusCodes } from 'http-status-codes' import { generateMockToken } from '../../../helpers/auth' import { db } from '../../../helpers/db' -import { mockAndSaveAIProvider } from '../../../helpers/mocks' +import { createMockProject, mockAndSaveAIProvider } from '../../../helpers/mocks' import { createMemberContext, createTestContext, TestContext } from '../../../helpers/test-context' import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' +import { aiProviderService } from '../../../../src/app/ai/ai-provider-service' let app: FastifyInstance | null = null let ctx: TestContext @@ -153,8 +154,8 @@ describe('AI Providers API', () => { }) }) - describe('GET /v1/ai-providers (list)', () => { - it('should include config with defaultHeaders when listing providers', async () => { + describe('GET /v1/ai-providers/configs (admin list)', () => { + it('should include config with defaultHeaders when listing configurations', async () => { await mockAndSaveAIProvider({ platformId: ctx.platform.id, provider: AIProviderName.CUSTOM, @@ -167,17 +168,94 @@ describe('AI Providers API', () => { }, }) - const response = await ctx.get('/v1/ai-providers') + const response = await ctx.get('/v1/ai-providers/configs') expect(response?.statusCode).toBe(StatusCodes.OK) const body = response?.json() const customProvider = body.find( - (p: any) => p.provider === AIProviderName.CUSTOM, + (p: { provider: string }) => p.provider === AIProviderName.CUSTOM, ) expect(customProvider).toBeDefined() expect(customProvider.config.defaultHeaders).toEqual({ 'X-Test': 'test' }) }) + + it('forbids a non-admin platform member from listing configurations', async () => { + const memberCtx = await createMemberContext(app!, ctx, { + projectRole: DefaultProjectRole.VIEWER, + }) + + const response = await memberCtx.get('/v1/ai-providers/configs') + + expect(response?.statusCode).toBe(StatusCodes.FORBIDDEN) + }) + }) + + describe('GET /v1/ai-providers (project list)', () => { + it('hides a configuration whose project scope excludes the caller project', async () => { + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Other project only', + config: { + baseUrl: 'https://api.example.com/v1', + apiKeyHeader: 'Authorization', + models: [], + }, + projectScope: 'selected', + projectIds: [apId()], + }) + + const response = await ctx.get('/v1/ai-providers', { projectId: ctx.project.id }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + expect(response?.json().some((p: { provider: string }) => p.provider === AIProviderName.CUSTOM)).toBe(false) + }) + + it('never leaks project ids or model allow-lists to a project caller', async () => { + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Scoped elsewhere', + config: { + baseUrl: 'https://api.example.com/v1', + apiKeyHeader: 'Authorization', + models: [], + }, + projectScope: 'except', + projectIds: [apId()], + modelScope: 'selected', + modelIds: ['model-a'], + }) + + const response = await ctx.get('/v1/ai-providers', { projectId: ctx.project.id }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + for (const entry of response?.json()) { + expect(Object.keys(entry).sort()).toEqual(['enabledForChat', 'name', 'provider']) + } + }) + + it('rejects a project list request without a project id', async () => { + const response = await ctx.get('/v1/ai-providers') + + expect(response?.statusCode).toBe(StatusCodes.FORBIDDEN) + }) + + it('rejects a sibling project the caller is not a member of', async () => { + const sibling = createMockProject({ + ownerId: ctx.user.id, + platformId: ctx.platform.id, + }) + await db.save('project', sibling) + const memberCtx = await createMemberContext(app!, ctx, { + projectRole: DefaultProjectRole.VIEWER, + }) + + const response = await memberCtx.get('/v1/ai-providers', { projectId: sibling.id }) + + expect(response?.statusCode).toBe(StatusCodes.FORBIDDEN) + }) }) describe('authorization: mutations require platform admin', () => { @@ -259,14 +337,417 @@ describe('AI Providers API', () => { expect(response?.statusCode).toBe(StatusCodes.NO_CONTENT) }) - it('still allows a non-admin platform member to list providers', async () => { + it('still allows a non-admin platform member to list providers for their project', async () => { const memberCtx = await createMemberContext(app!, ctx, { projectRole: DefaultProjectRole.VIEWER, }) - const response = await memberCtx.get('/v1/ai-providers') + const response = await memberCtx.get('/v1/ai-providers', { projectId: ctx.project.id }) expect(response?.statusCode).toBe(StatusCodes.OK) }) }) + + describe('multiple keys per provider', () => { + const customConfig = (baseUrl: string, models: ProviderModelConfig[] = []) => ({ + baseUrl, + apiKeyHeader: 'Authorization', + models, + }) + + const engineGet = async (path: string, projectId: string) => { + const engineToken = await generateMockToken({ + type: PrincipalType.ENGINE, + id: apId(), + projectId, + platform: { id: ctx.platform.id }, + }) + return app!.inject({ + method: 'GET', + url: path, + headers: { authorization: `Bearer ${engineToken}` }, + }) + } + + it('allows two configurations for the same provider', async () => { + for (const name of ['Key A', 'Key B']) { + const response = await ctx.post('/v1/ai-providers', { + provider: AIProviderName.CUSTOM, + displayName: name, + config: customConfig(`https://api.example.com/${name.replace(' ', '-')}`), + auth: { apiKey: 'test-key' }, + }) + expect(response?.statusCode).toBe(StatusCodes.OK) + } + + const list = await ctx.get('/v1/ai-providers/configs') + const customRows = list?.json().filter((p: { provider: string }) => p.provider === AIProviderName.CUSTOM) + expect(customRows).toHaveLength(2) + }) + + it('resolves the key with the most specific project scope first', async () => { + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'All projects', + config: customConfig('https://all.example.com'), + created: '2026-08-10T00:00:00.000Z', + }) + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Scoped', + config: { baseUrl: 'https://scoped.example.com', apiKeyHeader: 'Authorization', models: [] }, + projectScope: 'selected', + projectIds: [ctx.project.id], + created: '2026-08-01T00:00:00.000Z', + }) + + const response = await engineGet(`/api/v1/ai-providers/${AIProviderName.CUSTOM}/config`, ctx.project.id) + + expect(response?.statusCode).toBe(StatusCodes.OK) + expect(response?.json().config.baseUrl).toBe('https://scoped.example.com') + }) + + it('breaks specificity ties by newest created', async () => { + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Older', + config: customConfig('https://older.example.com'), + created: '2026-08-01T00:00:00.000Z', + }) + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Newer', + config: customConfig('https://newer.example.com'), + created: '2026-08-10T00:00:00.000Z', + }) + + const response = await engineGet(`/api/v1/ai-providers/${AIProviderName.CUSTOM}/config`, ctx.project.id) + + expect(response?.statusCode).toBe(StatusCodes.OK) + expect(response?.json().config.baseUrl).toBe('https://newer.example.com') + }) + + it('excludes a project listed in an except scope', async () => { + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Except this project', + config: { baseUrl: 'https://except.example.com', apiKeyHeader: 'Authorization', models: [] }, + projectScope: 'except', + projectIds: [ctx.project.id], + }) + + const response = await engineGet(`/api/v1/ai-providers/${AIProviderName.CUSTOM}/config`, ctx.project.id) + + expect(response?.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + + it('allows a project not listed in an except scope', async () => { + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Except another project', + config: customConfig('https://except-other.example.com'), + projectScope: 'except', + projectIds: [apId()], + }) + + const response = await engineGet(`/api/v1/ai-providers/${AIProviderName.CUSTOM}/config`, ctx.project.id) + + expect(response?.statusCode).toBe(StatusCodes.OK) + expect(response?.json().config.baseUrl).toBe('https://except-other.example.com') + }) + + it('lists one entry per provider for an engine caller, whatever the key count', async () => { + for (const name of ['Key A', 'Key B']) { + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: name, + config: customConfig(`https://api.example.com/${name.replace(' ', '-')}`), + }) + } + + const response = await engineGet('/api/v1/ai-providers', ctx.project.id) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const customRows = response?.json().filter((p: { provider: string }) => p.provider === AIProviderName.CUSTOM) + expect(customRows).toHaveLength(1) + }) + + it('rejects creating the managed ACTIVEPIECES provider', async () => { + const response = await ctx.post('/v1/ai-providers', { + provider: AIProviderName.ACTIVEPIECES, + displayName: 'Activepieces', + config: {}, + auth: { apiKey: 'k', apiKeyHash: 'h' }, + }) + + expect(response?.statusCode).toBe(StatusCodes.CONFLICT) + }) + + it('keeps a single chat provider across keys of the same provider', async () => { + const first = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Chat A', + config: customConfig('https://chat-a.example.com'), + enabledForChat: true, + }) + const second = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Chat B', + config: customConfig('https://chat-b.example.com'), + }) + + const update = await ctx.post(`/v1/ai-providers/${second.id}`, { + displayName: 'Chat B', + enabledForChat: true, + }) + expect(update?.statusCode).toBe(StatusCodes.OK) + + const list = await ctx.get('/v1/ai-providers/configs') + const chatRows = list?.json().filter((p: { enabledForChat: boolean }) => p.enabledForChat) + expect(chatRows).toHaveLength(1) + expect(chatRows[0].id).toBe(second.id) + expect(chatRows[0].id).not.toBe(first.id) + }) + + it('reports a provider as chat-enabled even when the chat key is not the ranking winner', async () => { + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Chat key, ranked last', + config: customConfig('https://chat.example.com'), + enabledForChat: true, + created: '2026-08-01T00:00:00.000Z', + }) + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Newer key, ranked first', + config: customConfig('https://newer.example.com'), + created: '2026-08-10T00:00:00.000Z', + }) + + const response = await engineGet('/api/v1/ai-providers', ctx.project.id) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const customRow = response?.json().find((p: { provider: string }) => p.provider === AIProviderName.CUSTOM) + expect(customRow.enabledForChat).toBe(true) + }) + + it('lists a specific key models through the admin configuration route', async () => { + const olderModels = [ + { modelId: 'older-model', modelName: 'Older model', modelType: AIProviderModelType.TEXT }, + ] + const newerModels = [ + { modelId: 'newer-model', modelName: 'Newer model', modelType: AIProviderModelType.TEXT }, + ] + const older = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Older key', + config: customConfig('https://older-key.example.com', olderModels), + created: '2026-08-01T00:00:00.000Z', + }) + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Newer key', + config: customConfig('https://newer-key.example.com', newerModels), + created: '2026-08-10T00:00:00.000Z', + }) + + const resolved = await ctx.get(`/v1/ai-providers/${AIProviderName.CUSTOM}/models`, { projectId: ctx.project.id }) + expect(resolved?.statusCode).toBe(StatusCodes.OK) + expect(resolved?.json().map((m: { id: string }) => m.id)).toEqual(['newer-model']) + + const byConfig = await ctx.get(`/v1/ai-providers/configs/${older.id}/models`) + expect(byConfig?.statusCode).toBe(StatusCodes.OK) + expect(byConfig?.json().map((m: { id: string }) => m.id)).toEqual(['older-model']) + }) + + it('forbids a non-admin member from listing models of a configuration by id', async () => { + const excluded = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Other project key', + config: customConfig('https://other-project.example.com', [ + { modelId: 'secret-model', modelName: 'Secret model', modelType: AIProviderModelType.TEXT }, + ]), + projectScope: 'selected', + projectIds: [apId()], + }) + const memberCtx = await createMemberContext(app!, ctx, { + projectRole: DefaultProjectRole.VIEWER, + }) + + const response = await memberCtx.get(`/v1/ai-providers/configs/${excluded.id}/models`) + + expect(response?.statusCode).toBe(StatusCodes.FORBIDDEN) + }) + + it('does not resolve a configuration excluded from the caller project', async () => { + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Other project key', + config: customConfig('https://other-project.example.com', [ + { modelId: 'secret-model', modelName: 'Secret model', modelType: AIProviderModelType.TEXT }, + ]), + projectScope: 'selected', + projectIds: [apId()], + }) + + const response = await ctx.get(`/v1/ai-providers/${AIProviderName.CUSTOM}/models`, { projectId: ctx.project.id }) + + expect(response?.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + + it('scopes the chat provider to the requesting project', async () => { + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Scoped chat key', + config: customConfig('https://chat-scoped.example.com'), + enabledForChat: true, + projectScope: 'selected', + projectIds: [apId()], + }) + + const excluded = await aiProviderService(app!.log).getChatProvider({ + platformId: ctx.platform.id, + scope: { type: 'project', projectId: ctx.project.id }, + }) + expect(excluded).toBeNull() + + const platformWide = await aiProviderService(app!.log).getChatProvider({ + platformId: ctx.platform.id, + scope: { type: 'platform' }, + }) + expect(platformWide?.provider).toBe(AIProviderName.CUSTOM) + }) + + it('applies the key model allow-list on every project-scoped call', async () => { + const models = [ + { modelId: 'model-a', modelName: 'Model A', modelType: AIProviderModelType.TEXT }, + { modelId: 'model-b', modelName: 'Model B', modelType: AIProviderModelType.TEXT }, + ] + const restricted = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Restricted models', + config: customConfig('https://models.example.com', models), + modelScope: 'selected', + modelIds: ['model-a'], + }) + + const engineResponse = await engineGet(`/api/v1/ai-providers/${AIProviderName.CUSTOM}/models`, ctx.project.id) + expect(engineResponse?.statusCode).toBe(StatusCodes.OK) + expect(engineResponse?.json().map((m: { id: string }) => m.id)).toEqual(['model-a']) + + const userResponse = await ctx.get(`/v1/ai-providers/${AIProviderName.CUSTOM}/models`, { projectId: ctx.project.id }) + expect(userResponse?.statusCode).toBe(StatusCodes.OK) + expect(userResponse?.json().map((m: { id: string }) => m.id)).toEqual(['model-a']) + + const adminResponse = await ctx.get(`/v1/ai-providers/configs/${restricted.id}/models`) + expect(adminResponse?.statusCode).toBe(StatusCodes.OK) + expect(adminResponse?.json().map((m: { id: string }) => m.id).sort()).toEqual(['model-a', 'model-b']) + }) + }) + + describe('keyServesScope (a run that changes project mid-turn)', () => { + it('refuses the project a running key is scoped away from', async () => { + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'This project only', + config: { baseUrl: 'https://scoped.example.com', apiKeyHeader: 'Authorization', models: [] }, + enabledForChat: true, + projectScope: 'selected', + projectIds: [ctx.project.id], + }) + + const serves = await aiProviderService(app!.log).keyServesScope({ + platformId: ctx.platform.id, + resolvedFor: { type: 'project', projectId: ctx.project.id }, + target: { type: 'project', projectId: apId() }, + }) + + expect(serves).toBe(false) + }) + + it('lets a key its owner left open to every project follow the switch', async () => { + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Every project', + config: { baseUrl: 'https://open.example.com', apiKeyHeader: 'Authorization', models: [] }, + enabledForChat: true, + projectScope: 'all', + }) + + const serves = await aiProviderService(app!.log).keyServesScope({ + platformId: ctx.platform.id, + resolvedFor: { type: 'project', projectId: ctx.project.id }, + target: { type: 'project', projectId: apId() }, + }) + + expect(serves).toBe(true) + }) + + it('refuses when any key the run could be holding excludes the target project', async () => { + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Open chat key', + config: { baseUrl: 'https://open-chat.example.com', apiKeyHeader: 'Authorization', models: [] }, + enabledForChat: true, + projectScope: 'all', + }) + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Pinned to this project', + config: { baseUrl: 'https://pinned.example.com', apiKeyHeader: 'Authorization', models: [] }, + projectScope: 'selected', + projectIds: [ctx.project.id], + }) + + const serves = await aiProviderService(app!.log).keyServesScope({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + resolvedFor: { type: 'project', projectId: ctx.project.id }, + target: { type: 'project', projectId: apId() }, + }) + + expect(serves).toBe(false) + }) + + it('refuses to drop a restricted key out of project scope altogether', async () => { + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'All but one', + config: { baseUrl: 'https://except.example.com', apiKeyHeader: 'Authorization', models: [] }, + enabledForChat: true, + projectScope: 'except', + projectIds: [apId()], + }) + + const serves = await aiProviderService(app!.log).keyServesScope({ + platformId: ctx.platform.id, + resolvedFor: { type: 'project', projectId: ctx.project.id }, + target: { type: 'platform' }, + }) + + expect(serves).toBe(false) + }) + }) }) diff --git a/packages/server/api/test/integration/ce/ai-provider/azure-models.test.ts b/packages/server/api/test/integration/ce/ai-provider/azure-models.test.ts index da77dcf37f18..3be41c5b21a6 100644 --- a/packages/server/api/test/integration/ce/ai-provider/azure-models.test.ts +++ b/packages/server/api/test/integration/ce/ai-provider/azure-models.test.ts @@ -2,6 +2,7 @@ import { AIProviderName } from '@activepieces/core-utils' import { FastifyInstance } from 'fastify' import { StatusCodes } from 'http-status-codes' import { vi } from 'vitest' +import { db } from '../../../helpers/db' import { mockAndSaveAIProvider } from '../../../helpers/mocks' import { createTestContext, TestContext } from '../../../helpers/test-context' import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' @@ -44,7 +45,7 @@ describe('GET /v1/ai-providers/:provider/models (azure)', () => { config: { resourceName: 'my-resource', apiVersion: '2024-10-21' }, }) - const response = await ctx.get('/v1/ai-providers/azure/models') + const response = await ctx.get('/v1/ai-providers/azure/models', { projectId: ctx.project.id }) expect(response?.statusCode).toBe(StatusCodes.OK) expect(mockSendRequest).toHaveBeenCalledTimes(1) @@ -55,3 +56,38 @@ describe('GET /v1/ai-providers/:provider/models (azure)', () => { ]) }) }) + +describe('GET /v1/ai-providers/configs/:id/models (azure)', () => { + it('keeps two azure resources apart even when they share an api key', async () => { + mockSendRequest.mockImplementation((request: { url: string }) => { + const deployment = request.url.includes('resource-a') + ? 'deployment-a' + : 'deployment-b' + return Promise.resolve({ + body: { data: [{ id: deployment, model: 'gpt-4o', status: 'succeeded' }] }, + }) + }) + + const first = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.AZURE, + displayName: 'Azure A', + config: { resourceName: 'resource-a' }, + }) + const second = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.AZURE, + displayName: 'Azure B', + config: { resourceName: 'resource-b' }, + }) + await db.update('ai_provider', second.id, { auth: first.auth }) + + const firstResponse = await ctx.get(`/v1/ai-providers/configs/${first.id}/models`) + const secondResponse = await ctx.get(`/v1/ai-providers/configs/${second.id}/models`) + + expect(firstResponse?.statusCode).toBe(StatusCodes.OK) + expect(secondResponse?.statusCode).toBe(StatusCodes.OK) + expect(firstResponse?.json().map((m: { id: string }) => m.id)).toEqual(['deployment-a']) + expect(secondResponse?.json().map((m: { id: string }) => m.id)).toEqual(['deployment-b']) + }) +}) diff --git a/packages/server/api/test/integration/ee/agent/agent-credit-gate.test.ts b/packages/server/api/test/integration/ee/agent/agent-credit-gate.test.ts index cd2334ca2fe7..23ffd77aab8c 100644 --- a/packages/server/api/test/integration/ee/agent/agent-credit-gate.test.ts +++ b/packages/server/api/test/integration/ee/agent/agent-credit-gate.test.ts @@ -41,6 +41,68 @@ describe('Chat credit gate on self-hosted (managed AI unavailable)', () => { expect(response.json().params.entityType).toBe('ChatAiProvider') }) + it('rejects a message whose project is excluded from every enabled chat key, rather than queueing a job the worker cannot service', async () => { + const ctx = await createTestContext(app, { plan: { chatEnabled: true } }) + const chatProvider = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.OPENAI, + }) + await db.update('ai_provider', chatProvider.id, { + enabledForChat: true, + projectScope: 'except', + projectIds: [ctx.project.id], + }) + + const conversationResponse = await ctx.post(CONVERSATIONS_URL, {}) + expect(conversationResponse.statusCode).toBe(StatusCodes.CREATED) + const conversationId = conversationResponse.json().id + await db.update('agent_conversation', conversationId, { projectId: ctx.project.id }) + + const response = await ctx.post(`${CONVERSATIONS_URL}/${conversationId}/messages`, { content: 'hello' }) + + expect(response.statusCode).toBe(StatusCodes.NOT_FOUND) + expect(response.json().params.entityType).toBe('ChatAiProvider') + }) + + it('rejects a projectless conversation whose adopted project is excluded, rather than resolving platform-wide', async () => { + const ctx = await createTestContext(app, { plan: { chatEnabled: true } }) + const chatProvider = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.OPENAI, + }) + await db.update('ai_provider', chatProvider.id, { + enabledForChat: true, + projectScope: 'except', + projectIds: [ctx.project.id], + }) + + const response = await sendMessage(ctx) + + expect(response.statusCode).toBe(StatusCodes.NOT_FOUND) + expect(response.json().params.entityType).toBe('ChatAiProvider') + }) + + it('accepts a message when the conversation project is inside the enabled chat key scope', async () => { + const ctx = await createTestContext(app, { plan: { chatEnabled: true } }) + const chatProvider = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.OPENAI, + }) + await db.update('ai_provider', chatProvider.id, { + enabledForChat: true, + projectScope: 'selected', + projectIds: [ctx.project.id], + }) + + const conversationResponse = await ctx.post(CONVERSATIONS_URL, {}) + const conversationId = conversationResponse.json().id + await db.update('agent_conversation', conversationId, { projectId: ctx.project.id }) + + const response = await ctx.post(`${CONVERSATIONS_URL}/${conversationId}/messages`, { content: 'hello' }) + + expect(response.statusCode).toBe(StatusCodes.OK) + }) + it('rejects messages with a provider-not-found error when no chat provider is configured', async () => { const ctx = await createTestContext(app, { plan: { chatEnabled: true } }) @@ -57,7 +119,7 @@ describe('Chat credit gate on self-hosted (managed AI unavailable)', () => { provider: AIProviderName.ACTIVEPIECES, }) - const response = await ctx.get('/v1/ai-providers') + const response = await ctx.get('/v1/ai-providers', { projectId: ctx.project.id }) expect(response.statusCode).toBe(StatusCodes.OK) const providers: { provider: string }[] = response.json() diff --git a/packages/server/api/test/unit/app/ee/agent/agent-fast-model-provider.test.ts b/packages/server/api/test/unit/app/ee/agent/agent-fast-model-provider.test.ts index e073cc287478..c5a48a08d400 100644 --- a/packages/server/api/test/unit/app/ee/agent/agent-fast-model-provider.test.ts +++ b/packages/server/api/test/unit/app/ee/agent/agent-fast-model-provider.test.ts @@ -22,6 +22,7 @@ const { agentHelpers } = await import('../../../../../src/app/ee/agent/agent-hel const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } as never const platformId = 'platform-1' +const scope = { type: 'project', projectId: 'project-1' } as const describe('resolveFastModel', () => { beforeEach(() => { @@ -34,15 +35,15 @@ describe('resolveFastModel', () => { }) it('fills a flow step\'s inputs on the run\'s own provider, so a platform without chat can still run one', async () => { - const model = await agentHelpers.resolveFastModel({ platformId, provider: AIProviderName.OPENROUTER, log }) + const model = await agentHelpers.resolveFastModel({ platformId, provider: AIProviderName.OPENROUTER, scope, log }) expect(mockGetChatProvider).not.toHaveBeenCalled() - expect(mockGetConfigOrThrow).toHaveBeenCalledWith({ platformId, provider: AIProviderName.OPENROUTER }) + expect(mockGetConfigOrThrow).toHaveBeenCalledWith({ platformId, provider: AIProviderName.OPENROUTER, scope }) expect(model).toMatchObject({ provider: AIProviderName.OPENROUTER, modelId: 'anthropic/claude-haiku-4.5' }) }) it('still refuses when no provider is named and the platform has no chat provider', async () => { - await expect(agentHelpers.resolveFastModel({ platformId, log })).rejects.toMatchObject({ + await expect(agentHelpers.resolveFastModel({ platformId, scope, log })).rejects.toMatchObject({ error: { code: 'ENTITY_NOT_FOUND', params: { entityType: 'ChatAiProvider' } }, }) }) @@ -50,7 +51,7 @@ describe('resolveFastModel', () => { it('uses the chat provider when no provider is named and one is configured', async () => { mockGetChatProvider.mockResolvedValue({ provider: AIProviderName.ANTHROPIC, auth: { apiKey: 'k' }, config: {} }) - const model = await agentHelpers.resolveFastModel({ platformId, log }) + const model = await agentHelpers.resolveFastModel({ platformId, scope, log }) expect(model).toMatchObject({ provider: AIProviderName.ANTHROPIC, modelId: 'claude-haiku-4-5' }) }) diff --git a/packages/server/api/test/unit/app/ee/agent/agent-model-resolution.test.ts b/packages/server/api/test/unit/app/ee/agent/agent-model-resolution.test.ts index 7a918f9983af..6ba72999c266 100644 --- a/packages/server/api/test/unit/app/ee/agent/agent-model-resolution.test.ts +++ b/packages/server/api/test/unit/app/ee/agent/agent-model-resolution.test.ts @@ -89,3 +89,20 @@ describe('chatUsageTracker — a flow step is not billed as a chat message', () })).resolves.toBeUndefined() }) }) + +describe('runScopeOrThrow', () => { + it('scopes a run to its project', () => { + expect(agentHelpers.runScopeOrThrow({ projectId: 'proj-1' })).toEqual({ type: 'project', projectId: 'proj-1' }) + }) + + it('refuses a run with no project instead of widening to every platform key', () => { + expect(() => agentHelpers.runScopeOrThrow({ projectId: null })).toThrow() + }) +}) + +describe('resolveChatProviderName', () => { + it('reports no provider for a conversation with no project, rather than guessing one platform-wide', async () => { + const log = { info: () => undefined, warn: () => undefined, error: () => undefined, debug: () => undefined } + await expect(agentHelpers.resolveChatProviderName({ platformId: 'plat-1', projectId: null, log: log as never })).resolves.toBeNull() + }) +}) diff --git a/packages/server/api/test/unit/app/ee/agent/agent-rpc-handlers.test.ts b/packages/server/api/test/unit/app/ee/agent/agent-rpc-handlers.test.ts index ea30a067bc3d..00006eeb6f64 100644 --- a/packages/server/api/test/unit/app/ee/agent/agent-rpc-handlers.test.ts +++ b/packages/server/api/test/unit/app/ee/agent/agent-rpc-handlers.test.ts @@ -25,6 +25,10 @@ const { mockSet, mockWhere, mockAndWhere, mockExecute, mockFindOneBy, mockFindOn mockSendConversationUpdate: vi.fn(), })) +const { mockAssertProjectSwitchKeepsKey } = vi.hoisted(() => ({ + mockAssertProjectSwitchKeepsKey: vi.fn().mockResolvedValue(undefined), +})) + const { mockGetFileOrThrow, mockKbSearch } = vi.hoisted(() => ({ mockGetFileOrThrow: vi.fn().mockResolvedValue({ id: 'kb-1' }), mockKbSearch: vi.fn().mockResolvedValue([]), @@ -92,6 +96,7 @@ type QueryBuilderMock = { vi.mock('../../../../../src/app/ee/agent/agent-helpers', () => ({ agentHelpers: { + assertProjectSwitchKeepsKey: mockAssertProjectSwitchKeepsKey, resolveFastModel: () => ({}), resolveEmbeddingModel: () => ({ model: {}, providerOptions: {} }), conversationRepo: () => ({ @@ -287,6 +292,8 @@ async function callUpdateProjectContext(input: { conversationId: string, runId?: describe('agentRpcHandlers.updateProjectContext — a flow-step run stays in its own project', () => { beforeEach(() => { mockSet.mockClear() + mockAssertProjectSwitchKeepsKey.mockClear() + mockAssertProjectSwitchKeepsKey.mockResolvedValue(undefined) }) it('refuses to move a flow-step run to another project', async () => { @@ -311,6 +318,14 @@ describe('agentRpcHandlers.updateProjectContext — a flow-step run stays in its expect(mockSet).toHaveBeenCalled() }) + + it('keeps a chat run out of a project its running key excludes', async () => { + mockFindOneBy.mockResolvedValue({ source: 'CHAT', projectId: 'proj-own', platformId: 'plat-1' }) + mockAssertProjectSwitchKeepsKey.mockRejectedValue(new Error('key not available there')) + + await expect(callUpdateProjectContext({ conversationId: 'conv-1', projectId: 'proj-other' })).rejects.toThrow() + expect(mockSet).not.toHaveBeenCalled() + }) }) async function callGetAgentConfigFor(input: Record): Promise { diff --git a/packages/server/engine/test/handler/flow-rerun.test.ts b/packages/server/engine/test/handler/flow-rerun.test.ts index b1e2517a0ef6..0affba7e45d6 100644 --- a/packages/server/engine/test/handler/flow-rerun.test.ts +++ b/packages/server/engine/test/handler/flow-rerun.test.ts @@ -1,62 +1,68 @@ -import { FlowRunStatus } from '@activepieces/shared' +import { createServer, Server } from 'node:http' +import { AddressInfo } from 'node:net' +import { FlowRunStatus, PieceAction } from '@activepieces/shared' import { FlowExecutorContext } from '../../src/lib/handler/context/flow-execution-context' import { flowExecutor } from '../../src/lib/handler/flow-executor' import { buildPieceAction, generateMockEngineConstants } from './test-helper' -const failedHttpAction = buildPieceAction({ - name: 'send_http', - pieceName: '@activepieces/piece-http', - actionName: 'send_request', - input: { - 'url': 'https://cloud.activepieces.com/api/v1/asd', - 'method': 'GET', - 'headers': {}, - 'body_type': 'none', - 'body': {}, - 'queryParams': {}, - }, -}) +let server: Server +let baseUrl: string + +function httpAction(path: string): PieceAction { + return buildPieceAction({ + name: 'send_http', + pieceName: '@activepieces/piece-http', + actionName: 'send_request', + input: { + 'url': `${baseUrl}${path}`, + 'method': 'GET', + 'headers': {}, + 'body_type': 'none', + 'body': {}, + 'queryParams': {}, + }, + }) +} -const successHttpAction = buildPieceAction({ - name: 'send_http', - pieceName: '@activepieces/piece-http', - actionName: 'send_request', - input: { - 'url': 'https://cloud.activepieces.com/api/v1/pieces', - 'method': 'GET', - 'headers': {}, - 'body_type': 'none', - 'body': {}, - 'queryParams': {}, - }, +beforeAll(async () => { + server = createServer((req, res) => { + const notFound = req.url === '/missing' + res.writeHead(notFound ? 404 : 200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(notFound ? { message: 'Route not found' } : { ok: true })) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}` }) +afterAll(async () => { + await new Promise((resolve, reject) => server.close((err) => err ? reject(err) : resolve())) +}) describe('flow retry', () => { it('should retry entire flow', async () => { const context = FlowExecutorContext.empty() const failedResult = await flowExecutor.execute({ - action: failedHttpAction, executionState: context, constants: generateMockEngineConstants(), + action: httpAction('/missing'), executionState: context, constants: generateMockEngineConstants(), }) const retryEntireFlow = await flowExecutor.execute({ - action: successHttpAction, executionState: context, constants: generateMockEngineConstants(), + action: httpAction('/ok'), executionState: context, constants: generateMockEngineConstants(), }) expect(failedResult.verdict.status).toBe(FlowRunStatus.FAILED) expect(retryEntireFlow.verdict.status).toBe(FlowRunStatus.RUNNING) - }, 10000) + }) it('should retry flow from failed step', async () => { const context = FlowExecutorContext.empty() const failedResult = await flowExecutor.execute({ - action: failedHttpAction, executionState: context, constants: generateMockEngineConstants(), + action: httpAction('/missing'), executionState: context, constants: generateMockEngineConstants(), }) const retryFromFailed = await flowExecutor.execute({ - action: successHttpAction, executionState: context, constants: generateMockEngineConstants({}), + action: httpAction('/ok'), executionState: context, constants: generateMockEngineConstants({}), }) expect(failedResult.verdict.status).toBe(FlowRunStatus.FAILED) expect(retryFromFailed.verdict.status).toBe(FlowRunStatus.RUNNING) - }, 10000) + }) }) diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-worker-tools.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-worker-tools.ts index edfdead0a151..c122d872f2fc 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-worker-tools.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/agent-worker-tools.ts @@ -353,7 +353,7 @@ function createDisplayTools({ waitForApproval, displayToolTimeoutMs, onConnectio } function createLocalTools({ onSetProjectContext, projects }: { - onSetProjectContext: (projectId: string | null) => Promise + onSetProjectContext: (projectId: string | null) => Promise<{ success: boolean, error?: string }> projects: Array<{ id: string, displayName: string, type: string }> }): ToolSet { const availableProjectIds = new Set(projects.map((p) => p.id)) @@ -369,7 +369,10 @@ function createLocalTools({ onSetProjectContext, projects }: { return { success: false, error: `Project ${input.projectId} is not accessible.` } } const project = projects.find((p) => p.id === input.projectId) - await onSetProjectContext(input.projectId) + const outcome = await onSetProjectContext(input.projectId) + if (!outcome.success) { + return { success: false, error: outcome.error } + } return { success: true, message: `Now working in project ${project?.displayName ?? input.projectId}.` } }, }), @@ -378,7 +381,10 @@ function createLocalTools({ onSetProjectContext, projects }: { description: 'Clear project context. Useful when working across multiple projects.', inputSchema: z.object({}), execute: async () => { - await onSetProjectContext(null) + const outcome = await onSetProjectContext(null) + if (!outcome.success) { + return { success: false, error: outcome.error } + } return { success: true, message: 'Project context cleared.' } }, }), diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts index 7ca0907ac2db..7f875f1fa6cb 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts @@ -556,8 +556,13 @@ function buildToolSet({ ctx, eventEmitter, log, phaseState, taintState, mcpToolS const selectedConnectionByPiece = new Map() const localTools = agentWorkerTools.createLocalTools({ onSetProjectContext: async (projectId) => { + const { error } = await tryCatch(() => ctx.apiClient.updateProjectContext({ conversationId, runId, projectId, provider })) + if (!isNil(error)) { + log.warn({ error, conversation: { id: conversationId }, project: projectId ? { id: projectId } : undefined }, '[executeAgentRun] Refused a project switch') + return { success: false, error: 'Could not switch to that project on this chat — the AI provider key this chat runs on is not available there. Start a new chat in that project instead.' } + } projectState.projectId = projectId - await ctx.apiClient.updateProjectContext({ conversationId, runId, projectId }) + return { success: true } }, projects, }) diff --git a/packages/web/src/app/builder/pieces-selector/index.tsx b/packages/web/src/app/builder/pieces-selector/index.tsx index 9bcf8c176c4d..32b44708e56a 100644 --- a/packages/web/src/app/builder/pieces-selector/index.tsx +++ b/packages/web/src/app/builder/pieces-selector/index.tsx @@ -141,7 +141,7 @@ const PieceSelectorContent = ({ }); } }, [isOpen]); - const { data: aiProviders } = aiProviderQueries.useAiProviders(); + const { data: aiProviders } = aiProviderQueries.useProjectAiProviders(); const { pieceModel: aiPieceModel, isError: isAiPieceError, diff --git a/packages/web/src/app/routes/platform/setup/ai/index.tsx b/packages/web/src/app/routes/platform/setup/ai/index.tsx index 1b8d48a165de..fdf27a7aabae 100644 --- a/packages/web/src/app/routes/platform/setup/ai/index.tsx +++ b/packages/web/src/app/routes/platform/setup/ai/index.tsx @@ -30,7 +30,7 @@ const ACTIVEPIECES_LOGO_URL = 'https://cdn.activepieces.com/pieces/activepieces.png'; export default function AIProvidersPage() { - const { data: providers, refetch } = aiProviderQueries.useAiProviders(); + const { data: providers, refetch } = aiProviderQueries.useAiProviderConfigs(); const { data: currentUser } = userHooks.useCurrentUser(); const { platform } = platformHooks.useCurrentPlatform(); const allowWrite = platform.plan.aiProvidersEnabled; diff --git a/packages/web/src/features/agents/ai-model/hooks.ts b/packages/web/src/features/agents/ai-model/hooks.ts index 6c8f388c4fc8..32a1019ba4d2 100644 --- a/packages/web/src/features/agents/ai-model/hooks.ts +++ b/packages/web/src/features/agents/ai-model/hooks.ts @@ -7,6 +7,7 @@ import { import { useQuery } from '@tanstack/react-query'; import { aiProviderApi } from '@/features/platform-admin/api/ai-provider-api'; +import { authenticationSession } from '@/lib/authentication-session'; type AIModelType = 'text' | 'image'; @@ -51,20 +52,27 @@ function managedTierLabel(modelId: string): string | undefined { export const aiModelHooks = { useListProviders: () => { + const projectId = authenticationSession.getProjectId(); return useQuery({ - queryKey: ['ai-providers'], - queryFn: () => aiProviderApi.list(), + queryKey: ['ai-providers', projectId], + enabled: !isNil(projectId), + queryFn: () => + isNil(projectId) ? [] : aiProviderApi.listForProject(projectId), }); }, useGetModelsForProvider: (provider?: AIProviderName) => { + const projectId = authenticationSession.getProjectId(); return useQuery({ - queryKey: ['ai-models', provider], - enabled: !!provider, + queryKey: ['ai-models', provider, projectId], + enabled: !isNil(provider) && !isNil(projectId), queryFn: async () => { - if (isNil(provider)) return []; + if (isNil(provider) || isNil(projectId)) return []; - const allModels = await aiProviderApi.listModelsForProvider(provider); + const allModels = await aiProviderApi.listModelsForProvider( + provider, + projectId, + ); return getAllowedModelsForProvider(provider, allModels, 'text'); }, diff --git a/packages/web/src/features/agents/ai-model/index.tsx b/packages/web/src/features/agents/ai-model/index.tsx index 58f699bc30b2..5f21863d30bb 100644 --- a/packages/web/src/features/agents/ai-model/index.tsx +++ b/packages/web/src/features/agents/ai-model/index.tsx @@ -194,7 +194,7 @@ export function AIModelSelector({ {sortedProviders.map((provider) => ( handleProviderChange(provider.provider as AIProviderName) diff --git a/packages/web/src/features/platform-admin/api/ai-provider-api.ts b/packages/web/src/features/platform-admin/api/ai-provider-api.ts index 1c88fe93408d..6fb6e18ad0a7 100644 --- a/packages/web/src/features/platform-admin/api/ai-provider-api.ts +++ b/packages/web/src/features/platform-admin/api/ai-provider-api.ts @@ -2,17 +2,30 @@ import { AIProviderModel, AIProviderWithoutSensitiveData, CreateAIProviderRequest, + ProjectAIProvider, UpdateAIProviderRequest, } from '@activepieces/shared'; import { api } from '@/lib/api'; export const aiProviderApi = { - list() { - return api.get('/v1/ai-providers'); + listForProject(projectId: string) { + return api.get('/v1/ai-providers', { projectId }); }, - listModelsForProvider(provider: string) { - return api.get(`/v1/ai-providers/${provider}/models`); + listConfigs() { + return api.get( + '/v1/ai-providers/configs', + ); + }, + listModelsForProvider(provider: string, projectId: string) { + return api.get(`/v1/ai-providers/${provider}/models`, { + projectId, + }); + }, + listModelsForConfig(configId: string) { + return api.get( + `/v1/ai-providers/configs/${configId}/models`, + ); }, upsert(request: CreateAIProviderRequest): Promise { return api.post('/v1/ai-providers', request); diff --git a/packages/web/src/features/platform-admin/hooks/ai-provider-hooks.ts b/packages/web/src/features/platform-admin/hooks/ai-provider-hooks.ts index 4e90fb29a6c4..890152c06e10 100644 --- a/packages/web/src/features/platform-admin/hooks/ai-provider-hooks.ts +++ b/packages/web/src/features/platform-admin/hooks/ai-provider-hooks.ts @@ -1,3 +1,4 @@ +import { isNil } from '@activepieces/core-utils'; import { AIProviderAuthConfig, CreateAIProviderRequest, @@ -6,20 +7,34 @@ import { import { useMutation, useQuery } from '@tanstack/react-query'; import { AxiosError } from 'axios'; +import { authenticationSession } from '@/lib/authentication-session'; + import { aiProviderApi } from '../api/ai-provider-api'; export const aiProviderKeys = { - all: ['ai-providers'] as const, + configs: ['ai-provider-configs'] as const, + forProject: (projectId: string | null) => + ['ai-providers', projectId] as const, }; export const aiProviderQueries = { - useAiProviders: () => + useAiProviderConfigs: () => useQuery({ - queryKey: aiProviderKeys.all, - queryFn: () => aiProviderApi.list(), + queryKey: aiProviderKeys.configs, + queryFn: () => aiProviderApi.listConfigs(), }), + useProjectAiProviders: () => { + const projectId = authenticationSession.getProjectId(); + return useQuery({ + queryKey: aiProviderKeys.forProject(projectId), + queryFn: () => + isNil(projectId) ? [] : aiProviderApi.listForProject(projectId), + enabled: !isNil(projectId), + }); + }, useChatProvider: () => { - const { data: providers, ...rest } = aiProviderQueries.useAiProviders(); + const { data: providers, ...rest } = + aiProviderQueries.useProjectAiProviders(); return { ...rest, data: providers?.find((p) => p.enabledForChat) }; }, }; From 9c37d7c71dd87138c4f41dd0bb8eb01fdde1a8c4 Mon Sep 17 00:00:00 2001 From: Louai Boumediene <92324961+Louai-Zokerburg@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:16:59 +0100 Subject: [PATCH 3/5] feat(ai-providers): AI Center providers UI with multi-key configurations (#14825) --- .../knowledge/ai-intelligence/ai-providers.md | 2 +- .../app/ai/providers/anthropic-provider.ts | 2 +- .../web/public/locales/en/translation.json | 81 +++ .../components/global-search/static-pages.ts | 2 +- .../app/components/sidebar/platform/index.tsx | 8 +- .../web/src/app/routes/platform-routes.tsx | 15 +- .../platform/setup/ai-capabilities/index.tsx | 162 ----- .../setup/ai/capabilities-tab/index.tsx | 224 ++++++ .../setup/ai/components/section-header.tsx | 23 + .../ai/components/selected-only-button.tsx | 24 + .../setup/ai/components/table-pagination.tsx | 77 ++ .../app/routes/platform/setup/ai/index.tsx | 221 +++--- .../setup/ai/providers-tab/config-detail.tsx | 398 +++++++++++ .../providers-tab/connect-provider-dialog.tsx | 460 ++++++++++++ .../platform/setup/ai/providers-tab/index.tsx | 658 ++++++++++++++++++ .../ai/providers-tab/manual-model-list.tsx | 73 ++ .../providers-tab/model-selection-panel.tsx | 134 ++++ .../providers-tab/project-selection-panel.tsx | 158 +++++ .../ai/providers-tab/provider-credentials.ts | 153 ++++ .../setup/ai/providers-tab/provider-logo.tsx | 30 + .../ai/providers-tab/provider-request.ts | 156 +++++ .../ai/universal-pieces/ai-provider-card.tsx | 94 --- .../universal-pieces/model-form-popover.tsx | 138 ---- .../upsert-provider-config-form.tsx | 538 -------------- .../upsert-provider-dialog.tsx | 344 --------- .../src/components/custom/input-with-icon.tsx | 4 +- .../platform-admin/api/ai-provider-api.ts | 11 +- .../platform-admin/hooks/ai-provider-hooks.ts | 41 +- 28 files changed, 2782 insertions(+), 1449 deletions(-) delete mode 100644 packages/web/src/app/routes/platform/setup/ai-capabilities/index.tsx create mode 100644 packages/web/src/app/routes/platform/setup/ai/capabilities-tab/index.tsx create mode 100644 packages/web/src/app/routes/platform/setup/ai/components/section-header.tsx create mode 100644 packages/web/src/app/routes/platform/setup/ai/components/selected-only-button.tsx create mode 100644 packages/web/src/app/routes/platform/setup/ai/components/table-pagination.tsx create mode 100644 packages/web/src/app/routes/platform/setup/ai/providers-tab/config-detail.tsx create mode 100644 packages/web/src/app/routes/platform/setup/ai/providers-tab/connect-provider-dialog.tsx create mode 100644 packages/web/src/app/routes/platform/setup/ai/providers-tab/index.tsx create mode 100644 packages/web/src/app/routes/platform/setup/ai/providers-tab/manual-model-list.tsx create mode 100644 packages/web/src/app/routes/platform/setup/ai/providers-tab/model-selection-panel.tsx create mode 100644 packages/web/src/app/routes/platform/setup/ai/providers-tab/project-selection-panel.tsx create mode 100644 packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-credentials.ts create mode 100644 packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-logo.tsx create mode 100644 packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-request.ts delete mode 100644 packages/web/src/app/routes/platform/setup/ai/universal-pieces/ai-provider-card.tsx delete mode 100644 packages/web/src/app/routes/platform/setup/ai/universal-pieces/model-form-popover.tsx delete mode 100644 packages/web/src/app/routes/platform/setup/ai/universal-pieces/upsert-provider-config-form.tsx delete mode 100644 packages/web/src/app/routes/platform/setup/ai/universal-pieces/upsert-provider-dialog.tsx diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md index e57fd801f4a9..0139641d4e61 100644 --- a/brain/knowledge/ai-intelligence/ai-providers.md +++ b/brain/knowledge/ai-intelligence/ai-providers.md @@ -63,7 +63,7 @@ Entry point: `aiProviderModule`, registered in `packages/server/api/src/app/app. - `packages/core/shared/src/lib/management/ai-providers/index.ts` — shared zod schemas, enums, request/response types - `packages/core/shared/src/lib/management/ai-tools/index.ts` — shared schemas for the AI Tool Configs sibling - `packages/web/src/features/platform-admin/api/` + `packages/web/src/features/platform-admin/hooks/` — frontend API clients and TanStack Query hooks (`ai-provider-*`, `ai-tool-config-*`) -- `packages/web/src/app/routes/platform/setup/ai/` — platform admin AI setup page and the `universal-pieces/` provider card, upsert dialog, config form, model popover +- `packages/web/src/app/routes/platform/setup/ai/` — the AI Center: `providers-tab/` (provider groups, connect dialog, per-config detail panel with model and project scope pickers) and `capabilities-tab/` - `packages/web/src/app/routes/platform/setup/ai-capabilities/` — admin page, capability dialog, provider catalog for AI Tool Configs - `packages/web/src/features/agents/ai-model/` — model selector used in agent step settings diff --git a/packages/server/api/src/app/ai/providers/anthropic-provider.ts b/packages/server/api/src/app/ai/providers/anthropic-provider.ts index 6d4b1b4d03cb..65ac9edecf4a 100644 --- a/packages/server/api/src/app/ai/providers/anthropic-provider.ts +++ b/packages/server/api/src/app/ai/providers/anthropic-provider.ts @@ -10,7 +10,7 @@ export const anthropicProvider: AIProviderStrategy { const res = await httpClient.sendRequest<{ data: AnthropicModel[] }>({ - url: 'https://api.anthropic.com/v1/models', + url: 'https://api.anthropic.com/v1/models?limit=1000', method: HttpMethod.GET, headers: { 'x-api-key': authConfig.apiKey, diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 81c4fe17eb72..dc638fc5ad2a 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -1331,6 +1331,87 @@ "Reset": "Reset", "customization": "customization", "Reset to default": "Reset to default", + "configurationsCount": "{count, plural, =1 {1 key} other {# keys}}", + "Keys": "Keys", + "modelsCount": "{count, plural, =1 {1 model} other {# models}}", + "projectsCount": "{count, plural, =1 {1 project} other {# projects}}", + "exceptProjectsCount": "{count, plural, =1 {All except 1 project} other {All except # projects}}", + "Connect your first provider": "Connect your first provider", + "Bring an API key, then pick which models and projects can use it. Chat, agents, and AI steps run through it.": "Bring an API key, then pick which models and projects can use it. Chat, agents, and AI steps run through it.", + "No model is selected yet.": "No model is selected yet.", + "No project is selected \u2014 no project can use this key until you pick at least one.": "No project is selected \u2014 no project can use this key until you pick at least one.", + "+{count}": "+{count}", + "AI Center": "AI Center", + "AI Gateway token": "AI Gateway token", + "API key": "API key", + "API key header": "API key header", + "API version": "API version", + "AWS access key ID": "AWS access key ID", + "AWS region": "AWS region", + "AWS secret access key": "AWS secret access key", + "Add key": "Add key", + "All except": "All except", + "All models": "All models", + "All projects": "All projects", + "Also available": "Also available", + "Available to the assistant": "Available to the assistant", + "Assistant capabilities": "Assistant capabilities", + "Bring your own API key to connect any of these.": "Bring your own API key to connect any of these.", + "Capabilities": "Capabilities", + "Chat provider": "Chat provider", + "Claude models \u2014 strong for chat and agents": "Claude models \u2014 strong for chat and agents", + "Connect a provider": "Connect a provider", + "Connect a provider to turn on chat, agents, and AI steps across your platform.": "Connect a provider to turn on chat, agents, and AI steps across your platform.", + "Credentials": "Credentials", + "Custom headers": "Custom headers", + "Delete this key": "Delete this key", + "Each key has its own models and project access.": "Each key has its own models and project access.", + "Every project except these \u2014 new projects get access automatically.": "Every project except these \u2014 new projects get access automatically.", + "Every project on this platform can use it.": "Every project on this platform can use it.", + "GPT models \u2014 broad ecosystem and tooling": "GPT models \u2014 broad ecosystem and tooling", + "Google Vertex project ID": "Google Vertex project ID", + "Google Vertex project region": "Google Vertex project region", + "Hidden from the assistant": "Hidden from the assistant", + "How this key is labelled and authorised.": "How this key is labelled and authorised.", + "How to get {name} credentials": "How to get {name} credentials", + "Irreversible actions for this key.": "Irreversible actions for this key.", + "Leave empty to use the default. Some Azure resources require a different version, e.g. 2023-03-15-preview.": "Leave empty to use the default. Some Azure resources require a different version, e.g. 2023-03-15-preview.", + "Model": "Model", + "Model ids exposed through this key.": "Model ids exposed through this key.", + "Models": "Models", + "Models and project access stay as they are.": "Models and project access stay as they are.", + "No model matches your search.": "No model matches your search.", + "No models found": "No models found", + "No project is selected yet.": "No project is selected yet.", + "No project matches your search.": "No project matches your search.", + "Only these projects can use this key.": "Only these projects can use this key.", + "Or choose another provider": "Or choose another provider", + "Page {page} of {pages}": "Page {page} of {pages}", + "Powers the built-in chat for everyone on this platform": "Powers the built-in chat for everyone on this platform", + "Project access": "Project access", + "Providers": "Providers", + "Providers and capabilities for your platform.": "Providers and capabilities for your platform.", + "Replace credentials": "Replace credentials", + "Resource name": "Resource name", + "Save credentials": "Save credentials", + "Search models": "Search models", + "Search {count} projects": "Search {count} projects", + "Selected only": "Selected only", + "Showing {from}\u2013{to} of {total}": "Showing {from}\u2013{to} of {total}", + "Steps and agents using it will stop working.": "Steps and agents using it will stop working.", + "Steps and agents using this key will stop working.": "Steps and agents using this key will stop working.", + "Stored securely": "Stored securely", + "The provider rejected these credentials.": "The provider rejected these credentials.", + "This provider cannot list models automatically \u2014 add the model ids you want to expose.": "This provider cannot list models automatically \u2014 add the model ids you want to expose.", + "Turned off": "Turned off", + "We verify the credentials, then you pick models and projects.": "We verify the credentials, then you pick models and projects.", + "Which of this key\u2019s models the platform may use.": "Which of this key\u2019s models the platform may use.", + "You have unsaved changes": "You have unsaved changes", + "e.g. Marketing": "e.g. Marketing", + "e.g. openai/gpt-4o": "e.g. openai/gpt-4o", + "optional": "optional", + "{count} projects have access. Excluded: {names}": "{count} projects have access. Excluded: {names}", + "{name} key": "{name} key", "Tab name": "Tab name", "Show tab": "Show tab", "Hide tab": "Hide tab", diff --git a/packages/web/src/app/components/global-search/static-pages.ts b/packages/web/src/app/components/global-search/static-pages.ts index da4112d96733..fab2fbca9a46 100644 --- a/packages/web/src/app/components/global-search/static-pages.ts +++ b/packages/web/src/app/components/global-search/static-pages.ts @@ -66,7 +66,7 @@ export const STATIC_PAGES: StaticPage[] = [ }, { id: 'page-platform-ai', - label: 'Platform Admin — AI Providers', + label: 'Platform Admin — AI Center', href: '/platform/setup/ai', icon: BotIcon, requiresPlatformAdmin: true, diff --git a/packages/web/src/app/components/sidebar/platform/index.tsx b/packages/web/src/app/components/sidebar/platform/index.tsx index 4bc8684dee33..fc40d20d3a2f 100644 --- a/packages/web/src/app/components/sidebar/platform/index.tsx +++ b/packages/web/src/app/components/sidebar/platform/index.tsx @@ -4,7 +4,6 @@ import { ComponentType, useRef } from 'react'; import { Link } from 'react-router-dom'; import { McpSvg } from '@/assets/img/custom/mcp'; -import { BotIcon } from '@/components/icons/bot'; import { ChartLineIcon } from '@/components/icons/chart-line'; import { ChevronLeftIcon, @@ -66,12 +65,7 @@ export function PlatformSidebar() { }, { to: '/platform/setup/ai', - label: t('AI Providers'), - icon: BotIcon, - }, - { - to: '/platform/setup/ai-capabilities', - label: t('AI Capabilities'), + label: t('AI Center'), icon: SparklesIcon, }, { diff --git a/packages/web/src/app/routes/platform-routes.tsx b/packages/web/src/app/routes/platform-routes.tsx index 75a7231aecda..3086f87b3c25 100644 --- a/packages/web/src/app/routes/platform-routes.tsx +++ b/packages/web/src/app/routes/platform-routes.tsx @@ -43,9 +43,6 @@ const SSOPage = React.lazy(() => import('./platform/security/sso').then((m) => ({ default: m.SSOPage })), ); const AIProvidersPage = React.lazy(() => import('./platform/setup/ai')); -const AiCapabilitiesPage = React.lazy( - () => import('./platform/setup/ai-capabilities'), -); const PlatformMcpPage = React.lazy(() => import('./platform/setup/mcp')); const GeneralPage = React.lazy(() => import('./platform/setup/general').then((m) => ({ @@ -142,7 +139,7 @@ export const platformRoutes = [ path: '/platform/setup/ai', element: ( - + @@ -152,15 +149,7 @@ export const platformRoutes = [ }, { path: '/platform/setup/ai-capabilities', - element: ( - - - - - - - - ), + element: , }, { path: '/platform/setup/mcp', diff --git a/packages/web/src/app/routes/platform/setup/ai-capabilities/index.tsx b/packages/web/src/app/routes/platform/setup/ai-capabilities/index.tsx deleted file mode 100644 index 8630d9711ca0..000000000000 --- a/packages/web/src/app/routes/platform/setup/ai-capabilities/index.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { - AiToolCapability, - AiToolConfigWithoutSensitiveData, - PlatformRole, -} from '@activepieces/shared'; -import { t } from 'i18next'; -import { Globe, Image, LucideIcon, Pencil, Search, Trash } from 'lucide-react'; - -import { CenteredPage } from '@/app/components/centered-page'; -import { ConfirmationDeleteDialog } from '@/components/custom/delete-dialog'; -import { Button } from '@/components/ui/button'; -import { Switch } from '@/components/ui/switch'; -import { - aiToolConfigMutations, - aiToolConfigQueries, -} from '@/features/platform-admin'; -import { platformHooks } from '@/hooks/platform-hooks'; -import { userHooks } from '@/hooks/user-hooks'; - -import LockedFeatureGuard from '../../../../components/locked-feature-guard'; - -import { AiCapabilityDialog } from './ai-capability-dialog'; -import { AI_TOOL_CATALOG, AiToolCapabilityInfo } from './catalog'; - -const CAPABILITY_ICON: Record = { - [AiToolCapability.WEB_SEARCH]: Search, - [AiToolCapability.WEB_SCRAPING]: Globe, - [AiToolCapability.IMAGE_GENERATION]: Image, -}; - -export default function AiCapabilitiesPage() { - const { data: configs, refetch } = aiToolConfigQueries.useAiToolConfigs(); - const { data: currentUser } = userHooks.useCurrentUser(); - const { platform } = platformHooks.useCurrentPlatform(); - const allowWrite = platform.plan.aiProvidersEnabled; - - const { mutate: toggle } = aiToolConfigMutations.useUpdateAiToolConfig({ - onSuccess: () => refetch(), - }); - const { mutate: remove } = aiToolConfigMutations.useDeleteAiToolConfig({ - onSuccess: () => refetch(), - }); - - return ( - - -
- {AI_TOOL_CATALOG.map((capabilityInfo) => { - const config = configs?.find( - (c) => c.capability === capabilityInfo.capability, - ); - return ( - - config && - toggle({ id: config.id, request: { enabled: checked } }) - } - onDelete={() => config && remove(config.id)} - onSaved={() => refetch()} - /> - ); - })} -
-
-
- ); -} - -function CapabilityCard({ - capabilityInfo, - Icon, - config, - allowWrite, - onToggle, - onDelete, - onSaved, -}: { - capabilityInfo: AiToolCapabilityInfo; - Icon: LucideIcon; - config?: AiToolConfigWithoutSensitiveData; - allowWrite: boolean; - onToggle: (checked: boolean) => void; - onDelete: () => void; - onSaved: () => void; -}) { - const configId = config?.id; - const enabled = config?.enabled ?? false; - const providerName = capabilityInfo.providers.find( - (p) => p.id === config?.provider, - )?.name; - return ( -
-
- -
-
-

- {capabilityInfo.name} - {providerName && ( - - {' · '} - {providerName} - - )} -

-

- {capabilityInfo.description} -

-
- - {allowWrite && ( -
- {configId && } - - {configId ? ( - - ) : ( - - )} - - {configId && ( - onDelete()} - > - - - )} -
- )} -
- ); -} diff --git a/packages/web/src/app/routes/platform/setup/ai/capabilities-tab/index.tsx b/packages/web/src/app/routes/platform/setup/ai/capabilities-tab/index.tsx new file mode 100644 index 000000000000..c39b04b00829 --- /dev/null +++ b/packages/web/src/app/routes/platform/setup/ai/capabilities-tab/index.tsx @@ -0,0 +1,224 @@ +import { + AiToolCapability, + AiToolConfigWithoutSensitiveData, +} from '@activepieces/shared'; +import { t } from 'i18next'; +import { + Globe, + Image, + LucideIcon, + Search, + Settings2, + Trash2, +} from 'lucide-react'; + +import { ConfirmationDeleteDialog } from '@/components/custom/delete-dialog'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { + aiToolConfigMutations, + aiToolConfigQueries, +} from '@/features/platform-admin'; +import { platformHooks } from '@/hooks/platform-hooks'; +import { cn } from '@/lib/utils'; + +import { AiCapabilityDialog } from '../../ai-capabilities/ai-capability-dialog'; +import { + AI_TOOL_CATALOG, + AiToolCapabilityInfo, + AiToolProviderInfo, +} from '../../ai-capabilities/catalog'; +import { SectionHeader } from '../components/section-header'; + +export function CapabilitiesTab() { + const { data: configs, refetch } = aiToolConfigQueries.useAiToolConfigs(); + const { platform } = platformHooks.useCurrentPlatform(); + const allowWrite = platform.plan.aiProvidersEnabled; + + const { mutate: toggle } = aiToolConfigMutations.useUpdateAiToolConfig({ + onSuccess: () => refetch(), + }); + const { mutate: remove } = aiToolConfigMutations.useDeleteAiToolConfig({ + onSuccess: () => refetch(), + }); + + return ( +
+ +
+ {AI_TOOL_CATALOG.map((capabilityInfo) => { + const config = configs?.find( + (c) => c.capability === capabilityInfo.capability, + ); + return ( + + config && toggle({ id: config.id, request: { enabled } }) + } + onDelete={() => config && remove(config.id)} + onSaved={() => refetch()} + /> + ); + })} +
+
+ ); +} + +function CapabilityCard({ + capabilityInfo, + config, + allowWrite, + onToggle, + onDelete, + onSaved, +}: { + capabilityInfo: AiToolCapabilityInfo; + config?: AiToolConfigWithoutSensitiveData; + allowWrite: boolean; + onToggle: (enabled: boolean) => void; + onDelete: () => void; + onSaved: () => void; +}) { + const Icon = CAPABILITY_ICON[capabilityInfo.capability]; + const connectedProvider = capabilityInfo.providers.find( + (provider) => provider.id === config?.provider, + ); + + return ( +
+
+
+ +
+
+

+ {capabilityInfo.name} +

+
+ + + {!config + ? t('Not connected') + : config.enabled + ? t('Active') + : t('Turned off')} + + {connectedProvider && ( + <> + · + + + )} +
+
+ {config && allowWrite && ( +
+ + + + onDelete()} + > + + +
+ )} +
+

+ {capabilityInfo.description} +

+
+ {config ? ( + <> + + {config.enabled + ? t('Available to the assistant') + : t('Hidden from the assistant')} + + {allowWrite && ( + + )} + + ) : ( + <> + + {capabilityInfo.providers.map((provider, index) => ( + + {index > 0 && ·} + + + ))} + + {allowWrite && ( + + + + )} + + )} +
+
+ ); +} + +function ProviderLink({ provider }: { provider: AiToolProviderInfo }) { + return ( + + {provider.name} + + ); +} + +const CAPABILITY_ICON: Record = { + [AiToolCapability.WEB_SEARCH]: Search, + [AiToolCapability.WEB_SCRAPING]: Globe, + [AiToolCapability.IMAGE_GENERATION]: Image, +}; diff --git a/packages/web/src/app/routes/platform/setup/ai/components/section-header.tsx b/packages/web/src/app/routes/platform/setup/ai/components/section-header.tsx new file mode 100644 index 000000000000..92a313cfe3fb --- /dev/null +++ b/packages/web/src/app/routes/platform/setup/ai/components/section-header.tsx @@ -0,0 +1,23 @@ +export function SectionHeader({ + title, + count, + description, +}: { + title: string; + count?: number; + description: string; +}) { + return ( +
+
+

{title}

+ {count !== undefined && ( + + {count} + + )} +
+

{description}

+
+ ); +} diff --git a/packages/web/src/app/routes/platform/setup/ai/components/selected-only-button.tsx b/packages/web/src/app/routes/platform/setup/ai/components/selected-only-button.tsx new file mode 100644 index 000000000000..f279a80b4bbd --- /dev/null +++ b/packages/web/src/app/routes/platform/setup/ai/components/selected-only-button.tsx @@ -0,0 +1,24 @@ +import { t } from 'i18next'; + +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; + +export function SelectedOnlyButton({ + pressed, + onToggle, +}: { + pressed: boolean; + onToggle: () => void; +}) { + return ( + + ); +} diff --git a/packages/web/src/app/routes/platform/setup/ai/components/table-pagination.tsx b/packages/web/src/app/routes/platform/setup/ai/components/table-pagination.tsx new file mode 100644 index 000000000000..b78375a5af2c --- /dev/null +++ b/packages/web/src/app/routes/platform/setup/ai/components/table-pagination.tsx @@ -0,0 +1,77 @@ +import { t } from 'i18next'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +export function TablePagination({ + page, + pageSize, + total, + onPageChange, + className, +}: { + page: number; + pageSize: number; + total: number; + onPageChange: (page: number) => void; + className?: string; +}) { + if (total <= pageSize) { + return null; + } + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + + return ( +
+ + {t('Showing {from}–{to} of {total}', { + from: page * pageSize + 1, + to: Math.min((page + 1) * pageSize, total), + total, + })} + +
+ + + {t('Page {page} of {pages}', { + page: page + 1, + pages: pageCount, + })} + + +
+
+ ); +} + +export function pageSlice({ + items, + page, + pageSize, +}: { + items: T[]; + page: number; + pageSize: number; +}): { rows: T[]; page: number } { + const pageCount = Math.max(1, Math.ceil(items.length / pageSize)); + const currentPage = Math.min(page, pageCount - 1); + return { + rows: items.slice(currentPage * pageSize, (currentPage + 1) * pageSize), + page: currentPage, + }; +} diff --git a/packages/web/src/app/routes/platform/setup/ai/index.tsx b/packages/web/src/app/routes/platform/setup/ai/index.tsx index fdf27a7aabae..a9340f147749 100644 --- a/packages/web/src/app/routes/platform/setup/ai/index.tsx +++ b/packages/web/src/app/routes/platform/setup/ai/index.tsx @@ -1,52 +1,19 @@ -import { AIProviderName } from '@activepieces/core-utils'; -import { - AIProviderWithoutSensitiveData, - PlatformRole, -} from '@activepieces/shared'; +import { PlatformRole } from '@activepieces/shared'; import { t } from 'i18next'; -import { MessageSquare } from 'lucide-react'; +import { Bot, WandSparkles } from 'lucide-react'; +import { useSearchParams } from 'react-router-dom'; -import { CenteredPage } from '@/app/components/centered-page'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { SUPPORTED_AI_PROVIDERS, AiProviderInfo } from '@/features/agents'; -import { - aiProviderQueries, - aiProviderMutations, -} from '@/features/platform-admin'; -import { platformHooks } from '@/hooks/platform-hooks'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { userHooks } from '@/hooks/user-hooks'; +import { cn } from '@/lib/utils'; import LockedFeatureGuard from '../../../../components/locked-feature-guard'; -import { AIProviderCard } from './universal-pieces/ai-provider-card'; - -const ACTIVEPIECES_LOGO_URL = - 'https://cdn.activepieces.com/pieces/activepieces.png'; +import { CapabilitiesTab } from './capabilities-tab'; +import { ProvidersTab } from './providers-tab'; export default function AIProvidersPage() { - const { data: providers, refetch } = aiProviderQueries.useAiProviderConfigs(); const { data: currentUser } = userHooks.useCurrentUser(); - const { platform } = platformHooks.useCurrentPlatform(); - const allowWrite = platform.plan.aiProvidersEnabled; - - const { mutateAsync: deleteProvider } = - aiProviderMutations.useDeleteAiProvider({ - onSuccess: () => refetch(), - }); - - const { mutateAsync: toggleChatProvider } = - aiProviderMutations.useToggleChatProvider({ - onSuccess: () => refetch(), - }); - - const configuredProviders = providers ?? []; - const chatProvider = providers?.find((p) => p.enabledForChat); return ( - - {allowWrite && configuredProviders.length > 0 && ( - - toggleChatProvider({ providerId, displayName }) - } - /> - )} - -
- {SUPPORTED_AI_PROVIDERS.map((providerDef) => { - const config = providers?.find( - (p) => p.provider === providerDef.provider, - ); - - return ( - deleteProvider(id)} - onSave={() => refetch()} - allowWrite={allowWrite} - /> - ); - })} -
-
+
); } -function ChatProviderSelector({ - providers, - providerInfos, - selectedProviderId, - onSelect, -}: { - providers: AIProviderWithoutSensitiveData[]; - providerInfos: AiProviderInfo[]; - selectedProviderId: string | null; - onSelect: (providerId: string, displayName: string) => void; -}) { - const getLogoUrl = (providerName: string) => - providerInfos.find((p) => p.provider === providerName)?.logoUrl ?? - (providerName === AIProviderName.ACTIVEPIECES - ? ACTIVEPIECES_LOGO_URL - : undefined); +function AICenter() { + const [searchParams, setSearchParams] = useSearchParams(); + + const rawTab = searchParams.get('tab'); + const activeTab = isTabValue(rawTab) ? rawTab : 'providers'; + + const setTab = (tab: TabValue) => { + const newParams = new URLSearchParams(searchParams); + if (tab === 'providers') { + newParams.delete('tab'); + } else { + newParams.set('tab', tab); + } + setSearchParams(newParams, { replace: true }); + }; + + const navItems: { value: TabValue; label: string; icon: typeof Bot }[] = [ + { value: 'providers', label: t('Providers'), icon: Bot }, + { value: 'capabilities', label: t('Capabilities'), icon: WandSparkles }, + ]; return ( -
-
- -
-
-

{t('Chat Provider')}

-

- {t('Select which AI provider powers the chat feature')} -

+ { + const next = TAB_VALUES.find((candidate) => candidate === value); + if (next) { + setTab(next); + } + }} + className="flex w-full flex-1 min-h-0" + > + +
+
+ + + + + + +
- -
+ ); } + +function isTabValue(value: string | null): value is TabValue { + return TAB_VALUES.some((tab) => tab === value); +} + +const TAB_VALUES = ['providers', 'capabilities'] as const; + +type TabValue = (typeof TAB_VALUES)[number]; diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/config-detail.tsx b/packages/web/src/app/routes/platform/setup/ai/providers-tab/config-detail.tsx new file mode 100644 index 000000000000..85d6beba8fec --- /dev/null +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/config-detail.tsx @@ -0,0 +1,398 @@ +import { + AIProviderModelType, + AIProviderWithoutSensitiveData, + AiProviderModelScope, + AiProviderProjectScope, + CloudflareGatewayProviderConfig, + formErrors, + OpenAICompatibleProviderConfig, + Project, + UpdateAIProviderRequest, +} from '@activepieces/shared'; +import { useQuery } from '@tanstack/react-query'; +import { t } from 'i18next'; +import { ChevronLeft, KeyRound, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { z } from 'zod'; + +import { ConfirmationDeleteDialog } from '@/components/custom/delete-dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { AiProviderInfo } from '@/features/agents'; +import { aiProviderApi, aiProviderKeys } from '@/features/platform-admin'; + +import { SectionHeader } from '../components/section-header'; + +import { ManualModelList } from './manual-model-list'; +import { ModelSelectionPanel } from './model-selection-panel'; +import { ProjectSelectionPanel } from './project-selection-panel'; +import { providerCredentials } from './provider-credentials'; +import { ProviderLogo } from './provider-logo'; + +export function ConfigDetail({ + config, + info, + projects, + isSaving, + onSave, + onDelete, + onReplaceCredentials, + onBack, +}: { + config: AIProviderWithoutSensitiveData; + info: AiProviderInfo; + projects: Project[]; + isSaving: boolean; + onSave: (request: UpdateAIProviderRequest) => void; + onDelete: () => void; + onReplaceCredentials: () => void; + onBack: () => void; +}) { + const [draft, setDraft] = useState(draftOf(config)); + const [deleteOpen, setDeleteOpen] = useState(false); + + const manualModels = providerCredentials.usesManualModels({ + provider: config.provider, + }); + const { data: models = [] } = useQuery({ + queryKey: aiProviderKeys.configModels(config.id), + queryFn: () => aiProviderApi.listModelsForConfig(config.id), + enabled: !manualModels, + }); + const selectableModels = [ + ...models, + ...draft.modelIds + .filter((modelId) => !models.some((model) => model.id === modelId)) + .map((modelId) => ({ + id: modelId, + name: modelId, + type: AIProviderModelType.TEXT, + })), + ]; + const dirty = JSON.stringify(draft) !== JSON.stringify(draftOf(config)); + const nameMissing = draft.name.trim().length === 0; + const enabledModelCount = + !manualModels && draft.modelScope === 'all' + ? models.length + : draft.modelIds.length; + const allowedProjectCount = + draft.projectScope === 'all' + ? projects.length + : draft.projectScope === 'except' + ? projects.length - draft.projectIds.length + : draft.projectIds.length; + + const save = () => { + const manualConfigParse = manualModels + ? ManualProviderConfig.safeParse(config.config) + : undefined; + const manualConfig = manualConfigParse?.success + ? manualConfigParse.data + : undefined; + if (nameMissing) { + return; + } + onSave({ + displayName: draft.name.trim(), + modelScope: draft.modelScope, + modelIds: draft.modelIds, + projectScope: draft.projectScope, + projectIds: draft.projectIds, + ...(manualConfig + ? { + config: { + ...manualConfig, + models: draft.modelIds.map( + (modelId) => + manualConfig.models.find( + (model) => model.modelId === modelId, + ) ?? { + modelId, + modelName: modelId, + modelType: AIProviderModelType.TEXT, + }, + ), + }, + } + : {}), + }); + }; + + return ( +
+
+ +
+ +
+

+ {draft.name} +

+
+ {info.name} +
+
+
+
+ +
+ +
+
+ + + setDraft({ ...draft, name: event.target.value }) + } + className="max-w-sm" + aria-invalid={nameMissing} + /> + {nameMissing && ( +

+ {t(formErrors.required)} +

+ )} +
+
+
+
+ +
+
+

+ {t('Credentials')} +

+

+ {t('Stored securely')} +

+
+
+ +
+
+
+ +
+
+ + {!manualModels && ( + + setDraft({ + ...draft, + modelScope: value === 'all' ? 'all' : 'selected', + modelIds: value === 'all' ? [] : draft.modelIds, + }) + } + options={[ + { value: 'all', label: t('All models') }, + { value: 'selected', label: t('Only selected') }, + ]} + /> + )} +
+ {manualModels ? ( + setDraft({ ...draft, modelIds })} + /> + ) : ( + draft.modelScope === 'selected' && ( + setDraft({ ...draft, modelIds })} + /> + ) + )} +
+ +
+
+ + + setDraft({ + ...draft, + projectScope: + value === 'all' + ? 'all' + : value === 'except' + ? 'except' + : 'selected', + projectIds: value === 'all' ? [] : draft.projectIds, + }) + } + options={[ + { value: 'all', label: t('All') }, + { value: 'selected', label: t('Only selected') }, + { value: 'except', label: t('All except') }, + ]} + /> +
+ {draft.projectScope !== 'all' && ( + setDraft({ ...draft, projectIds })} + /> + )} +
+ +
+ +
+
+

+ {t('Delete this key')} +

+

+ {t('Steps and agents using it will stop working.')} +

+
+ +
+ onDelete()} + /> +
+ + {dirty && ( +
+
+ {t('You have unsaved changes')} + + +
+
+ )} +
+ ); +} + +function ScopeTabs({ + value, + onChange, + options, +}: { + value: string; + onChange: (value: string) => void; + options: { value: string; label: string }[]; +}) { + return ( + + ); +} + +function draftOf(config: AIProviderWithoutSensitiveData): ConfigDraft { + const manualModels = providerCredentials.usesManualModels({ + provider: config.provider, + }); + return { + name: config.name, + modelScope: config.modelScope, + modelIds: + manualModels && 'models' in config.config + ? config.config.models.map((model) => model.modelId) + : config.modelIds, + projectScope: config.projectScope, + projectIds: config.projectIds, + }; +} + +const ManualProviderConfig = z.union([ + OpenAICompatibleProviderConfig, + CloudflareGatewayProviderConfig, +]); + +type ConfigDraft = { + name: string; + modelScope: AiProviderModelScope; + modelIds: string[]; + projectScope: AiProviderProjectScope; + projectIds: string[]; +}; diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/connect-provider-dialog.tsx b/packages/web/src/app/routes/platform/setup/ai/providers-tab/connect-provider-dialog.tsx new file mode 100644 index 000000000000..03e4f24d3403 --- /dev/null +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/connect-provider-dialog.tsx @@ -0,0 +1,460 @@ +import { AIProviderName } from '@activepieces/core-utils'; +import { + AIProviderWithoutSensitiveData, + formErrors, + MarkdownVariant, +} from '@activepieces/shared'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { AxiosError } from 'axios'; +import { t } from 'i18next'; +import { ChevronDown, Eye, EyeOff } from 'lucide-react'; +import { useState } from 'react'; +import { ControllerRenderProps, useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { DictionaryInput } from '@/components/custom/dictionary-input'; +import { ApMarkdown } from '@/components/custom/markdown'; +import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { AiProviderInfo, SUPPORTED_AI_PROVIDERS } from '@/features/agents'; +import { aiProviderMutations } from '@/features/platform-admin'; +import { cn } from '@/lib/utils'; + +import { CredentialField, providerCredentials } from './provider-credentials'; +import { ProviderLogo } from './provider-logo'; +import { providerRequestUtils } from './provider-request'; + +export function ConnectProviderDialog({ + open, + onOpenChange, + editing, + defaultProvider, + onConnected, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + editing?: AIProviderWithoutSensitiveData; + defaultProvider?: AIProviderName; + onConnected: (createdId?: string) => void; +}) { + return ( + + + { + onConnected(createdId); + onOpenChange(false); + }} + onCancel={() => onOpenChange(false)} + /> + + + ); +} + +function ConnectProviderForm({ + editing, + defaultProvider, + onConnected, + onCancel, +}: { + editing?: AIProviderWithoutSensitiveData; + defaultProvider?: AIProviderName; + onConnected: (createdId?: string) => void; + onCancel: () => void; +}) { + const form = useForm({ + resolver: zodResolver(connectFormSchema()), + mode: 'onChange', + defaultValues: defaultValuesOf({ editing, defaultProvider }), + }); + + const provider = form.watch('provider'); + const info = providerInfoOf({ provider }); + const fields = providerCredentials.fieldsOf({ provider }); + + const { mutate: connect, isPending: connecting } = + aiProviderMutations.useUpsertAiProvider({ + providerId: editing?.id, + onSuccess: (created) => onConnected(created?.id), + onError: ( + error: AxiosError<{ message?: string; params?: { message: string } }>, + ) => { + const data = error.response?.data; + form.setError('root.serverError', { + type: 'manual', + message: + data?.params?.message ?? + data?.message ?? + t('The provider rejected these credentials.'), + }); + }, + }); + + const handleSubmit = (values: ConnectFormValues) => { + form.clearErrors('root.serverError'); + const request = providerRequestUtils.buildCreateRequest({ + provider: values.provider, + displayName: values.name.trim(), + credentials: values.credentials, + headers: values.headers, + existingConfig: editing?.config, + }); + connect(request); + }; + + return ( +
+ + + + {editing ? t('Replace credentials') : t('Connect a provider')} + + + {editing + ? t('Models and project access stay as they are.') + : t( + 'We verify the credentials, then you pick models and projects.', + )} + + + + +
+ ( + + {t('Provider')} + + + + )} + /> + + {info?.markdown && } + + ( + + {t('Name')} + + + + + + )} + /> + + {fields.map((credentialField) => ( + + ))} + + {form.formState.errors.root?.serverError && ( + + {form.formState.errors.root.serverError.message} + + )} +
+
+ + + + + +
+ + ); +} + +function CredentialFieldInput({ + form, + field, +}: { + form: ConnectForm; + field: CredentialField; +}) { + if (field.type === 'dictionary') { + return ( + ( + + {field.label} + + + )} + /> + ); + } + + return ( + ( + + + {field.label} + {field.optional && ( + + {t('optional')} + + )} + + {field.options ? ( + + ) : field.secret ? ( + + ) : ( + + + + )} + {field.description && ( + {field.description} + )} + + + )} + /> + ); +} + +function SecretInput({ + field, + placeholder, +}: { + field: ControllerRenderProps; + placeholder?: string; +}) { + const [visible, setVisible] = useState(false); + return ( +
+ + + + +
+ ); +} + +function SetupInstructions({ info }: { info: AiProviderInfo }) { + const [open, setOpen] = useState(false); + return ( + + + + + + + + + ); +} + +function defaultValuesOf({ + editing, + defaultProvider, +}: { + editing?: AIProviderWithoutSensitiveData; + defaultProvider?: AIProviderName; +}): ConnectFormValues { + const provider = + editing?.provider ?? defaultProvider ?? AIProviderName.ANTHROPIC; + const secretKeys = providerCredentials.secretKeysOf({ provider }); + const prefill = editing + ? providerRequestUtils.credentialPrefillOf({ row: editing }) + : {}; + return { + provider, + name: editing?.name ?? defaultNameOf(provider), + credentials: { + ...emptyCredentialsOf(provider), + ...Object.fromEntries( + Object.entries(prefill).map(([key, value]) => [ + key, + secretKeys.includes(key) ? '' : value, + ]), + ), + }, + headers: editing + ? providerRequestUtils.headersPrefillOf({ row: editing }) + : {}, + }; +} + +function displayNameOf(provider: AIProviderName): string | undefined { + return SUPPORTED_AI_PROVIDERS.find((info) => info.provider === provider) + ?.name; +} + +function defaultNameOf(provider: AIProviderName): string { + return t('{name} key', { + name: displayNameOf(provider) ?? provider, + }); +} + +function emptyCredentialsOf(provider: AIProviderName): Record { + return Object.fromEntries( + providerCredentials + .fieldsOf({ provider }) + .filter((field) => field.type !== 'dictionary') + .map((field) => [field.key, '']), + ); +} + +function providerInfoOf({ + provider, +}: { + provider: AIProviderName; +}): AiProviderInfo | undefined { + return SUPPORTED_AI_PROVIDERS.find( + (candidate) => candidate.provider === provider, + ); +} + +function connectFormSchema() { + return z + .object({ + provider: z.enum(AIProviderName), + name: z.string().trim().min(1, formErrors.required), + credentials: z.record(z.string(), z.string()), + headers: z.record(z.string(), z.string()), + }) + .superRefine((values, ctx) => { + providerCredentials + .fieldsOf({ provider: values.provider }) + .filter((field) => field.type !== 'dictionary' && !field.optional) + .forEach((field) => { + if ((values.credentials[field.key] ?? '').trim().length === 0) { + ctx.addIssue({ + code: 'custom', + message: formErrors.required, + path: ['credentials', field.key], + }); + } + }); + }); +} + +type ConnectFormValues = z.infer>; + +type ConnectForm = ReturnType>; diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/index.tsx b/packages/web/src/app/routes/platform/setup/ai/providers-tab/index.tsx new file mode 100644 index 000000000000..1deba6d1b249 --- /dev/null +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/index.tsx @@ -0,0 +1,658 @@ +import { AIProviderName } from '@activepieces/core-utils'; +import { AIProviderWithoutSensitiveData, Project } from '@activepieces/shared'; +import { useQueryClient } from '@tanstack/react-query'; +import { t } from 'i18next'; +import { + Bot, + MessageSquare, + MoreHorizontal, + Plus, + Settings2, + Trash2, +} from 'lucide-react'; +import { useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import { ConfirmationDeleteDialog } from '@/components/custom/delete-dialog'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { AiProviderInfo, SUPPORTED_AI_PROVIDERS } from '@/features/agents'; +import { + aiProviderKeys, + aiProviderMutations, + aiProviderQueries, +} from '@/features/platform-admin'; +import { projectCollectionUtils } from '@/features/projects'; +import { platformHooks } from '@/hooks/platform-hooks'; +import { cn } from '@/lib/utils'; + +import { SectionHeader } from '../components/section-header'; + +import { ConfigDetail } from './config-detail'; +import { ConnectProviderDialog } from './connect-provider-dialog'; +import { ProjectSwatch } from './project-selection-panel'; +import { ProviderLogo } from './provider-logo'; + +export function ProvidersTab() { + const [searchParams, setSearchParams] = useSearchParams(); + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState< + AIProviderWithoutSensitiveData | undefined + >(undefined); + const [credentialsVersion, setCredentialsVersion] = useState(0); + const [dialogProvider, setDialogProvider] = useState< + AIProviderName | undefined + >(undefined); + + const queryClient = useQueryClient(); + const { data: providers, refetch } = aiProviderQueries.useAiProviderConfigs(); + const { platform } = platformHooks.useCurrentPlatform(); + const allowWrite = platform.plan.aiProvidersEnabled; + const { data: projects } = projectCollectionUtils.useAllPlatformProjects(); + const configs = (providers ?? []).filter( + (provider) => provider.provider !== AIProviderName.ACTIVEPIECES, + ); + const chatProviderRow = (providers ?? []).find( + (provider) => provider.enabledForChat, + ); + + const { mutate: toggleChatProvider } = + aiProviderMutations.useToggleChatProvider({ + onSuccess: () => refetch(), + }); + const { mutateAsync: deleteProvider } = + aiProviderMutations.useDeleteAiProvider({ + onSuccess: () => refetch(), + }); + const { mutate: updateProvider, isPending: isSaving } = + aiProviderMutations.useUpdateAiProvider({ + onSuccess: () => refetch(), + }); + + const openConfig = (id: string) => { + const next = new URLSearchParams(searchParams); + next.set('config', id); + setSearchParams(next); + }; + const closeConfig = () => { + const next = new URLSearchParams(searchParams); + next.delete('config'); + setSearchParams(next); + }; + const openConnect = (provider?: AIProviderName) => { + setEditing(undefined); + setDialogProvider(provider); + setDialogOpen(true); + }; + const openReplaceCredentials = (config: AIProviderWithoutSensitiveData) => { + setEditing(config); + setDialogProvider(undefined); + setDialogOpen(true); + }; + const onConnected = async (createdId?: string) => { + await Promise.all([ + refetch(), + queryClient.invalidateQueries({ + queryKey: aiProviderKeys.configModels(), + }), + ]); + if (createdId) { + openConfig(createdId); + return; + } + setCredentialsVersion((version) => version + 1); + }; + const selectChatConfig = (configId: string) => { + const row = (providers ?? []).find((config) => config.id === configId); + if (row) { + toggleChatProvider({ providerId: row.id, displayName: row.name }); + } + }; + + const connectedProviders = [...new Set(configs.map((c) => c.provider))]; + const available = SUPPORTED_AI_PROVIDERS.filter( + ({ provider }) => !connectedProviders.includes(provider), + ); + + const activeConfig = configs.find( + (config) => config.id === searchParams.get('config'), + ); + const activeInfo = activeConfig + ? providerInfoOf({ provider: activeConfig.provider }) + : undefined; + if (activeConfig && activeInfo && allowWrite) { + return ( + <> + + updateProvider({ providerId: activeConfig.id, request }) + } + onDelete={async () => { + await deleteProvider(activeConfig.id); + closeConfig(); + }} + onReplaceCredentials={() => openReplaceCredentials(activeConfig)} + onBack={closeConfig} + /> + + + ); + } + + return ( + <> +
+
+ + {allowWrite && ( + + )} +
+ + {configs.length === 0 ? ( + + ) : ( + <> + {allowWrite && ( + + )} +
+ {connectedProviders.map((provider) => ( + config.provider === provider, + )} + projects={projects} + allowWrite={allowWrite} + onAdd={() => openConnect(provider)} + onOpen={openConfig} + onDelete={(id) => deleteProvider(id)} + /> + ))} +
+ {available.length > 0 && ( +
+ +
+ {available.map((info) => ( + openConnect(info.provider)} + /> + ))} +
+
+ )} + + )} +
+ + + + ); +} + +function ProviderGroup({ + provider, + configs, + projects, + allowWrite, + onAdd, + onOpen, + onDelete, +}: { + provider: AIProviderName; + configs: AIProviderWithoutSensitiveData[]; + projects: Project[]; + allowWrite: boolean; + onAdd: () => void; + onOpen: (id: string) => void; + onDelete: (id: string) => Promise; +}) { + const info = providerInfoOf({ provider }); + if (!info) { + return null; + } + + return ( +
+
+ +
+

{info.name}

+

+ {t('configurationsCount', { count: configs.length })} +

+
+ {allowWrite && ( + + )} +
+
+

+ {t('Keys')} +

+
+
+ {configs.map((config) => ( + onOpen(config.id)} + onDelete={() => onDelete(config.id)} + /> + ))} +
+
+ ); +} + +function ConfigRow({ + config, + projects, + allowWrite, + onOpen, + onDelete, +}: { + config: AIProviderWithoutSensitiveData; + projects: Project[]; + allowWrite: boolean; + onOpen: () => void; + onDelete: () => Promise; +}) { + const [deleteOpen, setDeleteOpen] = useState(false); + const namedProjects = projects.filter((project) => + config.projectIds.includes(project.id), + ); + const allowedProjectCount = + config.projectScope === 'except' + ? projects.length - namedProjects.length + : namedProjects.length; + const modelsLabel = + config.modelScope === 'all' + ? t('All models') + : t('modelsCount', { count: config.modelIds.length }); + const projectsLabel = + config.projectScope === 'all' + ? t('All projects') + : config.projectScope === 'except' + ? t('exceptProjectsCount', { count: namedProjects.length }) + : t('projectsCount', { count: allowedProjectCount }); + + return ( +
{ + if (allowWrite && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + onOpen(); + } + }} + className={cn( + 'flex items-center gap-4 rounded-lg px-5 py-3 transition-colors', + allowWrite && 'cursor-pointer hover:bg-muted/40', + )} + > +
+
+

+ {config.name} +

+ {config.enabledForChat && ( + + {t('Chat')} + + )} +
+ + +

+ {modelsLabel} + · + {projectsLabel} +

+
+ {config.modelIds.length > 0 && ( + + {config.modelIds.join(', ')} + + )} +
+
+ + {config.projectScope !== 'all' && ( +
+ +
+ )} + + {allowWrite && ( +
event.stopPropagation()}> + + + + + + + + {t('Edit')} + + setDeleteOpen(true)} + className="text-destructive" + > + + {t('Delete')} + + + + { + await onDelete(); + }} + /> +
+ )} +
+ ); +} + +function ProjectChips({ + projects, + excluded, + allowedCount, +}: { + projects: Project[]; + excluded: boolean; + allowedCount: number; +}) { + const shown = projects.slice(0, 3); + const rest = projects.length - shown.length; + return ( + + + + {excluded && ( + + {t('All except')} + + )} + {shown.map((project) => ( + + ))} + {rest > 0 && ( + + {t('+{count}', { count: rest })} + + )} + + + + {excluded + ? t('{count} projects have access. Excluded: {names}', { + count: allowedCount, + names: projects.map((project) => project.displayName).join(', '), + }) + : projects.map((project) => project.displayName).join(', ')} + + + ); +} + +function ChatProviderRow({ + configs, + value, + onChange, +}: { + configs: AIProviderWithoutSensitiveData[]; + value: string | null; + onChange: (configId: string) => void; +}) { + return ( +
+
+ +
+
+

{t('Chat provider')}

+

+ {t('Powers the built-in chat for everyone on this platform')} +

+
+ +
+ ); +} + +function EmptyProviders({ + onConnect, + allowWrite, +}: { + onConnect: (provider?: AIProviderName) => void; + allowWrite: boolean; +}) { + const recommended = RECOMMENDED_PROVIDERS.map((provider) => + SUPPORTED_AI_PROVIDERS.find((info) => info.provider === provider), + ).filter((info): info is AiProviderInfo => info !== undefined); + const others = SUPPORTED_AI_PROVIDERS.filter( + ({ provider }) => !RECOMMENDED_PROVIDERS.includes(provider), + ); + + return ( +
+
+
+ +
+
+

+ {t('Connect your first provider')} +

+

+ {t( + 'Bring an API key, then pick which models and projects can use it. Chat, agents, and AI steps run through it.', + )} +

+
+ {allowWrite && ( + + )} +
+
+ {recommended.map((info) => ( + onConnect(info.provider)} + /> + ))} +
+
+

+ {t('Or choose another provider')} +

+
+ {others.map((info) => ( + onConnect(info.provider)} + /> + ))} +
+
+
+ ); +} + +function AvailableProviderCard({ + info, + tagline, + recommended, + allowWrite, + onConnect, +}: { + info: AiProviderInfo; + tagline?: string; + recommended?: boolean; + allowWrite: boolean; + onConnect: () => void; +}) { + return ( +
+ +
+

{info.name}

+ {tagline && ( +

{tagline}

+ )} +
+ {allowWrite && ( + + )} +
+ ); +} + +function providerInfoOf({ + provider, +}: { + provider: AIProviderName; +}): AiProviderInfo | undefined { + return SUPPORTED_AI_PROVIDERS.find( + (candidate) => candidate.provider === provider, + ); +} + +const RECOMMENDED_PROVIDERS: AIProviderName[] = [ + AIProviderName.ANTHROPIC, + AIProviderName.OPENAI, +]; + +function recommendedTagline({ + provider, +}: { + provider: AIProviderName; +}): string | undefined { + switch (provider) { + case AIProviderName.ANTHROPIC: + return t('Claude models — strong for chat and agents'); + case AIProviderName.OPENAI: + return t('GPT models — broad ecosystem and tooling'); + default: + return undefined; + } +} diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/manual-model-list.tsx b/packages/web/src/app/routes/platform/setup/ai/providers-tab/manual-model-list.tsx new file mode 100644 index 000000000000..91324e581c38 --- /dev/null +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/manual-model-list.tsx @@ -0,0 +1,73 @@ +import { t } from 'i18next'; +import { Plus, X } from 'lucide-react'; +import { useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +export function ManualModelList({ + modelIds, + onChange, +}: { + modelIds: string[]; + onChange: (modelIds: string[]) => void; +}) { + const [draft, setDraft] = useState(''); + const add = () => { + const value = draft.trim(); + if (value.length === 0 || modelIds.includes(value)) { + return; + } + onChange([...modelIds, value]); + setDraft(''); + }; + + return ( +
+
+ setDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + add(); + } + }} + placeholder={t('e.g. openai/gpt-4o')} + /> + +
+ {modelIds.length === 0 ? ( +

+ {t( + 'This provider cannot list models automatically — add the model ids you want to expose.', + )} +

+ ) : ( +
+ {modelIds.map((modelId) => ( + + {modelId} + + + ))} +
+ )} +
+ ); +} diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/model-selection-panel.tsx b/packages/web/src/app/routes/platform/setup/ai/providers-tab/model-selection-panel.tsx new file mode 100644 index 000000000000..c6b067f4476f --- /dev/null +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/model-selection-panel.tsx @@ -0,0 +1,134 @@ +import { AIProviderModel } from '@activepieces/shared'; +import { ColumnDef } from '@tanstack/react-table'; +import { t } from 'i18next'; +import { Search, Sparkles } from 'lucide-react'; +import { useState } from 'react'; + +import { DataTable, RowDataWithActions } from '@/components/custom/data-table'; +import { InputWithIcon } from '@/components/custom/input-with-icon'; +import { Checkbox } from '@/components/ui/checkbox'; + +import { SelectedOnlyButton } from '../components/selected-only-button'; +import { pageSlice, TablePagination } from '../components/table-pagination'; + +export function ModelSelectionPanel({ + models, + selectedIds, + onChange, +}: { + models: AIProviderModel[]; + selectedIds: string[]; + onChange: (modelIds: string[]) => void; +}) { + const [search, setSearch] = useState(''); + const [page, setPage] = useState(0); + const [showSelectedOnly, setShowSelectedOnly] = useState(false); + + const filtered = models + .filter((model) => + model.name.toLowerCase().includes(search.trim().toLowerCase()), + ) + .filter((model) => !showSelectedOnly || selectedIds.includes(model.id)); + const { rows, page: currentPage } = pageSlice({ + items: filtered, + page, + pageSize: PAGE_SIZE, + }); + const allRowsSelected = + rows.length > 0 && rows.every((model) => selectedIds.includes(model.id)); + + const toggleModel = (modelId: string) => { + onChange( + selectedIds.includes(modelId) + ? selectedIds.filter((id) => id !== modelId) + : [...selectedIds, modelId], + ); + }; + const toggleRows = () => { + const rowIds = rows.map((model) => model.id); + onChange( + allRowsSelected + ? selectedIds.filter((id) => !rowIds.includes(id)) + : [...new Set([...selectedIds, ...rowIds])], + ); + }; + + const columns: ColumnDef>[] = [ + { + accessorKey: 'name', + header: () => ( +
+ + {t('Model')} +
+ ), + cell: ({ row }) => ( +
+ +
+ {row.original.name} + {row.original.id !== row.original.name && ( + + {row.original.id} + + )} +
+
+ ), + }, + ]; + + return ( +
+
+ } + value={search} + onChange={(event) => { + setSearch(event.target.value); + setPage(0); + }} + placeholder={t('Search models')} + className="max-w-xs grow-0" + /> + { + setShowSelectedOnly(!showSelectedOnly); + setPage(0); + }} + /> +
+
+ toggleModel(row.id)} + emptyStateTextTitle={t('No models found')} + emptyStateTextDescription={ + showSelectedOnly + ? t('No model is selected yet.') + : t('No model matches your search.') + } + emptyStateIcon={ + + } + /> +
+ +
+ ); +} + +const PAGE_SIZE = 10; diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/project-selection-panel.tsx b/packages/web/src/app/routes/platform/setup/ai/providers-tab/project-selection-panel.tsx new file mode 100644 index 000000000000..681ef873d5b2 --- /dev/null +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/project-selection-panel.tsx @@ -0,0 +1,158 @@ +import { + PROJECT_COLOR_PALETTE, + Project, + ProjectType, +} from '@activepieces/shared'; +import { ColumnDef } from '@tanstack/react-table'; +import { t } from 'i18next'; +import { FolderOpen, Search } from 'lucide-react'; +import { useState } from 'react'; + +import { DataTable, RowDataWithActions } from '@/components/custom/data-table'; +import { InputWithIcon } from '@/components/custom/input-with-icon'; +import { Checkbox } from '@/components/ui/checkbox'; + +import { SelectedOnlyButton } from '../components/selected-only-button'; +import { pageSlice, TablePagination } from '../components/table-pagination'; + +export function ProjectSelectionPanel({ + projects, + selectedIds, + onChange, +}: { + projects: Project[]; + selectedIds: string[]; + onChange: (projectIds: string[]) => void; +}) { + const [search, setSearch] = useState(''); + const [page, setPage] = useState(0); + const [showSelectedOnly, setShowSelectedOnly] = useState(false); + + const filtered = projects + .filter((project) => + project.displayName.toLowerCase().includes(search.trim().toLowerCase()), + ) + .filter((project) => !showSelectedOnly || selectedIds.includes(project.id)); + const { rows, page: currentPage } = pageSlice({ + items: filtered, + page, + pageSize: PAGE_SIZE, + }); + const allRowsSelected = + rows.length > 0 && + rows.every((project) => selectedIds.includes(project.id)); + + const toggleProject = (projectId: string) => { + onChange( + selectedIds.includes(projectId) + ? selectedIds.filter((id) => id !== projectId) + : [...selectedIds, projectId], + ); + }; + const toggleRows = () => { + const rowIds = rows.map((project) => project.id); + onChange( + allRowsSelected + ? selectedIds.filter((id) => !rowIds.includes(id)) + : [...new Set([...selectedIds, ...rowIds])], + ); + }; + + const columns: ColumnDef>[] = [ + { + accessorKey: 'name', + header: () => ( +
+ + {t('Project')} +
+ ), + cell: ({ row }) => ( +
+ + + + {row.original.displayName} + + {row.original.type === ProjectType.PERSONAL && ( + + {t('Personal')} + + )} +
+ ), + }, + ]; + + return ( +
+
+ } + value={search} + onChange={(event) => { + setSearch(event.target.value); + setPage(0); + }} + placeholder={t('Search {count} projects', { count: projects.length })} + className="max-w-xs grow-0" + /> + { + setShowSelectedOnly(!showSelectedOnly); + setPage(0); + }} + /> +
+
+ toggleProject(row.id)} + emptyStateTextTitle={t('No projects found')} + emptyStateTextDescription={ + showSelectedOnly + ? t('No project is selected yet.') + : t('No project matches your search.') + } + emptyStateIcon={ + + } + /> +
+ +
+ ); +} + +export function ProjectSwatch({ project }: { project: Project }) { + const palette = project.icon?.color + ? PROJECT_COLOR_PALETTE[project.icon.color] + : undefined; + return ( + + {project.displayName.charAt(0).toUpperCase()} + + ); +} + +const PAGE_SIZE = 10; diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-credentials.ts b/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-credentials.ts new file mode 100644 index 000000000000..180d143edd00 --- /dev/null +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-credentials.ts @@ -0,0 +1,153 @@ +import { AIProviderName } from '@activepieces/core-utils'; +import { t } from 'i18next'; + +import { AWS_BEDROCK_REGIONS } from '@/features/agents/aws-regions'; + +function fieldsOf({ + provider, +}: { + provider: AIProviderName; +}): CredentialField[] { + return PROVIDER_CREDENTIAL_FIELDS[provider] ?? DEFAULT_CREDENTIAL_FIELDS; +} + +function secretKeysOf({ provider }: { provider: AIProviderName }): string[] { + return fieldsOf({ provider }) + .filter((field) => field.secret) + .map((field) => field.key); +} + +function usesManualModels({ provider }: { provider: AIProviderName }): boolean { + return MANUAL_MODEL_PROVIDERS.includes(provider); +} + +const DEFAULT_CREDENTIAL_FIELDS: CredentialField[] = [ + { + key: 'apiKey', + label: t('API key'), + placeholder: 'sk-************************', + secret: true, + }, +]; + +const PROVIDER_CREDENTIAL_FIELDS: Partial< + Record +> = { + [AIProviderName.AZURE]: [ + { + key: 'apiKey', + label: t('API key'), + placeholder: 'sk-************************', + secret: true, + }, + { + key: 'resourceName', + label: t('Resource name'), + placeholder: 'your-resource-name', + }, + { + key: 'apiVersion', + label: t('API version'), + placeholder: '2024-10-21', + optional: true, + description: t( + 'Leave empty to use the default. Some Azure resources require a different version, e.g. 2023-03-15-preview.', + ), + }, + ], + [AIProviderName.BEDROCK]: [ + { + key: 'accessKeyId', + label: t('AWS access key ID'), + placeholder: 'AKIA************', + secret: true, + }, + { + key: 'secretAccessKey', + label: t('AWS secret access key'), + placeholder: '****************************************', + secret: true, + }, + { + key: 'region', + label: t('AWS region'), + options: AWS_BEDROCK_REGIONS.map((region) => ({ ...region })), + }, + ], + [AIProviderName.CLOUDFLARE_GATEWAY]: [ + { + key: 'apiKey', + label: t('AI Gateway token'), + placeholder: '************************', + secret: true, + }, + { + key: 'accountId', + label: t('Account ID'), + placeholder: 'your-account-id', + }, + { + key: 'gatewayId', + label: t('Gateway ID'), + placeholder: 'your-gateway-id', + }, + { + key: 'vertexRegion', + label: t('Google Vertex project region'), + placeholder: 'global', + optional: true, + }, + { + key: 'vertexProject', + label: t('Google Vertex project ID'), + placeholder: 'project-1234', + optional: true, + }, + ], + [AIProviderName.CUSTOM]: [ + { + key: 'baseUrl', + label: t('Base URL'), + placeholder: 'https://api.example.com/v1', + }, + { + key: 'apiKey', + label: t('API key'), + placeholder: 'sk-************************', + secret: true, + }, + { + key: 'apiKeyHeader', + label: t('API key header'), + placeholder: 'Authorization', + }, + { + key: 'defaultHeaders', + label: t('Custom headers'), + type: 'dictionary', + optional: true, + }, + ], +}; + +const MANUAL_MODEL_PROVIDERS: AIProviderName[] = [ + AIProviderName.CUSTOM, + AIProviderName.CLOUDFLARE_GATEWAY, +]; + +export const providerCredentials = { + fieldsOf, + secretKeysOf, + usesManualModels, +}; + +export type CredentialField = { + key: string; + label: string; + placeholder?: string; + secret?: boolean; + optional?: boolean; + description?: string; + options?: { value: string; label: string }[]; + type?: 'dictionary'; +}; diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-logo.tsx b/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-logo.tsx new file mode 100644 index 000000000000..8a8eb8ba7e25 --- /dev/null +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-logo.tsx @@ -0,0 +1,30 @@ +import { AiProviderInfo } from '@/features/agents'; + +export function ProviderLogo({ + info, + size = 'md', +}: { + info: AiProviderInfo; + size?: 'sm' | 'md'; +}) { + if (size === 'sm') { + return info.logoUrl ? ( + {info.name} + ) : null; + } + return ( +
+ {info.logoUrl && ( + {info.name} + )} +
+ ); +} diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-request.ts b/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-request.ts new file mode 100644 index 000000000000..46a10da5e967 --- /dev/null +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/provider-request.ts @@ -0,0 +1,156 @@ +import { AIProviderName } from '@activepieces/core-utils'; +import { + AIProviderConfig, + AIProviderWithoutSensitiveData, + CreateAIProviderRequest, + ProviderModelConfig, +} from '@activepieces/shared'; + +function buildCreateRequest({ + provider, + displayName, + credentials, + headers, + existingConfig, +}: BuildRequestParams): CreateAIProviderRequest { + const value = (key: string) => (credentials[key] ?? '').trim(); + const optional = (key: string) => { + const trimmed = value(key); + return trimmed.length > 0 ? trimmed : undefined; + }; + const models = existingModelsOf({ existingConfig }); + + switch (provider) { + case AIProviderName.AZURE: + return { + provider, + displayName, + config: { + resourceName: value('resourceName'), + apiVersion: optional('apiVersion'), + }, + auth: { apiKey: value('apiKey') }, + }; + case AIProviderName.BEDROCK: + return { + provider, + displayName, + config: { region: value('region') }, + auth: { + accessKeyId: value('accessKeyId'), + secretAccessKey: value('secretAccessKey'), + }, + }; + case AIProviderName.CLOUDFLARE_GATEWAY: + return { + provider, + displayName, + config: { + accountId: value('accountId'), + gatewayId: value('gatewayId'), + vertexRegion: optional('vertexRegion'), + vertexProject: optional('vertexProject'), + models, + }, + auth: { apiKey: value('apiKey') }, + }; + case AIProviderName.CUSTOM: + return { + provider, + displayName, + config: { + baseUrl: value('baseUrl'), + apiKeyHeader: value('apiKeyHeader'), + defaultHeaders: Object.keys(headers).length > 0 ? headers : undefined, + models, + }, + auth: { apiKey: value('apiKey') }, + }; + case AIProviderName.OPENAI: + return { + provider, + displayName, + config: {}, + auth: { apiKey: value('apiKey') }, + }; + case AIProviderName.ANTHROPIC: + return { + provider, + displayName, + config: {}, + auth: { apiKey: value('apiKey') }, + }; + case AIProviderName.GOOGLE: + return { + provider, + displayName, + config: {}, + auth: { apiKey: value('apiKey') }, + }; + case AIProviderName.OPENROUTER: + return { + provider, + displayName, + config: {}, + auth: { apiKey: value('apiKey') }, + }; + case AIProviderName.MISTRAL: + return { + provider, + displayName, + config: {}, + auth: { apiKey: value('apiKey') }, + }; + default: + throw new Error(`Provider ${provider} cannot be connected manually`); + } +} + +function credentialPrefillOf({ + row, +}: { + row: AIProviderWithoutSensitiveData; +}): Record { + const config: Record = row.config; + return Object.fromEntries( + Object.entries(config) + .filter(([, entry]) => typeof entry === 'string') + .map(([key, entry]) => [key, String(entry)]), + ); +} + +function headersPrefillOf({ + row, +}: { + row: AIProviderWithoutSensitiveData; +}): Record { + if ('defaultHeaders' in row.config && row.config.defaultHeaders) { + return row.config.defaultHeaders; + } + return {}; +} + +function existingModelsOf({ + existingConfig, +}: { + existingConfig?: AIProviderConfig; +}): ProviderModelConfig[] { + if (existingConfig && 'models' in existingConfig) { + return existingConfig.models; + } + return []; +} + +export const providerRequestUtils = { + buildCreateRequest, + credentialPrefillOf, + headersPrefillOf, +}; + +type BuildRequestParams = { + provider: AIProviderName; + displayName: string; + credentials: Record; + headers: Record; + existingConfig?: AIProviderConfig; +}; diff --git a/packages/web/src/app/routes/platform/setup/ai/universal-pieces/ai-provider-card.tsx b/packages/web/src/app/routes/platform/setup/ai/universal-pieces/ai-provider-card.tsx deleted file mode 100644 index f63a32e4285e..000000000000 --- a/packages/web/src/app/routes/platform/setup/ai/universal-pieces/ai-provider-card.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { AIProviderWithoutSensitiveData } from '@activepieces/shared'; -import { t } from 'i18next'; -import { Pencil, Trash } from 'lucide-react'; - -import { ConfirmationDeleteDialog } from '@/components/custom/delete-dialog'; -import { - Item, - ItemContent, - ItemTitle, - ItemDescription, - ItemActions, -} from '@/components/custom/item'; -import { ItemMediaImage } from '@/components/custom/item-media-image'; -import { Button } from '@/components/ui/button'; -import { AiProviderInfo } from '@/features/agents'; - -import { UpsertAIProviderDialog } from './upsert-provider-dialog'; - -const AIProviderCard = ({ - providerInfo, - providerConfig, - onDelete, - onSave, - allowWrite = true, -}: AIProviderCardProps) => { - const logoUrl = providerInfo.logoUrl; - const displayName = providerConfig?.name ?? providerInfo.name; - - return ( - - {logoUrl && } - - {displayName} - {allowWrite && ( - - {t('Configure credentials for {providerName} AI provider.', { - providerName: providerInfo.name, - })} - - )} - - {allowWrite && ( - - - {providerConfig ? ( - - ) : ( - - )} - - {providerConfig && ( - onDelete(providerConfig.id)} - > - - - )} - - )} - - ); -}; - -type AIProviderCardProps = { - providerInfo: AiProviderInfo; - providerConfig?: AIProviderWithoutSensitiveData; - onDelete: (id: string) => Promise; - onSave: () => void; - allowWrite?: boolean; -}; - -AIProviderCard.displayName = 'AIProviderCard'; -export { AIProviderCard }; diff --git a/packages/web/src/app/routes/platform/setup/ai/universal-pieces/model-form-popover.tsx b/packages/web/src/app/routes/platform/setup/ai/universal-pieces/model-form-popover.tsx deleted file mode 100644 index cffbbf9c625a..000000000000 --- a/packages/web/src/app/routes/platform/setup/ai/universal-pieces/model-form-popover.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { AIProviderModelType, ProviderModelConfig } from '@activepieces/shared'; -import { t } from 'i18next'; -import { useState } from 'react'; - -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from '@/components/ui/popover'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; - -type ModelFormPopoverProps = { - initialData?: ProviderModelConfig; - onSubmit: (model: ProviderModelConfig) => void; - children: React.ReactNode; -}; - -const ModelFormPopover = ({ - initialData, - onSubmit, - children, -}: ModelFormPopoverProps) => { - const [open, setOpen] = useState(false); - const defaultModel: ProviderModelConfig = { - modelId: '', - modelName: '', - modelType: AIProviderModelType.TEXT, - }; - - const [model, setModel] = useState( - initialData || defaultModel, - ); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - // so parent form doesn't submit - e.stopPropagation(); - onSubmit(model); - if (!initialData) { - setModel(defaultModel); - } - setOpen(false); - }; - - return ( - - {children} - -
-
-

- {initialData ? t('Edit Model') : t('Add Model')} -

-

- {t('Configure the model settings')} -

-
-
-
- - - setModel({ ...model, modelId: e.target.value }) - } - placeholder="e.g., gpt-4" - required - /> -
- -
- - - setModel({ ...model, modelName: e.target.value }) - } - placeholder="e.g., GPT-4" - required - /> -
- -
- - -
- -
- - -
-
-
-
-
- ); -}; - -ModelFormPopover.displayName = 'ModelFormPopover'; -export { ModelFormPopover }; diff --git a/packages/web/src/app/routes/platform/setup/ai/universal-pieces/upsert-provider-config-form.tsx b/packages/web/src/app/routes/platform/setup/ai/universal-pieces/upsert-provider-config-form.tsx deleted file mode 100644 index 3e0f5f1c4758..000000000000 --- a/packages/web/src/app/routes/platform/setup/ai/universal-pieces/upsert-provider-config-form.tsx +++ /dev/null @@ -1,538 +0,0 @@ -import { AIProviderName } from '@activepieces/core-utils'; -import { - AIProviderModelType, - CreateAIProviderRequest, - ProviderModelConfig, -} from '@activepieces/shared'; -import { t } from 'i18next'; -import { - Plus, - Pencil, - Trash2, - ImageIcon, - TextIcon, - AlertCircle, -} from 'lucide-react'; -import { useState } from 'react'; -import { UseFormReturn, useFieldArray } from 'react-hook-form'; - -import { DictionaryInput } from '@/components/custom/dictionary-input'; -import { Button } from '@/components/ui/button'; -import { - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@/components/ui/tooltip'; -import { AWS_BEDROCK_REGIONS } from '@/features/agents/aws-regions'; - -import { ModelFormPopover } from './model-form-popover'; - -type UpsertProviderConfigFormProps = { - form: UseFormReturn; - provider: AIProviderName; - apiKeyRequired?: boolean; - isLoading?: boolean; - isEditMode?: boolean; -}; - -export const UpsertProviderConfigForm = ({ - form, - provider, - apiKeyRequired = true, - isLoading, - isEditMode = false, -}: UpsertProviderConfigFormProps) => { - const { fields, append, remove, update } = useFieldArray({ - control: form.control, - name: 'config.models', - }); - - const [showApiKeyInput, setShowApiKeyInput] = useState(!isEditMode); - const [showBedrockAuthInputs, setShowBedrockAuthInputs] = useState( - !isEditMode, - ); - - return ( -
- {provider !== AIProviderName.BEDROCK && ( - ( - -
- - {provider === AIProviderName.CLOUDFLARE_GATEWAY - ? t('AI Gateway Token') - : t('API Key')} - - {!showApiKeyInput && ( - - )} -
- {showApiKeyInput && ( - - - - )} - -
- )} - /> - )} - - {provider === AIProviderName.AZURE && ( - <> - ( - - - {t('Resource Name')} - - - - - - - )} - /> - - ( - - {t('API Version')} - - - - - {t( - 'Optional. Leave empty to use the default. Some Azure resources require a different version, e.g. 2023-03-15-preview.', - )} - - - - )} - /> - - )} - - {provider === AIProviderName.CLOUDFLARE_GATEWAY && ( - <> - ( - - {t('Account ID')} - - - - - - )} - /> - - ( - - {t('Gateway ID')} - - - - - - )} - /> - ( - - - {t('Google Vertex Project Region')} - - - - - - - - )} - /> - ( - - - {t('Google Vertex Project ID')} - - - - - - - - )} - /> - - )} - - {provider === AIProviderName.BEDROCK && ( - <> - {!showBedrockAuthInputs && ( -
- - -
- )} - - {showBedrockAuthInputs && ( - <> - ( - - - {t('AWS Access Key ID')} - - - - - - - )} - /> - - ( - - - {t('AWS Secret Access Key')} - - - - - - - )} - /> - - )} - - ( - - {t('AWS Region')} - - - - )} - /> - - )} - - {provider === AIProviderName.CUSTOM && ( - <> - ( - - {t('Base URL')} - - - - - - )} - /> - - ( - - - {t('API Key Header')} - - - - - - - )} - /> - - ( -
- - -
- )} - /> - - )} - - {[AIProviderName.CUSTOM, AIProviderName.CLOUDFLARE_GATEWAY].includes( - provider, - ) && ( -
-
- - append(model)}> - - -
- - {fields.length === 0 ? ( -
- - - -

- {t( - 'This provider does not support listing models via API, please add models manually.', - )} -

-
- ) : ( -
- {fields.map((field, index) => ( - update(index, model)} - onRemove={() => remove(index)} - /> - ))} -
- )} -
- )} -
- ); -}; - -type ProviderConfigModelItemProps = { - model: ProviderModelConfig; - isLoading?: boolean; - onUpdate: (model: ProviderModelConfig) => void; - onRemove: () => void; -}; - -const ProviderConfigModelItem = ({ - model, - isLoading, - onUpdate, - onRemove, -}: ProviderConfigModelItemProps) => { - return ( -
-
-
- -
-

{model.modelName}

-

{model.modelId}

-
-
-
-
- onUpdate(updatedModel)} - > - - - -
-
- ); -}; - -const ModelTypeIcon = ({ modelType }: { modelType: AIProviderModelType }) => { - return ( - - - {modelType === AIProviderModelType.IMAGE ? ( - - ) : ( - - )} - - - {modelType === AIProviderModelType.IMAGE - ? t('Image Model') - : t('Text Model')} - - - ); -}; diff --git a/packages/web/src/app/routes/platform/setup/ai/universal-pieces/upsert-provider-dialog.tsx b/packages/web/src/app/routes/platform/setup/ai/universal-pieces/upsert-provider-dialog.tsx deleted file mode 100644 index 4d6d77beafd4..000000000000 --- a/packages/web/src/app/routes/platform/setup/ai/universal-pieces/upsert-provider-dialog.tsx +++ /dev/null @@ -1,344 +0,0 @@ -import { AIProviderName, isNil } from '@activepieces/core-utils'; -import { - AIProviderConfig, - AnthropicProviderAuthConfig, - AnthropicProviderConfig, - AzureProviderAuthConfig, - AzureProviderConfig, - BedrockProviderAuthConfig, - BedrockProviderConfig, - CloudflareGatewayProviderAuthConfig, - CloudflareGatewayProviderConfig, - CreateAIProviderRequest, - GoogleProviderAuthConfig, - GoogleProviderConfig, - OpenAICompatibleProviderAuthConfig, - OpenAICompatibleProviderConfig, - OpenAIProviderAuthConfig, - OpenAIProviderConfig, - UpdateAIProviderRequest, -} from '@activepieces/shared'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation } from '@tanstack/react-query'; -import { AxiosError } from 'axios'; -import { t } from 'i18next'; -import { useMemo, useState } from 'react'; -import { - FieldErrors, - Resolver, - ResolverOptions, - ResolverResult, - useForm, -} from 'react-hook-form'; -import { z } from 'zod'; - -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@/components/ui/dialog'; -import { - Form, - FormField, - FormItem, - FormMessage, - FormLabel, - FormControl, -} from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { SUPPORTED_AI_PROVIDERS } from '@/features/agents'; -import { - aiProviderApi, - hasAnyAuthFieldFilled, -} from '@/features/platform-admin'; - -import { ApMarkdown } from '../../../../../../components/custom/markdown'; - -import { UpsertProviderConfigForm } from './upsert-provider-config-form'; - -type UpsertAIProviderDialogProps = { - provider: AIProviderName; - providerId?: string; - config?: AIProviderConfig; - children: React.ReactNode; - onSave: () => void; - defaultDisplayName?: string; -}; - -export const UpsertAIProviderDialog = (params: UpsertAIProviderDialogProps) => { - const [open, setOpen] = useState(false); - - return ( - { - setOpen(val); - }} - > - - - ); -}; - -export const UpsertAIProviderDialogContent = ({ - children, - onSave, - config, - provider, - providerId, - defaultDisplayName = '', - setOpen, -}: UpsertAIProviderDialogProps & { setOpen: (val: boolean) => void }) => { - const currentProviderDef = useMemo( - () => SUPPORTED_AI_PROVIDERS.find((p) => p.provider === provider)!, - [provider], - ); - - const form = useForm({ - resolver: (( - values: CreateAIProviderRequest, - context: unknown, - options: ResolverOptions, - ) => { - const originalResolve = zodResolver( - createFormSchema(provider, !isNil(providerId)), - ) as unknown as ( - values: CreateAIProviderRequest, - context: unknown, - options: ResolverOptions, - ) => Promise>; - if (values.provider === AIProviderName.CLOUDFLARE_GATEWAY) { - if ( - values.config.models.some((m) => - m.modelId.includes('google-vertex-ai'), - ) - ) { - const errors: FieldErrors = {}; - if ( - isNil(values.config.vertexProject) || - values.config.vertexProject.trim().length === 0 - ) { - errors.config = { - vertexProject: { - message: 'Required when using Google Vertex AI models', - type: 'required', - }, - }; - } - if ( - isNil(values.config.vertexRegion) || - values.config.vertexRegion.trim().length === 0 - ) { - errors.config = { - ...errors.config, - vertexRegion: { - message: 'Required when using Google Vertex AI models', - type: 'required', - }, - }; - } - if (Object.keys(errors).length > 0) { - return { - errors, - values: {} as Record, - }; - } - } - } - return originalResolve(values, context, options); - }) as Resolver, - defaultValues: { - provider, - displayName: defaultDisplayName, - config: config, - } as CreateAIProviderRequest, - }); - - const { mutate, isPending } = useMutation({ - mutationFn: (data: CreateAIProviderRequest): Promise => { - if (providerId) { - const updateData: UpdateAIProviderRequest = { - displayName: data.displayName, - config: data.config, - ...(hasAnyAuthFieldFilled(data.auth) ? { auth: data.auth } : {}), - }; - return aiProviderApi.update(providerId, updateData); - } else { - return aiProviderApi.upsert(data); - } - }, - onSuccess: () => { - setOpen(false); - onSave(); - }, - onError: ( - error: AxiosError<{ message?: string; params?: { message: string } }>, - ) => { - const data = error.response?.data; - - form.setError('root.serverError', { - type: 'manual', - message: - data?.message ?? data?.params?.message ?? JSON.stringify(error), - }); - }, - }); - - const handleSave = (data: CreateAIProviderRequest) => { - mutate(data); - }; - - return ( - <> - {children} - - - - {providerId ? t('Update AI Provider') : t('Add AI Provider')} - - - -
- - -
- ( - - )} - /> - - {currentProviderDef.markdown && ( -
- -
- )} - - - - {form.formState.errors.root?.serverError && ( - - {form.formState.errors.root.serverError.message} - - )} -
-
- - - - - -
- -
- - ); -}; - -const OptionalAuthSchema = z - .object({ - apiKey: z.string().optional(), - accessKeyId: z.string().optional(), - secretAccessKey: z.string().optional(), - }) - .optional(); - -const createFormSchema = (provider: AIProviderName, editMode: boolean) => { - if (provider === AIProviderName.AZURE) { - return z.object({ - displayName: z.string().min(1), - provider: z.literal(AIProviderName.AZURE), - config: AzureProviderConfig, - auth: editMode ? OptionalAuthSchema : AzureProviderAuthConfig, - }); - } - if (provider === AIProviderName.CLOUDFLARE_GATEWAY) { - return z.object({ - displayName: z.string().min(1), - provider: z.literal(AIProviderName.CLOUDFLARE_GATEWAY), - config: CloudflareGatewayProviderConfig, - auth: editMode ? OptionalAuthSchema : CloudflareGatewayProviderAuthConfig, - }); - } - if (provider === AIProviderName.CUSTOM) { - return z.object({ - displayName: z.string().min(1), - provider: z.literal(AIProviderName.CUSTOM), - config: OpenAICompatibleProviderConfig, - auth: editMode ? OptionalAuthSchema : OpenAICompatibleProviderAuthConfig, - }); - } - if (provider === AIProviderName.BEDROCK) { - return z.object({ - displayName: z.string().min(1), - provider: z.literal(AIProviderName.BEDROCK), - config: BedrockProviderConfig, - auth: editMode ? OptionalAuthSchema : BedrockProviderAuthConfig, - }); - } - const authSchema = z.union([ - AnthropicProviderAuthConfig, - GoogleProviderAuthConfig, - OpenAIProviderAuthConfig, - ]); - return z.object({ - displayName: z.string().min(1), - provider: z.literal(provider), - auth: editMode ? OptionalAuthSchema : authSchema, - config: z.union([ - AnthropicProviderConfig, - GoogleProviderConfig, - OpenAIProviderConfig, - ]), - }); -}; diff --git a/packages/web/src/components/custom/input-with-icon.tsx b/packages/web/src/components/custom/input-with-icon.tsx index 1750db3819a2..cef53bcdbb2f 100644 --- a/packages/web/src/components/custom/input-with-icon.tsx +++ b/packages/web/src/components/custom/input-with-icon.tsx @@ -10,9 +10,9 @@ const InputWithIcon = React.forwardRef< React.InputHTMLAttributes & { icon: React.ReactNode; } ->(({ className, ...props }, ref) => ( +>(({ className, icon, ...props }, ref) => (
- {props.icon} + {icon} { - return api.post('/v1/ai-providers', request); + upsert(request: CreateAIProviderRequest) { + return api.post( + '/v1/ai-providers', + request, + ); }, update(providerId: string, request: UpdateAIProviderRequest): Promise { return api.post(`/v1/ai-providers/${providerId}`, request); }, - delete(provider: string): Promise { - return api.delete(`/v1/ai-providers/${provider}`); + delete(providerId: string): Promise { + return api.delete(`/v1/ai-providers/${providerId}`); }, }; diff --git a/packages/web/src/features/platform-admin/hooks/ai-provider-hooks.ts b/packages/web/src/features/platform-admin/hooks/ai-provider-hooks.ts index 890152c06e10..aad0ab79672a 100644 --- a/packages/web/src/features/platform-admin/hooks/ai-provider-hooks.ts +++ b/packages/web/src/features/platform-admin/hooks/ai-provider-hooks.ts @@ -1,6 +1,7 @@ import { isNil } from '@activepieces/core-utils'; import { AIProviderAuthConfig, + AIProviderWithoutSensitiveData, CreateAIProviderRequest, UpdateAIProviderRequest, } from '@activepieces/shared'; @@ -15,6 +16,10 @@ export const aiProviderKeys = { configs: ['ai-provider-configs'] as const, forProject: (projectId: string | null) => ['ai-providers', projectId] as const, + configModels: (configId?: string) => + configId === undefined + ? (['ai-provider-config-models'] as const) + : (['ai-provider-config-models', configId] as const), }; export const aiProviderQueries = { @@ -22,6 +27,7 @@ export const aiProviderQueries = { useQuery({ queryKey: aiProviderKeys.configs, queryFn: () => aiProviderApi.listConfigs(), + meta: { showErrorDialog: true, loadSubsetOptions: {} }, }), useProjectAiProviders: () => { const projectId = authenticationSession.getProjectId(); @@ -42,10 +48,31 @@ export const aiProviderQueries = { export const aiProviderMutations = { useDeleteAiProvider: ({ onSuccess }: { onSuccess: () => void }) => { return useMutation({ - mutationFn: (provider: string) => aiProviderApi.delete(provider), + mutationFn: (providerId: string) => aiProviderApi.delete(providerId), onSuccess, }); }, + useUpdateAiProvider: ({ + onSuccess, + onError, + }: { + onSuccess: () => void; + onError?: ( + error: AxiosError<{ message?: string; params?: { message: string } }>, + ) => void; + }) => { + return useMutation({ + mutationFn: ({ + providerId, + request, + }: { + providerId: string; + request: UpdateAIProviderRequest; + }) => aiProviderApi.update(providerId, request), + onSuccess, + onError, + }); + }, useToggleChatProvider: ({ onSuccess }: { onSuccess: () => void }) => { return useMutation({ mutationFn: ({ @@ -65,17 +92,19 @@ export const aiProviderMutations = { onError, }: UpsertAiProviderOptions) => { return useMutation({ - mutationFn: (data: CreateAIProviderRequest): Promise => { + mutationFn: async ( + data: CreateAIProviderRequest, + ): Promise => { if (providerId) { const updateData: UpdateAIProviderRequest = { displayName: data.displayName, config: data.config, ...(hasAnyAuthFieldFilled(data.auth) ? { auth: data.auth } : {}), }; - return aiProviderApi.update(providerId, updateData); - } else { - return aiProviderApi.upsert(data); + await aiProviderApi.update(providerId, updateData); + return undefined; } + return aiProviderApi.upsert(data); }, onSuccess, onError, @@ -96,7 +125,7 @@ export const hasAnyAuthFieldFilled = ( type UpsertAiProviderOptions = { providerId?: string; - onSuccess: () => void; + onSuccess: (created?: AIProviderWithoutSensitiveData) => void; onError: ( error: AxiosError<{ message?: string; params?: { message: string } }>, ) => void; From 0beb39005023b5ac6d25a05d20691add3ce94734 Mon Sep 17 00:00:00 2001 From: Louai Boumediene <92324961+Louai-Zokerburg@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:47:25 +0100 Subject: [PATCH 4/5] feat(ai-providers): pick the provider key a step or agent runs on (#14942) --- .../knowledge/ai-intelligence/ai-providers.md | 9 +- ...er-key-and-omitting-one-can-only-narrow.md | 70 ++++++ .../engineering/ci-pr-review-hygiene.md | 1 + .../engineering/server-module-anatomy.md | 2 +- .../engineering/web-feature-anatomy.md | 1 + bun.lock | 4 +- .../execution/src/lib/workers/job-data.ts | 1 + .../src/lib/workers/worker-contract.ts | 5 + packages/core/piece-types/src/lib/agents.ts | 1 + .../core/piece-types/src/lib/ai-providers.ts | 12 + packages/core/shared/package.json | 2 +- .../core/shared/src/lib/ee/agent/agent.ts | 1 + .../src/lib/management/ai-providers/index.ts | 8 + packages/pieces/community/ai/package.json | 2 +- .../ai/src/lib/actions/agents/run-agent.ts | 1 + .../src/lib/actions/image/generate-image.ts | 14 +- .../ai/src/lib/actions/text/ask-ai.ts | 11 +- .../ai/src/lib/actions/text/summarize-text.ts | 9 +- .../src/lib/actions/utility/classify-text.ts | 9 +- .../utility/extract-structured-data.ts | 9 +- .../community/ai/src/lib/common/ai-sdk.ts | 7 +- .../community/ai/src/lib/common/props.ts | 103 +++++++-- packages/pieces/framework/src/index.ts | 1 + .../api/src/app/ai/ai-provider-controller.ts | 8 +- .../api/src/app/ai/ai-provider-service.ts | 83 +++++-- .../ee/agent/agent-conversation-controller.ts | 2 + .../api/src/app/ee/agent/agent-helpers.ts | 23 +- .../src/app/ee/agent/agent-rpc-handlers.ts | 8 +- .../src/app/ee/agent/agent-run-controller.ts | 8 +- .../ce/ai-provider/ai-provider.test.ts | 207 +++++++++++++++++- .../agent/agent-fast-model-provider.test.ts | 7 + .../jobs/ee/agent/execute-agent-run.ts | 11 +- .../web/public/locales/en/translation.json | 3 +- .../step-settings/agent-settings/index.tsx | 2 + .../web/src/app/routes/agents/id/index.tsx | 6 +- .../platform/setup/ai/providers-tab/index.tsx | 9 + .../web/src/features/agents/ai-model/hooks.ts | 5 +- .../src/features/agents/ai-model/index.tsx | 126 +++++++---- .../platform-admin/api/ai-provider-api.ts | 7 +- 39 files changed, 651 insertions(+), 147 deletions(-) create mode 100644 brain/knowledge/decisions/000030-a-step-may-pin-an-ai-provider-key-and-omitting-one-can-only-narrow.md diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md index 0139641d4e61..d4dc1880d5d3 100644 --- a/brain/knowledge/ai-intelligence/ai-providers.md +++ b/brain/knowledge/ai-intelligence/ai-providers.md @@ -30,19 +30,22 @@ Lets platform admins configure one or more LLM backends for AI pieces in flows. ### Gotchas -- **Multi-key resolution is deterministic, not configurable.** When several keys of one provider are eligible for a project, `resolveEligibleRow` picks by most specific `projectScope` (`selected` > `except` > `all`), newest `created` breaking ties — there is no priority/default field (decision: [providers-redesign-before-routing](../decisions/providers-redesign-before-routing.md)). The ACTIVEPIECES provider stays a singleton — `create()` rejects it (`aiProvider.activepiecesIsManaged`). +- **Multi-key resolution is deterministic, not configurable.** When several keys of one provider are eligible for a project, `resolveEligibleRow` picks by most specific `projectScope` (`selected` > `except` > `all`), newest `created` breaking ties — there is no priority/default field (decision: [providers-redesign-before-routing](../decisions/providers-redesign-before-routing.md)). The ACTIVEPIECES provider stays a singleton — `create()` rejects it (`aiProvider.activepiecesIsManaged`). That ranking is the *fallback*: a step or agent may also pin a key outright (decision: [000030](../decisions/000030-a-step-may-pin-an-ai-provider-key-and-omitting-one-can-only-narrow.md)), in which case `resolveRowForScope` serves that row after checking it is eligible for the caller's project. - **Every resolver takes a required `ProviderScope`; there is no "no project" default** (decision: [000027](../decisions/000027-ai-provider-resolution-takes-a-required-scope-and-splits-reads-by-trust-level.md)). `getConfigOrThrow` / `getChatProvider` / `getChatProviderName` / `listModels` all take `scope: { type: 'project', projectId } | { type: 'platform' }`. This is deliberate and load-bearing: the first cut made `projectId` optional and treated its absence as "every key is eligible", so each new call site that forgot to thread it silently bypassed project scoping — the agent piece/knowledge-base tool handlers, the chat model picker, and the `configId` model lookup each reopened the same hole in turn. Making the argument required turns an omission into a compile error, and `{ type: 'platform' }` at a call site is a reviewable claim rather than an accident. Only three consumers are legitimately platform-wide: the tool-search embedder, chat memory extraction, and the managed-ACTIVEPIECES singleton. - **The last fail-open scope lived in the helper that *builds* the scope, not in the resolvers.** Chat resolves the project a turn runs in (`selectRunProject`) before it resolves a credential, and that project is nullable — a user who can no longer see any project gets `null`. The first cut turned `null` into `{ type: 'platform' }`, which hands the run every key on the platform and is exactly the hole a required `ProviderScope` was meant to close. `agentHelpers.runScopeOrThrow` refuses the run instead; the analytics and billing paths, which want the provider's *name* and not a credential, take the nullable project id themselves (`resolveChatProviderName`) and report no provider for a conversation that has none. Rule of thumb: a nullable id feeding a scope constructor is the shape to look for, not a missing argument. - **The managed ACTIVEPIECES row is a singleton the database enforces, not the code.** `create()` rejecting it only covers the admin route; the row is *also* auto-provisioned from `listVisibleRows` on every list, so two concurrent `GET /v1/ai-providers` both missed the `existsBy` and inserted once the unique `(platformId, provider)` index was dropped. `idx_ai_provider_platform_id_managed` — unique on `(platformId)` `WHERE provider = 'activepieces'` — keeps it single, and the insert carries `ON CONFLICT DO NOTHING` so the losing racer is a no-op rather than a 500. Preferred over a distributed lock because the invariant holds even for a writer that never takes one. -- **Reads are split by trust level, and mixing them back together is how the scope bypass keeps returning.** Runtime/project reads are `GET /v1/ai-providers?projectId=` (deduped to one entry per provider, `{provider, name, enabledForChat}` only) and `GET /v1/ai-providers/:provider/models?projectId=` — both `securityAccess.project([USER, ENGINE], undefined, QUERY)`, so an ENGINE principal supplies its own project and a USER must name a project it belongs to, and both always apply the resolved key's `modelScope` allow-list. Admin reads are `GET /v1/ai-providers/configs` and `GET /v1/ai-providers/configs/:id/models` — `platformAdminOnly`, addressing an exact row and returning the *unfiltered* model list because that is what an admin picks the allow-list from. Never widen the project routes to accept a config id, and never hand `projectIds`/`modelIds` to a project caller: those are other projects' identifiers. +- **Reads are split by trust level, and mixing them back together is how the scope bypass keeps returning.** Runtime/project reads are `GET /v1/ai-providers?projectId=` (deduped to one entry per provider, `{provider, name, enabledForChat}` only) and `GET /v1/ai-providers/:provider/models?projectId=` — both `securityAccess.project([USER, ENGINE], undefined, QUERY)`, so an ENGINE principal supplies its own project and a USER must name a project it belongs to, and both always apply the resolved key's `modelScope` allow-list. Admin reads are `GET /v1/ai-providers/configs` and `GET /v1/ai-providers/configs/:id/models` — `platformAdminOnly`, addressing an exact row and returning the *unfiltered* model list because that is what an admin picks the allow-list from. Never widen the project routes to accept a config id, and never hand `projectIds`/`modelIds` to a project caller: those are other projects' identifiers. That rule is pinned by a guard test asserting the *exact* key set of a project entry (and of each item in its `keys` array), so adding a field to the project-facing response is meant to fail `ai-provider.test.ts` until someone states the field is safe to expose. +- **Two keys may legitimately hold the same secret, and you could not detect it anyway.** One API key scoped to two rows with different `modelScope`/`projectScope` allow-lists is a supported setup, not a mistake. Blocking it is also impractical: `encryptUtils.encryptObject` uses a fresh random IV per write, so the same credential stores as different ciphertext every time — deduping would need a separate HMAC column. Key *names* are the thing worth constraining, since a picker showing two rows called "Anthropic key" is unpickable. - **`mockAndSaveAIProvider` uses `save`, not upsert** — the old `(platformId, provider)` ON CONFLICT target died with the unique index; seeding the same provider twice now creates two keys, which is usually what a test wants. - ACTIVEPIECES auto-provision needs `OPENROUTER_PROVISION_KEY` env var set AND `aiCreditsEnabled` true. +- **Adding a provider is a leaf change, and the credential fields are the only part that is not.** A new vendor touches six places: the `AIProviderName` enum (`packages/core/utils/.../permission.ts`), its auth/config schemas plus the two unions and `ProviderConfigUnion` in `packages/core/shared/.../management/ai-providers/index.ts` (all in the per-provider region, well above the generic request/response schemas at the bottom), a strategy file registered in `ai/providers/index.ts`, the model factory switch, name/logo/markdown in `packages/web/src/features/agents/ai-providers.ts`, and translation keys. None of that is the credential form: extra fields beyond `apiKey` (Azure's `resourceName`, Bedrock's region) are declared in **one** file — `PROVIDER_CREDENTIAL_FIELDS` in `.../setup/ai/providers-tab/provider-credentials.ts`, which falls back to `DEFAULT_CREDENTIAL_FIELDS` (a single `apiKey`) for any provider with no entry, so a plain API-key vendor needs no UI work at all. That file replaced the deleted `universal-pieces/upsert-provider-config-form.tsx` in the multi-key redesign, so a provider authored against an older branch loses its custom fields on merge **silently** — git resolves delete-vs-modify by taking the deletion, no conflict marker, and the provider just becomes unconfigurable in the admin UI. A vendor with no `/models` endpoint also belongs in `MANUAL_MODEL_PROVIDERS` in that same file (`CUSTOM`, `CLOUDFLARE_GATEWAY`) so the admin enters model ids by hand. Everything else about a provider is orthogonal to multi-key: that is a table-level change (drop `UNIQUE (platformId, provider)`, add the four scope columns, keep a unique partial index for `activepieces` only), so a provider inherits multi-key with no provider-side code, and the admin providers-tab enumerates `SUPPORTED_AI_PROVIDERS` from `packages/web/src/features/agents/ai-providers.ts` rather than a catalog of its own, so a new vendor appears there on its own. When basing provider work off a branch that predates the redesign, re-check `provider-credentials.ts` after the merge. - **A failed `enrichWithKeysIfNeeded()` is self-sustaining, and it takes chat down with it.** `createKey` runs on the chat hot path — `chatHelpers.resolveChatProvider` → `getChatProvider` calls it whenever the platform's managed ACTIVEPIECES row has no `apiKey` — and the `save` happens *after* the OpenRouter call, so a failure persists nothing and the next chat turn calls `createKey` again. There is also no distributed lock or cache, so concurrent turns for one platform each mint a live key and only the last is saved; the orphans keep spending provisioning quota. Seen in prod 2026-07-30: `keys-modify-api-rpd-v2` 429 (OpenRouter's key create/modify bucket, 10k/day on the provision key — a *separate* limit from inference), which killed every chat turn for the affected platform in `getChatConfig` before the first token, with no recovery until the bucket reset at 00:00 UTC. - **`openrouter-api.ts` uses raw `fetch`** — no timeout, no retry, no `tryCatch`, and it bypasses the repo's `safeHttp` rule for outbound HTTP in `packages/server/api`. A `getKey` 408 from OpenRouter escapes the admin `increaseAiCredits` path as an unhandled rejection. - **Chat model tiers are Activepieces-only.** `ACTIVEPIECES_CHAT_TIERS` (`fast`/`smart`/`premium`, shown as Fast/Expert/Heavy) hold OpenRouter-shaped Anthropic ids, so they only mean anything for the ACTIVEPIECES and OPENROUTER chat providers. Any provider that declares `ALLOWED_CHAT_MODELS_BY_PROVIDER` (openai, anthropic, google) picks a real model id from that list instead. Naively stripping the tier's vendor prefix for every provider is what once sent `claude-haiku-4-5` to OpenAI and broke every message. +- **A short model list in the key's picker is the vendor's catalog, not a truncation.** `listModels` returns whatever the provider's own `/models` endpoint gives and filters nothing except the key's `modelScope` allow-list. Anthropic ships roughly a dozen models, OpenAI ~80 (mostly embeddings/tts/whisper), while OpenRouter is an aggregator and returns 400+ from every vendor it proxies — so the counts differ by an order of magnitude by design. Anthropic pages at 20 by default, which is why its request pins `?limit=1000`. If the list shows exactly three Claude models, that is the *chat* dropdown reading the curated `ANTHROPIC_CHAT_MODELS`, a different surface from the admin picker. - **Read the chat model list through `aiProviderUtils.getCuratedChatModels({ provider })`.** It is the one accessor the server resolver (`chatHelpers.resolveModelIdForProvider`) and the chat dropdown share, so the two cannot drift; it returns `{ id, label }` or `undefined` — never an empty list, so callers may treat a result as non-empty. Labels come from the hardcoded `CHAT_MODEL_LABELS` (falling back to the id) rather than `AIProviderModel.name`, because the live `listModels` response cannot supply one for every provider: anthropic returns `display_name` and google `displayName`, but OpenAI's `/v1/models` returns ids only. - **`conversation.modelName` carries either a tier id or a real model id** — it is a free string with no discriminator. A legacy tier id resolves to the tier's equivalent model when the provider ships it, else the provider's first curated model, so old conversations keep working after a provider switch. Note `premium` maps to opus 4.8, which the native anthropic list does not carry, so a legacy `premium` on anthropic lands on Sonnet. -- Chat-provider resolution is **first `enabledForChat` row wins**, *not* "prefer ACTIVEPIECES". All three branches of `findAvailableChatProviderRow` reduce to that: when the managed provider is visible the function returns `chatProviders[0]` whatever it is, so a platform with `[openai, activepieces]` both chat-enabled resolves to **openai**. The client mirror is `aiProviderQueries.useChatProvider()` (`providers.find((p) => p.enabledForChat)`) — always read the resolved chat provider through it rather than re-deriving the rule inline. Both sides lean on an unordered `findBy()`: there is no `ORDER BY`, so "first" is not guaranteed stable when several providers are chat-enabled. +- Chat-provider resolution is **first `enabledForChat` row wins**, *not* "prefer ACTIVEPIECES". All three branches of `findAvailableChatProviderRow` reduce to that: when the managed provider is visible the function returns `chatProviders[0]` whatever it is, so a platform with `[openai, activepieces]` both chat-enabled resolves to **openai**. The client mirror is `aiProviderQueries.useChatProvider()` (`providers.find((p) => p.enabledForChat)`) — always read the resolved chat provider through it rather than re-deriving the rule inline. **`enabledForChat` on a deduped project entry must be an OR across that provider's keys, never the top-ranked key's flag** — ranking (`selected` > `except` > `all`, newest first) and chat selection answer different questions, so reading `rows[0].enabledForChat` makes the client report "no provider configured" whenever the chat-enabled key is not the ranking winner, while the server (`findAvailableChatProviderRow`, which queries `enabledForChat: true` directly) happily serves the turn. Invisible with one key per provider. Both sides lean on an unordered `findBy()`: there is no `ORDER BY`, so "first" is not guaranteed stable when several providers are chat-enabled. - Listing providers is **not a pure read**: both `listConfigs` and `listForProject` go through `listVisibleRows`, which inserts the ACTIVEPIECES provider row when `aiCreditsEnabled && !activepiecesExists`. A `GET /v1/ai-providers` can therefore create a row. It also applies the hidden-provider filter (`plan.embeddingEnabled` hides the managed provider), which is why the client can trust its output without re-checking flags. - Managed-chat credit cost per turn is `tier.creditWeight + billableToolCalls` (`fast` 2 / `smart` 10 / `premium` 20, from `ACTIVEPIECES_CHAT_TIERS`), and BYOK collapses the weight to `CHAT_BYOK_CREDIT_WEIGHT` (1) regardless of tier — so never show tier weights to a BYOK platform. `CHAT_BYOK_CREDIT_WEIGHT` / `CHAT_CREDITS_PER_TOOL_CALL` live in `@activepieces/shared` so the billed number and the number shown in the model picker come from one place. **Every credit-cost surface must render from `ACTIVEPIECES_CHAT_TIERS`, never from a local copy** — the billing Credits FAQ (`credits-info-dialog.tsx`) hardcoded its own 2/10/20 table and drifted the moment the tiers were relabelled, so it still shows Fast/Smart/Premium against the real Fast/Expert/Heavy. - Azure model listing (`azureProvider.listModels`) is pinned to the retired data-plane api-version `2023-03-15-preview` — newer versions 404 and break `validateConnection`; the configured `apiVersion` only affects inference via `@ai-sdk/azure` (GIT-1310). diff --git a/brain/knowledge/decisions/000030-a-step-may-pin-an-ai-provider-key-and-omitting-one-can-only-narrow.md b/brain/knowledge/decisions/000030-a-step-may-pin-an-ai-provider-key-and-omitting-one-can-only-narrow.md new file mode 100644 index 000000000000..a91d4cf131cf --- /dev/null +++ b/brain/knowledge/decisions/000030-a-step-may-pin-an-ai-provider-key-and-omitting-one-can-only-narrow.md @@ -0,0 +1,70 @@ +--- +icon: 📌 +status: accepted +--- + +# A step may pin an AI provider key, and omitting one can only narrow + +## Decision + +An AI step and an agent may name the exact `ai_provider` row they run on. The picker is **one flat +list of keys**, not a provider list plus a key list: each option is a key, labelled +`Anthropic: key 1` when its provider holds more than one and plainly `Anthropic` when it holds one. +The AI piece's `provider` prop therefore stores `{ provider, configId }` rather than a bare enum, and +an agent stores `AgentConfig.providerConfigId` beside its `provider`. Every read routes +through one resolver, `resolveRowForScope({ platformId, provider, scope, configId })` — with no +`configId` it falls back to the deterministic ranking (`selected` > `except` > `all`, newest first); +with one it serves that row only after checking it belongs to the platform, matches the named +provider, and passes `rowAllowsScope` for the caller's project. Key names are unique per +`(platformId, provider)`, checked in the service. + +## Context + +The multi-key redesign gave a platform several keys per provider, but `listForProject` deduped them to +one entry and a step stored only an `AIProviderName`, so the builder showed a single row labelled with +the winning key's display name. An admin who configured "Anthropic key 1" and "Anthropic key 2" saw +one of them and reasonably read the other as lost; two keys both scoped to *all* projects meant the +newer one won everywhere and the older never executed. + +## Why + +Scoping alone cannot express "this step, that key" — it can only express "this project, that key", +and a project routinely wants a cheap key for one step and a production key for another. + +The obvious risk was reopening the fail-open hole that decision +[000027](000027-ai-provider-resolution-takes-a-required-scope-and-splits-reads-by-trust-level.md) +closed, since `configId` is optional by nature. It does not, and the asymmetry is the point: a +forgotten `configId` degrades to the deterministic winner, which is by construction already eligible +for that scope, so an omission narrows-or-equals and never widens. `scope` stays required and is +still what authorizes the row. + +The first cut used two dropdowns — Provider, then an optional Configuration — to keep `provider` a +bare enum, since a flat list makes the stored value stop being a provider name and every action that +branches on it (web search, image capability, `getEffectiveProviderAndModel`) has to read it back +out. That was rejected on use: an admin who configures three keys expects to *see* three lines, and a +vendor row that silently resolves one of them is the confusion the redesign set out to fix. The +parsing risk is contained by making the value an object rather than a composite string and by giving +the piece exactly one accessor, `aiProviderSelection.resolveOrThrow`, which also maps a legacy plain +string to `{ provider, configId: undefined }` so flows saved before the change keep resolving +automatically. A per-key *route* was still rejected: `/:provider/config?configId=` already carries +both, and a second route would split the trust-level check that decision 000027 consolidated. + +Names are constrained because a picker showing two rows called "Anthropic key" is unusable. The check +lives in the service rather than a unique index: an admin racing themselves is not a real threat, and +the index costs a migration and a Postgres error to map back to a form message. Duplicate +*credentials* stay legal — one secret with two allow-lists is a supported setup, and the random-IV +encryption makes duplicates undetectable anyway. + +## Consequences + +`ProjectAIProvider` now carries `keys: [{ id, name }]` for the project-facing list, and its `name` is +the vendor label (`aiProviders[provider].name`) rather than a key's display name; the list itself +stays deduped, so `ap_list_ai_models` and the agent selector are unaffected. `GetProviderConfigResponse` +returns `configId`, which the chat worker echoes back on piece and knowledge-base tool calls, so every +call in one turn runs on the same key instead of re-ranking mid-run. + +A pinned key becomes a hard dependency: delete it, or scope its project away, and the step fails at run +time rather than sliding to another key. That is the intended trade for an explicit choice, but it is +the reason the first cut avoided pinning. Because the flat list always carries a `configId`, every +step created after this change is pinned — automatic resolution now only applies to steps saved +before it, and to server-side consumers (chat, embedder, memory extraction) that name no key. diff --git a/brain/knowledge/engineering/ci-pr-review-hygiene.md b/brain/knowledge/engineering/ci-pr-review-hygiene.md index 4e92cbaff600..3f1e288f304e 100644 --- a/brain/knowledge/engineering/ci-pr-review-hygiene.md +++ b/brain/knowledge/engineering/ci-pr-review-hygiene.md @@ -22,6 +22,7 @@ Enforcement is the **`Codeowners review` repository ruleset** (active on the def ## Gotchas - **A bare `*` in CODEOWNERS matches every file at every depth, so the catch-all owner is dragged into PRs that have nothing to do with them.** Unlike `docs/*` (direct children only), `*` is fully recursive, and last-match-wins means only an explicit later rule can release a path. A lockfile-only PR requested `core` ([#14629](https://github.com/activepieces/activepieces/pull/14629)), and so did a single-page docs PR ([#14422](https://github.com/activepieces/activepieces/pull/14422), one file under `brain/`). The release valve is a **path listed with no owner after the `*` line**, which GitHub reads as owned-by-nobody; CODEOWNERS has no `!negation` syntax and no brace expansion — `packages/**/{A,B}.md` parses clean and matches a file literally named `{A,B}.md`. Verify any edit with `gh api repos/activepieces/activepieces/codeowners/errors` — an invalid line is silently *skipped*, which quietly restores the catch-all owner instead of failing loudly. - **A spurious `core` request on a pieces PR is not always the lockfile — check for a second root file.** [#14558](https://github.com/activepieces/activepieces/pull/14558) looked like the lockfile case but its non-pieces files were `bun.lock` *and* `tsconfig.base.json`; the `core` request landed 6s after the commit that touched the tsconfig, not after the pieces push. Per-piece `paths` mappings generated into root `tsconfig.base.json` mean a pieces change can still reach a core-owned file, and no CODEOWNERS pattern can fix that — the file holds real compiler options and CODEOWNERS has no sub-file granularity. +- **Greptile's Confidence Score prose is cumulative — a low score is not evidence of a live problem.** It edits one summary comment in place, and its "Files Needing Attention" list keeps naming findings that are already resolved and outdated: #14825 sat at 2/5 citing three files, two of which were a closed P1 and a duplicate view of the third. Read the *unresolved* review threads (`reviewThreads(first:60) { isResolved isOutdated }` over GraphQL — the REST comments endpoint carries no resolution state) and judge from those; re-trigger the review to refresh the score. It also re-raises the same class of finding each round with a new comment id, so a fix on one thread does not silence its sibling. - **A red check does not block a merge.** The gate only prevents merges once `PR size` is added as a **required status check** for `main` in branch protection. Until then it is visible but advisory. - **Workflow actions are pinned to major-version tags, not SHAs** (`actions/checkout@v5`, `oven-sh/setup-bun@v2`). The only SHA pins live in the CodeQL security workflow. Reviewers — human and AI — regularly suggest SHA-pinning a single new workflow; decline it. Moving to SHA pinning is a repo-wide policy call, and a half-pinned `.github/` is worse than a consistent one. - **`redis-memory-server` compiles Redis from source during `bun install`, so its version must stay pinned.** It is in `trustedDependencies`, and with no version configured it defaults to `stable` — whatever `download.redis.io/redis-stable.tar.gz` points at today. When that moved to Redis 8.10.0 (2026-07-29), the bundled module tree (redisearch, redistimeseries, LibMR) started failing to build on runners and took `bun install` down across every branch: 8.10.0 vendors the module sources into the tarball and changes the default make goal to `build`, which compiles every module under `modules/*/src` regardless of `BUILD_WITH_MODULES`. It reads as flakiness because `ci.yml` caches `~/.bun/install/cache` but not the compiled binary, so each run recompiles and only sometimes survives. Root `package.json` pins `redisMemoryServer.version` to **8.8.1**, the newest release that still builds core-only — treat it as a ceiling, bump it deliberately, and never go back to `stable`. diff --git a/brain/knowledge/engineering/server-module-anatomy.md b/brain/knowledge/engineering/server-module-anatomy.md index e30ea388f624..4291a3768411 100644 --- a/brain/knowledge/engineering/server-module-anatomy.md +++ b/brain/knowledge/engineering/server-module-anatomy.md @@ -135,7 +135,7 @@ Verify with `npm run lint-dev` and `npm run test-api`. - **The migration generator emits the wrong interface.** Every generated file must be patched from `MigrationInterface` to this repo's `Migration`, or CI rejects it. Never hand-write the SQL instead — generate from the entity diff, then patch. - **Exception: the generator diffs against *your* database, so a surviving table of the same name produces an `ALTER`, not a `CREATE`.** Adding `AgentEntity` (table `agent`) emitted a mutation of the dead 2025 `agent` table — `DROP COLUMN systemPrompt`, then `ADD "iconKey" character varying NOT NULL` with no default, which fails on any table that has rows — and left `agent_run` untouched. When you are deliberately replacing an orphaned table, hand-write `DROP TABLE IF EXISTS … CASCADE` + `CREATE TABLE`, and check `pg_constraint` for FKs pointing at it first. - **`npm run check-migrations` can pass while your database is untouched.** It sources `.env.tests` (not `.env.dev`) and pipes `migration:run` to `/dev/null`, so it reported "No changes in database schema were found" against a database still holding the pre-migration table. Treat a green run as "the entity and *some* database agree", not as proof your migration executed. To actually verify, run `migration:run` against the dev DB with an explicit `AP_POSTGRES_HOST` and then inspect `information_schema.columns`, `pg_indexes` and `pg_constraint`. -- **Migration timestamps collide across unmerged branches.** `migrations` is keyed by class name, so two branches can both claim `1824000000000` and only conflict at merge. Before picking a timestamp, check the applied ledger (`select name from migrations order by id desc limit 5`) as well as the files on `main` — a timestamp can already be in use by a branch you cannot see. +- **Migration timestamps collide across unmerged branches.** `migrations` is keyed by class name, so two branches can both claim `1824000000000` and only conflict at merge. Before picking a timestamp, check the applied ledger (`select name from migrations order by id desc limit 5`) as well as the files on `main` — a timestamp can already be in use by a branch you cannot see. When the collision does surface in a merge, renumber **yours** — the one on `main` is already applied in production and cannot move — which means renaming the file, the class, and the class's `name` field, then re-registering it after the merged one in `getMigrations()`. Whoever already ran the old name locally needs no DB surgery *provided* `up()` is idempotent (`IF NOT EXISTS` / `DROP … IF EXISTS` throughout): TypeORM sees an unapplied name and re-runs it as a no-op. Without that, they have to update the `migrations` ledger row by hand. - **PGlite has one connection, so `CONCURRENTLY` breaks it.** Guard on `system.get(AppSystemProp.DB_TYPE) === DatabaseType.PGLITE` and issue a plain `CREATE INDEX` on that branch. When you do use `CONCURRENTLY`, set `transaction = false` on the migration class — PostgreSQL requires it outside a transaction. - **`EntitySchema` supports partial-index `where`, but not expression columns.** For a partial index on a bare column (e.g. `ON file(platformId) WHERE projectId IS NULL`), pass `where: '"projectId" IS NULL'` alongside `columns: ['platformId']` — TypeORM 0.3.x's `EntitySchemaIndexOptions.where` is honored by the Postgres driver (`PostgresQueryRunner` line 2442: `${where ? "WHERE " + where : ""}`), so `synchronize` can stay on and `migration:generate` tracks the index correctly. Reserve `synchronize: false` for **expression indexes** — `columns` is `string[]` of bare column names with no expression syntax, so an index like `ON file(type, (metadata->>'flowId'))` (see `idx_file_sample_data_flow_id`) genuinely can't be expressed and needs the opt-out. Blindly using `synchronize: false` for every hand-written index (which I did once and got called on) leaves TypeORM blind to the index — future `migration:generate` won't drop it if you remove it from the entity, and drift can silently accumulate. - **`UpdateResult.affected` is `undefined` on PGlite — never branch on it.** TypeORM's Postgres driver sets `affected` from `raw.rowCount`, and `typeorm-pglite` returns PGlite's `Results` (`{ rows, fields, affectedRows }`) with no `rowCount`. So the compare-and-set idiom `if (result.affected === 0) return null` is *always false* on PGlite and every predicate in the `WHERE` becomes decorative — the guard silently passes. This is not test-only: `AP_DB_TYPE=PGLITE` is the documented one-line Docker install (`docs/install/options/docker.mdx`). It hit MCP OAuth (`mcpOAuthCodeService.consume`), where it made authorization codes replayable, unbound to their client and redirect_uri, and immune to expiry. Use `.returning('*')` and test `updateResult.raw` for emptiness instead — that works on both drivers. Confirmed against the pinned `@electric-sql/pglite` 0.3.14: a plain `UPDATE` answers `{ rows, fields, affectedRows }` with **`rowCount: undefined`**, while the same statement with `RETURNING *` fills `rows` correctly (0 on no match, 1 on match). Note PGlite *does* report `affectedRows` — it is only `rowCount`, the field TypeORM reads, that is missing, so "PGlite loses the count" is the wrong mental model. The remaining call sites were converted in 2026-08 (`ee/agent/agent-rpc-handlers.ts`, `ee/projects/platform-project-service.ts`); a `.affected` that only feeds a log line was left alone. **Integration tests here run on Postgres** (`.env.tests` points at a real server), so they cannot catch this class at all — run the suite with `AP_DB_TYPE=PGLITE` prefixed to exercise it, which works today and is how the fix was proven red-to-green. Prefer `.returning('id')` over `.returning('*')`: on a table like `agent_conversation` the star form hauls the whole `messages` jsonb back on every write, and a row only has to be counted, not read. diff --git a/brain/knowledge/engineering/web-feature-anatomy.md b/brain/knowledge/engineering/web-feature-anatomy.md index c8b2c46dc0f8..a7fc4c92705d 100644 --- a/brain/knowledge/engineering/web-feature-anatomy.md +++ b/brain/knowledge/engineering/web-feature-anatomy.md @@ -63,6 +63,7 @@ Verify with `npx turbo run lint --filter=web`, or `npm run lint-dev` for the who ## Gotchas - **A `packages/web` test runs in the `node` environment by default, so importing anything that touches `window` at module load fails at collection.** `vitest.config.ts` sets `environment: 'node'`; ~26 suites opt into a DOM with a `// @vitest-environment jsdom` docblock on line 1. The failure is a bare `ReferenceError: window is not defined` pointing at a *transitive* import (`embed-provider.tsx` reading `window.opener`, reached via `@/features/projects`), not at the test — so read the stack, don't hunt in your own file. Missing the docblock is why `chunk-reducer.test.ts` was red for as long as it was: CI did not run the web suite at all, so nothing surfaced it. +- **A panel that hand-rolls its draft state gets none of the form validation the rest of the app assumes.** react-hook-form + `zodResolver` is what surfaces `formErrors.required` and friends; a `useState` draft with a Save button has no schema, so the usual mistake is to *substitute* a fallback for an empty field (`name.trim().length > 0 ? name.trim() : existing.name`) instead of rejecting it. That reads as a silent failure: the request succeeds, the old value returns, and nothing explains why. When a surface cannot use react-hook-form, derive the invalid state, render the message next to the field, and disable the submit — do not paper over the empty value. Bit the AI Center key-detail panel while its sibling connect dialog, on a zod resolver, was correct. The second failure mode is that such a draft never resyncs: seeded once from a prop, it outlives any refetch of the row it mirrors, so a mutation that changes the row without changing its `key` (the AI Center replaces a key's credentials, and the panel is keyed on the config id) leaves the draft describing the old row — phantom "unsaved changes", and a save that reverts what the mutation just wrote. Bump a version segment into the `key` at the site that performs the mutation rather than diffing props inside the panel: TanStack Query hands back a new object identity on every refetch, so a naive identity comparison discards the admin's unsaved edits on a window refocus. - **Exported types and constants belong at the *end* of the file**, after the components and logic. Reading a file should start with what it does, not its type declarations. - **`showErrorDialog` on the wrong query is worse than missing it.** On an auxiliary query it throws a modal over a page that was working fine; on the primary query, omitting it leaves the user staring at an empty table with no explanation. - **A ref assigned during render (`const ref = useRef(x); ref.current = x`) is stale inside socket/event callbacks.** The value only advances when React commits a render, so two events handled before that commit both read the same base — a read-modify-write (merging a step into `run.steps`) silently drops the earlier event. Read the zustand store directly instead: `useBuilderStore().getState()` (`app/builder/builder-hooks.ts`) always returns current state. Bit the test-flow widget's progress merge, PR #14453. diff --git a/bun.lock b/bun.lock index 4ce5defb55ef..561fc2df4ede 100644 --- a/bun.lock +++ b/bun.lock @@ -162,7 +162,7 @@ }, "packages/core/shared": { "name": "@activepieces/shared", - "version": "0.139.0", + "version": "0.140.0", "dependencies": { "@activepieces/core-execution": "workspace:*", "@activepieces/core-formula": "workspace:*", @@ -334,7 +334,7 @@ }, "packages/pieces/community/ai": { "name": "@activepieces/piece-ai", - "version": "0.6.1", + "version": "0.7.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/core/execution/src/lib/workers/job-data.ts b/packages/core/execution/src/lib/workers/job-data.ts index a56404c04c5a..dceee51409d5 100644 --- a/packages/core/execution/src/lib/workers/job-data.ts +++ b/packages/core/execution/src/lib/workers/job-data.ts @@ -334,6 +334,7 @@ export const ExecuteAgentRunJobData = z.object({ structuredOutput: z.array(AgentOutputField).optional(), maxSteps: z.number().int().positive().optional(), provider: z.enum(AIProviderName).optional(), + providerConfigId: z.string().optional(), modelName: z.string().nullable(), files: z.array(z.object({ name: z.string(), diff --git a/packages/core/execution/src/lib/workers/worker-contract.ts b/packages/core/execution/src/lib/workers/worker-contract.ts index eaa7ce50417a..df51a933cf19 100644 --- a/packages/core/execution/src/lib/workers/worker-contract.ts +++ b/packages/core/execution/src/lib/workers/worker-contract.ts @@ -109,6 +109,7 @@ export type SendAgentEventRequest = { export type GetAgentConfigRequest = { provider?: AIProviderName + providerConfigId?: string conversationId: string runId?: string platformId: string @@ -136,6 +137,7 @@ export type AgentAiToolsConfig = { export type AgentConfigResponse = { provider: string + providerConfigId: string auth: Record providerConfig: Record modelId: string @@ -194,6 +196,7 @@ export type UpdateProjectContextRequest = { runId?: string projectId: string | null provider?: AIProviderName + providerConfigId?: string } export type ExecuteAgentToolRequest = { @@ -210,6 +213,7 @@ export type ExecutePieceToolRequest = { toolName: string instruction: string provider?: AIProviderName + providerConfigId?: string piece: AgentPieceToolMetadata } @@ -221,6 +225,7 @@ export type ExecuteKnowledgeBaseToolRequest = { conversationId: string toolName: string provider?: AIProviderName + providerConfigId?: string knowledgeBaseFileId: string query: string } diff --git a/packages/core/piece-types/src/lib/agents.ts b/packages/core/piece-types/src/lib/agents.ts index 124613b7f8b6..e028642f897a 100644 --- a/packages/core/piece-types/src/lib/agents.ts +++ b/packages/core/piece-types/src/lib/agents.ts @@ -289,6 +289,7 @@ export type ToolCallBase = z.infer export type AgentProviderModel = { provider: AIProviderName model: string + configId?: string } export type AgentResult = { diff --git a/packages/core/piece-types/src/lib/ai-providers.ts b/packages/core/piece-types/src/lib/ai-providers.ts index ac83df0f2114..4d55c17ea0f6 100644 --- a/packages/core/piece-types/src/lib/ai-providers.ts +++ b/packages/core/piece-types/src/lib/ai-providers.ts @@ -120,8 +120,20 @@ export const AIProviderWithoutSensitiveData = z.object({ }) export type AIProviderWithoutSensitiveData = z.infer +export const ProjectAIProvider = z.object({ + provider: z.enum(AIProviderName), + name: z.string(), + enabledForChat: z.boolean(), + keys: z.array(z.object({ + id: z.string(), + name: z.string(), + })), +}) +export type ProjectAIProvider = z.infer + export const GetProviderConfigResponse = z.object({ provider: z.enum(AIProviderName), + configId: z.string(), config: AIProviderConfig, auth: AIProviderAuthConfig, platformId: z.string(), diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index d884384a3f4d..6533a4fb0e49 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.139.0", + "version": "0.140.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", diff --git a/packages/core/shared/src/lib/ee/agent/agent.ts b/packages/core/shared/src/lib/ee/agent/agent.ts index 393cf735c69b..6764342e8392 100644 --- a/packages/core/shared/src/lib/ee/agent/agent.ts +++ b/packages/core/shared/src/lib/ee/agent/agent.ts @@ -39,6 +39,7 @@ enum AgentIcon { const AgentConfig = z.object({ instructions: z.string().max(MAX_AGENT_TEXT_LENGTH), provider: Nullable(z.enum(AIProviderName)), + providerConfigId: Nullable(ApId), modelName: Nullable(z.string().max(MAX_AGENT_NAME_LENGTH)), maxSteps: z.number().int().positive().max(MAX_AGENT_STEP_BUDGET).default(DEFAULT_AGENT_MAX_STEPS), tools: z.array(AgentTool).max(MAX_AGENT_TOOLS).default([]), diff --git a/packages/core/shared/src/lib/management/ai-providers/index.ts b/packages/core/shared/src/lib/management/ai-providers/index.ts index d7f15fbef1aa..8515c33495e5 100644 --- a/packages/core/shared/src/lib/management/ai-providers/index.ts +++ b/packages/core/shared/src/lib/management/ai-providers/index.ts @@ -221,10 +221,17 @@ export const AIProviderWithoutSensitiveData = z.object({ }) export type AIProviderWithoutSensitiveData = z.infer +export const ProjectAIProviderKey = z.object({ + id: z.string(), + name: z.string(), +}) +export type ProjectAIProviderKey = z.infer + export const ProjectAIProvider = z.object({ provider: z.nativeEnum(AIProviderName), name: z.string(), enabledForChat: z.boolean(), + keys: z.array(ProjectAIProviderKey), }) export type ProjectAIProvider = z.infer @@ -254,6 +261,7 @@ export type UpdateAIProviderRequest = z.infer export const GetProviderConfigResponse = z.object({ provider: z.nativeEnum(AIProviderName), + configId: z.string(), config: AIProviderConfig, auth: AIProviderAuthConfig, platformId: z.string(), diff --git a/packages/pieces/community/ai/package.json b/packages/pieces/community/ai/package.json index e565d66bab59..f5b03ad062e6 100644 --- a/packages/pieces/community/ai/package.json +++ b/packages/pieces/community/ai/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-ai", - "version": "0.6.1", + "version": "0.7.0", "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", diff --git a/packages/pieces/community/ai/src/lib/actions/agents/run-agent.ts b/packages/pieces/community/ai/src/lib/actions/agents/run-agent.ts index 67b22168b604..6d88fd1984b8 100644 --- a/packages/pieces/community/ai/src/lib/actions/agents/run-agent.ts +++ b/packages/pieces/community/ai/src/lib/actions/agents/run-agent.ts @@ -137,6 +137,7 @@ export const runAgent = createAction({ waitpointId: waitpoint.id, ...spreadIfDefined('modelName', (context.propsValue.aiProviderModel as AgentProviderModel | undefined)?.model), ...spreadIfDefined('provider', (context.propsValue.aiProviderModel as AgentProviderModel | undefined)?.provider), + ...spreadIfDefined('providerConfigId', (context.propsValue.aiProviderModel as AgentProviderModel | undefined)?.configId), tools, structuredOutput: context.propsValue.structuredOutput ?? [], ...spreadIfDefined('maxSteps', context.propsValue.maxSteps), diff --git a/packages/pieces/community/ai/src/lib/actions/image/generate-image.ts b/packages/pieces/community/ai/src/lib/actions/image/generate-image.ts index de1e721113fa..4a194288f9a2 100644 --- a/packages/pieces/community/ai/src/lib/actions/image/generate-image.ts +++ b/packages/pieces/community/ai/src/lib/actions/image/generate-image.ts @@ -17,10 +17,10 @@ import { import { generateImage } from 'ai'; import mime from 'mime-types'; import { isNil } from '@activepieces/pieces-framework'; -import { getEffectiveProviderAndModel } from '@activepieces/pieces-framework'; +import { getEffectiveProviderAndModel, spreadIfDefined } from '@activepieces/pieces-framework'; import { createAIModel } from '../../common/ai-sdk'; import { AIProviderName } from '@activepieces/pieces-framework'; -import { aiProps } from '../../common/props'; +import { aiProps, aiProviderSelection } from '../../common/props'; export const generateImageAction = createAction({ audience: 'both', @@ -54,7 +54,7 @@ export const generateImageAction = createAction({ auth: PieceAuth.None(), refreshers: ['provider', 'model'], props: async (propsValue): Promise => { - const rawProvider = propsValue['provider'] as unknown as string; + const rawProvider = aiProviderSelection.resolve(propsValue['provider'])?.provider; const rawModel = propsValue['model'] as unknown as string; const { provider: effectiveProvider, model: effectiveModel } = getEffectiveProviderAndModel({ provider: rawProvider, @@ -141,7 +141,7 @@ export const generateImageAction = createAction({ }), }, async run(context) { - const provider = context.propsValue.provider; + const { provider, configId } = aiProviderSelection.resolveOrThrow(context.propsValue.provider); const modelId = context.propsValue.model; const inputImages = collectInputImages({ @@ -150,7 +150,8 @@ export const generateImageAction = createAction({ }); const image = await getGeneratedImage({ - provider: provider as AIProviderName, + provider, + ...spreadIfDefined('configId', configId), modelId, engineToken: context.server.token, apiUrl: context.server.apiUrl, @@ -207,6 +208,7 @@ const extractImageFiles = (value: unknown): ApFile[] => { const getGeneratedImage = async ({ provider, + configId, modelId, engineToken, apiUrl, @@ -218,6 +220,7 @@ const getGeneratedImage = async ({ advancedOptions, }: { provider: AIProviderName; + configId?: string; modelId: string; engineToken: string; apiUrl: string; @@ -230,6 +233,7 @@ const getGeneratedImage = async ({ }): Promise => { const model = await createAIModel({ provider, + ...spreadIfDefined('configId', configId), modelId, engineToken, apiUrl, diff --git a/packages/pieces/community/ai/src/lib/actions/text/ask-ai.ts b/packages/pieces/community/ai/src/lib/actions/text/ask-ai.ts index 4833bf641cae..3ca7e01bc1e6 100644 --- a/packages/pieces/community/ai/src/lib/actions/text/ask-ai.ts +++ b/packages/pieces/community/ai/src/lib/actions/text/ask-ai.ts @@ -4,7 +4,7 @@ import { } from '@activepieces/pieces-framework'; import { ModelMessage, generateText, stepCountIs } from 'ai'; import { AIProviderName, getEffectiveProviderAndModel, spreadIfDefined } from '@activepieces/pieces-framework'; -import { aiProps } from '../../common/props'; +import { aiProps, aiProviderSelection } from '../../common/props'; import { createAIModel } from '../../common/ai-sdk'; import { buildWebSearchOptionsProperty, buildWebSearchConfig, WebSearchOptions } from '../../common/web-search'; @@ -47,14 +47,14 @@ export const askAI = createAction({ }), webSearchOptions: buildWebSearchOptionsProperty( (propsValue) => ({ - provider: propsValue['provider'] as string | undefined, + provider: aiProviderSelection.resolve(propsValue['provider'])?.provider, model: propsValue['model'] as string | undefined, }), ['webSearch', 'provider', 'model'], ), }, async run(context) { - const provider = context.propsValue.provider; + const { provider, configId } = aiProviderSelection.resolveOrThrow(context.propsValue.provider); const modelId = context.propsValue.model; const storage = context.store; const webSearchEnabled = !!context.propsValue.webSearch; @@ -68,11 +68,12 @@ export const askAI = createAction({ }); const { provider: effectiveProvider } = getEffectiveProviderAndModel({ - provider: provider as AIProviderName, + provider, model: modelId, }); const model = await createAIModel({ - provider: provider as AIProviderName, + provider, + ...spreadIfDefined('configId', configId), modelId, engineToken: context.server.token, apiUrl: context.server.apiUrl, diff --git a/packages/pieces/community/ai/src/lib/actions/text/summarize-text.ts b/packages/pieces/community/ai/src/lib/actions/text/summarize-text.ts index f7b958f5e2fb..0f58398a5034 100644 --- a/packages/pieces/community/ai/src/lib/actions/text/summarize-text.ts +++ b/packages/pieces/community/ai/src/lib/actions/text/summarize-text.ts @@ -1,8 +1,8 @@ -import { AIProviderName } from '@activepieces/pieces-framework'; +import { AIProviderName, spreadIfDefined } from '@activepieces/pieces-framework'; import { createAIModel } from '../../common/ai-sdk'; import { createAction, Property } from '@activepieces/pieces-framework'; import { generateText } from 'ai'; -import { aiProps } from '../../common/props'; +import { aiProps, aiProviderSelection } from '../../common/props'; export const summarizeText = createAction({ audience: 'both', @@ -31,11 +31,12 @@ export const summarizeText = createAction({ }), }, async run(context) { - const provider = context.propsValue.provider; + const { provider, configId } = aiProviderSelection.resolveOrThrow(context.propsValue.provider); const modelId = context.propsValue.model; const model = await createAIModel({ - provider: provider as AIProviderName, + provider, + ...spreadIfDefined('configId', configId), modelId, engineToken: context.server.token, apiUrl: context.server.apiUrl, diff --git a/packages/pieces/community/ai/src/lib/actions/utility/classify-text.ts b/packages/pieces/community/ai/src/lib/actions/utility/classify-text.ts index a74dd2072a9b..7bfd502cdabb 100644 --- a/packages/pieces/community/ai/src/lib/actions/utility/classify-text.ts +++ b/packages/pieces/community/ai/src/lib/actions/utility/classify-text.ts @@ -1,8 +1,8 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { generateText } from 'ai'; import { createAIModel } from '../../common/ai-sdk'; -import { aiProps } from '../../common/props'; -import { AIProviderName } from '@activepieces/pieces-framework'; +import { aiProps, aiProviderSelection } from '../../common/props'; +import { spreadIfDefined } from '@activepieces/pieces-framework'; export const classifyText = createAction({ audience: 'both', @@ -27,11 +27,12 @@ export const classifyText = createAction({ async run(context) { const categories = (context.propsValue.categories as string[]) ?? []; - const provider = context.propsValue.provider; + const { provider, configId } = aiProviderSelection.resolveOrThrow(context.propsValue.provider); const modelId = context.propsValue.model; const model = await createAIModel({ - provider: provider as AIProviderName, + provider, + ...spreadIfDefined('configId', configId), modelId, engineToken: context.server.token, apiUrl: context.server.apiUrl, diff --git a/packages/pieces/community/ai/src/lib/actions/utility/extract-structured-data.ts b/packages/pieces/community/ai/src/lib/actions/utility/extract-structured-data.ts index 4dea35e2b312..36dbeff5c14f 100644 --- a/packages/pieces/community/ai/src/lib/actions/utility/extract-structured-data.ts +++ b/packages/pieces/community/ai/src/lib/actions/utility/extract-structured-data.ts @@ -3,8 +3,8 @@ import { createAIModel } from '../../common/ai-sdk'; import { generateText, tool, jsonSchema, ModelMessage, UserModelMessage } from 'ai'; import mime from 'mime-types'; import Ajv from 'ajv'; -import { aiProps } from '../../common/props'; -import { AIProviderName } from '@activepieces/pieces-framework'; +import { aiProps, aiProviderSelection } from '../../common/props'; +import { spreadIfDefined } from '@activepieces/pieces-framework'; export const extractStructuredData = createAction({ audience: 'both', @@ -128,7 +128,7 @@ export const extractStructuredData = createAction({ }), }, async run(context) { - const provider = context.propsValue.provider; + const { provider, configId } = aiProviderSelection.resolveOrThrow(context.propsValue.provider); const modelId = context.propsValue.model; const text = context.propsValue.text; const files = (context.propsValue.files as Array<{ file: ApFile }>) ?? []; @@ -141,7 +141,8 @@ export const extractStructuredData = createAction({ } const model = await createAIModel({ - provider: provider as AIProviderName, + provider, + ...spreadIfDefined('configId', configId), modelId, engineToken: context.server.token, apiUrl: context.server.apiUrl, diff --git a/packages/pieces/community/ai/src/lib/common/ai-sdk.ts b/packages/pieces/community/ai/src/lib/common/ai-sdk.ts index b35bc8818799..83b0c8fc93f9 100644 --- a/packages/pieces/community/ai/src/lib/common/ai-sdk.ts +++ b/packages/pieces/community/ai/src/lib/common/ai-sdk.ts @@ -13,13 +13,14 @@ import { createAiGateway } from 'ai-gateway-provider'; import { createAnthropic as createAnthropicGateway } from 'ai-gateway-provider/providers/anthropic'; import { createGoogleGenerativeAI as createGoogleGateway } from 'ai-gateway-provider/providers/google'; -async function fetchProviderConfig(params: { provider: AIProviderName, engineToken: string, apiUrl: string }) { +async function fetchProviderConfig(params: { provider: AIProviderName, engineToken: string, apiUrl: string, configId?: string }) { const { body } = await httpClient.sendRequest({ method: HttpMethod.GET, url: `${params.apiUrl}v1/ai-providers/${params.provider}/config`, headers: { Authorization: `Bearer ${params.engineToken}`, }, + ...(params.configId === undefined ? {} : { queryParams: { configId: params.configId } }), }) return body } @@ -28,6 +29,7 @@ export function createAIModel(params: CreateAIModelParams): Promise): Promise; export async function createAIModel({ provider, + configId, modelId, engineToken, projectId, @@ -37,7 +39,7 @@ export async function createAIModel({ openaiResponsesModel = false, isImage, }: CreateAIModelParams): Promise { - const { config, auth, platformId } = await fetchProviderConfig({ provider, engineToken, apiUrl }); + const { config, auth, platformId } = await fetchProviderConfig({ provider, engineToken, apiUrl, configId }); if (isImage && !AI_PROVIDER_CAPABILITIES[provider].supportsImageGeneration) { throw new Error(`Provider ${provider} does not support image models`) @@ -325,6 +327,7 @@ const handleDefaultAiGatewayProvider = ({accountId, gatewayId, headers, isImage, type CreateAIModelParams = { provider: AIProviderName; + configId?: string; modelId: string; engineToken: string; projectId: string; diff --git a/packages/pieces/community/ai/src/lib/common/props.ts b/packages/pieces/community/ai/src/lib/common/props.ts index 52235f49bcd1..8e81da0929a4 100644 --- a/packages/pieces/community/ai/src/lib/common/props.ts +++ b/packages/pieces/community/ai/src/lib/common/props.ts @@ -1,7 +1,7 @@ import { ACTIVEPIECES_CHAT_TIERS, PieceAuth, Property } from '@activepieces/pieces-framework'; import { httpClient, HttpMethod } from '@activepieces/pieces-common'; import { isNil } from '@activepieces/pieces-framework'; -import { AIProviderModel, AIProviderName, AIProviderWithoutSensitiveData } from '@activepieces/pieces-framework'; +import { AIProviderModel, AIProviderName, ProjectAIProvider } from '@activepieces/pieces-framework'; type AIModelType = 'text' | 'image'; @@ -9,42 +9,74 @@ function managedModelLabel(modelId: string): string | undefined { return ACTIVEPIECES_CHAT_TIERS.find((tier) => tier.modelId === modelId)?.label; } -type AIPropsParams = { - modelType: T; - allowedProviders?: AIProviderName[]; -}; +async function listProviders(ctx: { + server: { apiUrl: string; token: string }; +}): Promise { + const { body } = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${ctx.server.apiUrl}v1/ai-providers`, + headers: { + Authorization: `Bearer ${ctx.server.token}`, + }, + }); + return body; +} + +function toProviderName(value: string): AIProviderName | undefined { + return Object.values(AIProviderName).find((provider) => provider === value); +} + +function resolveSelection(value: unknown): AIProviderSelection | undefined { + if (typeof value === 'string') { + const provider = toProviderName(value); + return isNil(provider) ? undefined : { provider }; + } + if (typeof value !== 'object' || isNil(value) || !('provider' in value)) { + return undefined; + } + const provider = + typeof value.provider === 'string' + ? toProviderName(value.provider) + : undefined; + if (isNil(provider)) { + return undefined; + } + const configId = + 'configId' in value && typeof value.configId === 'string' + ? value.configId + : undefined; + return { provider, ...(isNil(configId) ? {} : { configId }) }; +} export const aiProps = ({ modelType, allowedProviders, }: AIPropsParams) => ({ - provider: Property.Dropdown({ + provider: Property.Dropdown({ auth: PieceAuth.None(), displayName: 'Provider', required: true, refreshers: [], options: async (_, ctx) => { - const { body: supportedProviders } = - await httpClient.sendRequest({ - method: HttpMethod.GET, - url: `${ctx.server.apiUrl}v1/ai-providers`, - headers: { - Authorization: `Bearer ${ctx.server.token}`, - }, - }); + const supportedProviders = await listProviders(ctx); return { placeholder: 'Select AI Provider', disabled: false, options: supportedProviders - .map(provider => ({ - label: provider.name, - value: provider.provider, - })) - .filter(option => + .filter(provider => allowedProviders - ? allowedProviders.includes(option.value as AIProviderName) + ? allowedProviders.includes(provider.provider) : true + ) + .flatMap(provider => + provider.keys.map(key => ({ + label: + provider.keys.length > 1 + ? `${provider.name}: ${key.name}` + : provider.name, + value: { provider: provider.provider, configId: key.id }, + })) ), }; }, @@ -56,9 +88,9 @@ export const aiProps = ({ required: true, refreshers: ['provider'], options: async (propsValue, ctx) => { - const provider = propsValue['provider'] as string + const selection = resolveSelection(propsValue['provider']) - if (isNil(provider)) { + if (isNil(selection)) { return { disabled: true, options: [], @@ -66,6 +98,7 @@ export const aiProps = ({ }; } + const { provider, configId } = selection; const { body: allModels } = await httpClient.sendRequest({ method: HttpMethod.GET, @@ -73,6 +106,7 @@ export const aiProps = ({ headers: { Authorization: `Bearer ${ctx.server.token}`, }, + ...(isNil(configId) ? {} : { queryParams: { configId } }), }); return { @@ -88,4 +122,27 @@ export const aiProps = ({ }; }, }), -}); \ No newline at end of file +}); + +function resolveSelectionOrThrow(value: unknown): AIProviderSelection { + const selection = resolveSelection(value); + if (isNil(selection)) { + throw new Error('Pick an AI provider for this step'); + } + return selection; +} + +export const aiProviderSelection = { + resolve: resolveSelection, + resolveOrThrow: resolveSelectionOrThrow, +}; + +export type AIProviderSelection = { + provider: AIProviderName; + configId?: string; +}; + +type AIPropsParams = { + modelType: T; + allowedProviders?: AIProviderName[]; +}; diff --git a/packages/pieces/framework/src/index.ts b/packages/pieces/framework/src/index.ts index 6566f0f5970c..3c3bfd8f53ff 100644 --- a/packages/pieces/framework/src/index.ts +++ b/packages/pieces/framework/src/index.ts @@ -55,6 +55,7 @@ export { // ai providers AIProviderModel, AIProviderWithoutSensitiveData, + ProjectAIProvider, AzureProviderConfig, BaseAIProviderAuthConfig, BedrockProviderAuthConfig, diff --git a/packages/server/api/src/app/ai/ai-provider-controller.ts b/packages/server/api/src/app/ai/ai-provider-controller.ts index 12e47fa6d521..20ff25da4fe1 100644 --- a/packages/server/api/src/app/ai/ai-provider-controller.ts +++ b/packages/server/api/src/app/ai/ai-provider-controller.ts @@ -1,5 +1,5 @@ import { AIProviderName } from '@activepieces/core-utils' -import { AIProviderModel, CreateAIProviderRequest, PrincipalType, UpdateAIProviderRequest } from '@activepieces/shared' +import { AIProviderModel, CreateAIProviderRequest, PrincipalType, spreadIfDefined, UpdateAIProviderRequest } from '@activepieces/shared' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' import { z } from 'zod' @@ -34,6 +34,7 @@ export const aiProviderController: FastifyPluginAsyncZod = async (app) => { platformId, provider, scope: { type: 'project', projectId: request.principal.projectId }, + ...spreadIfDefined('configId', request.query.configId), }) }) app.get('/:provider/models', ListModels, async (request) => { @@ -41,6 +42,7 @@ export const aiProviderController: FastifyPluginAsyncZod = async (app) => { platformId: request.principal.platform.id, provider: request.params.provider, scope: { type: 'project', projectId: request.projectId }, + ...spreadIfDefined('configId', request.query.configId), }) }) app.post('/', CreateAIProvider, async (request) => { @@ -97,6 +99,9 @@ const GetAIProviderConfig = { params: z.object({ provider: z.nativeEnum(AIProviderName), }), + querystring: z.object({ + configId: z.string().optional(), + }), }, } @@ -110,6 +115,7 @@ const ListModels = { }), querystring: z.object({ projectId: z.string().optional(), + configId: z.string().optional(), }), response: { [StatusCodes.OK]: z.array(AIProviderModel), diff --git a/packages/server/api/src/app/ai/ai-provider-service.ts b/packages/server/api/src/app/ai/ai-provider-service.ts index d9ce4a379d40..fe938beb5336 100644 --- a/packages/server/api/src/app/ai/ai-provider-service.ts +++ b/packages/server/api/src/app/ai/ai-provider-service.ts @@ -1,4 +1,4 @@ -import { ActivepiecesError, AIProviderName, apId, ErrorCode, isNil, PlatformId, spreadIfDefined } from '@activepieces/core-utils' +import { ActivepiecesError, AIProviderName, apId, ErrorCode, isNil, PlatformId, spreadIfDefined, unique } from '@activepieces/core-utils' import { ActivePiecesProviderAuthConfig, AIProviderAuthConfig, AIProviderConfig, AIProviderModel, AiProviderProjectScope, AIProviderWithoutSensitiveData, CreateAIProviderRequest, GetProviderConfigResponse, ProjectAIProvider, UpdateAIProviderRequest } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import cron from 'node-cron' @@ -33,16 +33,20 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ async listForProject({ platformId, projectId }: { platformId: PlatformId, projectId: string }): Promise { const rows = await listVisibleRows({ platformId, log }) const eligible = rows.filter((row) => rowAllowsScope({ row, scope: { type: 'project', projectId } })) - const chatEnabledProviders = new Set(eligible.filter((row) => row.enabledForChat === true).map((row) => row.provider)) - return rankRows(eligible).reduce((acc, row) => ( - acc.some((entry) => entry.provider === row.provider) - ? acc - : [...acc, { provider: row.provider, name: row.displayName, enabledForChat: chatEnabledProviders.has(row.provider) }] - ), []) + const ranked = rankRows(eligible) + return unique(ranked.map((row) => row.provider)).map((provider) => { + const rows = ranked.filter((row) => row.provider === provider) + return { + provider, + name: aiProviders[provider].name, + enabledForChat: rows.some((row) => row.enabledForChat === true), + keys: rows.map((row) => ({ id: row.id, name: row.displayName })), + } + }) }, - async listModels({ platformId, provider, scope }: { platformId: PlatformId, provider: AIProviderName, scope: ProviderScope }): Promise { - const aiProvider = await resolveEligibleRow({ platformId, provider, scope }) + async listModels({ platformId, provider, scope, configId }: { platformId: PlatformId, provider: AIProviderName, scope: ProviderScope, configId?: string }): Promise { + const aiProvider = await resolveRowForScope({ platformId, provider, scope, configId }) const models = await fetchModels({ aiProvider, platformId }) return aiProvider.modelScope === 'selected' ? models.filter((model) => aiProvider.modelIds.includes(model.id)) @@ -61,6 +65,7 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ params: { message: 'aiProvider.activepiecesIsManaged' }, }) } + await assertDisplayNameIsFree({ platformId, provider: request.provider, displayName: request.displayName }) await this.validateProviderCredentials(request.provider, request.auth, request.config) const saved = await aiProviderRepo().save({ id: apId(), @@ -98,6 +103,8 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ return } + await assertDisplayNameIsFree({ platformId, provider: aiProvider.provider, displayName: request.displayName, exceptId: providerId }) + const config = request.config ?? aiProvider.config if (!isNil(request.auth)) { await this.validateProviderCredentials(aiProvider.provider, request.auth, config) @@ -141,19 +148,19 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ return null } const auth = await decryptRowAuth({ aiProvider: chatProvider, platformId }) - return { provider: chatProvider.provider, auth, config: chatProvider.config, platformId } + return { provider: chatProvider.provider, configId: chatProvider.id, auth, config: chatProvider.config, platformId } }, - async keyServesScope({ platformId, provider, resolvedFor, target }: { platformId: PlatformId, provider?: AIProviderName, resolvedFor: ProviderScope, target: ProviderScope }): Promise { - const candidates = await findRunKeyCandidates({ platformId, provider, scope: resolvedFor, log }) + async keyServesScope({ platformId, provider, configId, resolvedFor, target }: { platformId: PlatformId, provider?: AIProviderName, configId?: string, resolvedFor: ProviderScope, target: ProviderScope }): Promise { + const candidates = await findRunKeyCandidates({ platformId, provider, configId, scope: resolvedFor, log }) return candidates.every((row) => target.type === 'platform' ? row.projectScope === 'all' : rowAllowsScope({ row, scope: target })) }, - async exists({ platformId, provider, scope }: { platformId: PlatformId, provider: AIProviderName, scope: ProviderScope }): Promise { + async exists({ platformId, provider, scope, configId }: { platformId: PlatformId, provider: AIProviderName, scope: ProviderScope, configId?: string }): Promise { const rows = await aiProviderRepo().findBy({ platformId, provider }) - return rows.some((row) => rowAllowsScope({ row, scope })) + return rows.some((row) => rowAllowsScope({ row, scope }) && (isNil(configId) || row.id === configId)) }, async delete(platformId: PlatformId, providerId: string): Promise { @@ -183,10 +190,10 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ }) } }, - async getConfigOrThrow({ platformId, provider, scope }: { platformId: PlatformId, provider: AIProviderName, scope: ProviderScope }): Promise { - const aiProvider = await resolveEligibleRow({ platformId, provider, scope }) + async getConfigOrThrow({ platformId, provider, scope, configId }: { platformId: PlatformId, provider: AIProviderName, scope: ProviderScope, configId?: string }): Promise { + const aiProvider = await resolveRowForScope({ platformId, provider, scope, configId }) const auth = await decryptRowAuth({ aiProvider, platformId }) - return { provider: aiProvider.provider, auth, config: aiProvider.config, platformId } + return { provider: aiProvider.provider, configId: aiProvider.id, auth, config: aiProvider.config, platformId } }, async getOrCreateActivePiecesProviderAuthConfig(platformId: PlatformId): Promise { await ensureManagedProviderRow({ platformId }) @@ -282,7 +289,11 @@ async function listVisibleRows({ platformId, log }: { platformId: PlatformId, lo return rows.filter((row) => !(hideActivepieces && row.provider === AIProviderName.ACTIVEPIECES)) } -async function findRunKeyCandidates({ platformId, provider, scope, log }: { platformId: PlatformId, provider?: AIProviderName, scope: ProviderScope, log: FastifyBaseLogger }): Promise { +async function findRunKeyCandidates({ platformId, provider, configId, scope, log }: { platformId: PlatformId, provider?: AIProviderName, configId?: string, scope: ProviderScope, log: FastifyBaseLogger }): Promise { + if (!isNil(configId)) { + const pinnedRow = await aiProviderRepo().findOneBy({ id: configId, platformId }) + return isNil(pinnedRow) ? [] : [pinnedRow] + } const chatRow = await findAvailableChatProviderRow({ platformId, scope, log }) const namedRow = isNil(provider) ? null : await findEligibleRow({ platformId, provider, scope }) return [chatRow, namedRow].reduce((acc, row) => ( @@ -312,6 +323,40 @@ async function resolveEligibleRow({ platformId, provider, scope }: { platformId: return winner } +async function resolveRowForScope({ platformId, provider, scope, configId }: { platformId: PlatformId, provider: AIProviderName, scope: ProviderScope, configId?: string }): Promise { + if (isNil(configId)) { + return resolveEligibleRow({ platformId, provider, scope }) + } + const rows = await aiProviderRepo().findBy({ platformId, provider }) + const row = rows.find((candidate) => candidate.id === configId && rowAllowsScope({ row: candidate, scope })) + if (isNil(row)) { + throw new ActivepiecesError({ + code: ErrorCode.ENTITY_NOT_FOUND, + params: { + entityId: configId, + entityType: 'AIProvider', + }, + }, scope.type === 'platform' + ? `the ${provider} AI provider key is not configured on this platform` + : `the ${provider} AI provider key is not available to this project`) + } + return row +} + +async function assertDisplayNameIsFree({ platformId, provider, displayName, exceptId }: { platformId: PlatformId, provider: AIProviderName, displayName: string, exceptId?: string }): Promise { + if (provider === AIProviderName.ACTIVEPIECES) { + return + } + const rows = await aiProviderRepo().findBy({ platformId, provider }) + const taken = rows.some((row) => row.id !== exceptId && row.displayName.trim().toLowerCase() === displayName.trim().toLowerCase()) + if (taken) { + throw new ActivepiecesError({ + code: ErrorCode.VALIDATION, + params: { message: 'Another key of this provider already uses this name' }, + }) + } +} + async function getRowByIdOrThrow({ platformId, configId }: { platformId: PlatformId, configId: string }): Promise { const aiProvider = await aiProviderRepo().findOneBy({ id: configId, platformId }) if (isNil(aiProvider)) { @@ -385,7 +430,7 @@ async function enrichWithKeysIfNeeded(aiProvider: AIProviderSchema, platformId: config: {}, auth: await encryptUtils.encryptObject(rawAuth), }) - return { provider: savedAiProvider.provider, auth: rawAuth, config: savedAiProvider.config, platformId } + return { provider: savedAiProvider.provider, configId: savedAiProvider.id, auth: rawAuth, config: savedAiProvider.config, platformId } } diff --git a/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts b/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts index 6eac8bb179df..f5c3925e8dcf 100644 --- a/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-conversation-controller.ts @@ -176,6 +176,7 @@ export const agentConversationController: FastifyPluginAsyncZod = async (app) => log, scope: agentHelpers.runScopeOrThrow({ projectId: runProjectId }), ...spreadIfDefined('provider', agentConfig?.provider ?? undefined), + ...spreadIfDefined('providerConfigId', agentConfig?.providerConfigId ?? undefined), }) await assertCreditsAndAppSumoNotExceeded({ platformId, log }) @@ -199,6 +200,7 @@ export const agentConversationController: FastifyPluginAsyncZod = async (app) => structuredOutput: agentConfig.structuredOutput, maxSteps: agentConfig.maxSteps, ...spreadIfDefined('provider', agentConfig.provider ?? undefined), + ...spreadIfDefined('providerConfigId', agentConfig.providerConfigId ?? undefined), promptOverride: { system: agentConfig.instructions }, }), }, diff --git a/packages/server/api/src/app/ee/agent/agent-helpers.ts b/packages/server/api/src/app/ee/agent/agent-helpers.ts index 10df0b95c095..50b43a9fe9e9 100644 --- a/packages/server/api/src/app/ee/agent/agent-helpers.ts +++ b/packages/server/api/src/app/ee/agent/agent-helpers.ts @@ -83,13 +83,14 @@ function selectRunProject({ conversationProjectId, projects }: { conversationPro return stillReachable ? conversationProjectId : projects[0]?.id ?? null } -async function assertProjectSwitchKeepsKey({ platformId, provider, fromProjectId, toProjectId, log }: { platformId: string, provider?: AIProviderName, fromProjectId: string | null, toProjectId: string | null, log: FastifyBaseLogger }): Promise { +async function assertProjectSwitchKeepsKey({ platformId, provider, providerConfigId, fromProjectId, toProjectId, log }: { platformId: string, provider?: AIProviderName, providerConfigId?: string, fromProjectId: string | null, toProjectId: string | null, log: FastifyBaseLogger }): Promise { if (isNil(fromProjectId) || fromProjectId === toProjectId) { return } const serves = await aiProviderService(log).keyServesScope({ platformId, ...spreadIfDefined('provider', provider), + ...spreadIfDefined('configId', providerConfigId), resolvedFor: { type: 'project', projectId: fromProjectId }, target: isNil(toProjectId) ? { type: 'platform' } : { type: 'project', projectId: toProjectId }, }) @@ -102,11 +103,11 @@ async function assertProjectSwitchKeepsKey({ platformId, provider, fromProjectId }) } -async function resolveRunProvider({ platformId, provider, scope, log }: { platformId: string, provider?: AIProviderName, scope: ProviderScope, log: FastifyBaseLogger }): Promise { +async function resolveRunProvider({ platformId, provider, providerConfigId, scope, log }: { platformId: string, provider?: AIProviderName, providerConfigId?: string, scope: ProviderScope, log: FastifyBaseLogger }): Promise { if (isNil(provider)) { return resolveChatProvider({ platformId, scope, log }) } - return aiProviderService(log).getConfigOrThrow({ platformId, provider, scope }) + return aiProviderService(log).getConfigOrThrow({ platformId, provider, scope, ...spreadIfDefined('configId', providerConfigId) }) } async function resolveChatProvider({ platformId, scope, log }: { platformId: string, scope: ProviderScope, log: FastifyBaseLogger }): Promise { @@ -120,7 +121,7 @@ async function resolveChatProvider({ platformId, scope, log }: { platformId: str return chatProvider } -async function assertRunProviderConfigured({ platformId, provider, scope, log }: { platformId: string, provider?: AIProviderName | null, scope: ProviderScope, log: FastifyBaseLogger }): Promise { +async function assertRunProviderConfigured({ platformId, provider, providerConfigId, scope, log }: { platformId: string, provider?: AIProviderName | null, providerConfigId?: string | null, scope: ProviderScope, log: FastifyBaseLogger }): Promise { if (isNil(provider)) { const chatProvider = await aiProviderService(log).getChatProviderName({ platformId, scope }) if (isNil(chatProvider)) { @@ -131,7 +132,7 @@ async function assertRunProviderConfigured({ platformId, provider, scope, log }: } return } - const configured = await aiProviderService(log).exists({ platformId, provider, scope }) + const configured = await aiProviderService(log).exists({ platformId, provider, scope, ...spreadIfDefined('configId', providerConfigId ?? undefined) }) if (!configured) { throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, @@ -183,8 +184,8 @@ function resolveModelIdForAnalytics({ provider, selectedModel }: { provider: AIP return aiProviderUtils.isCuratedChatModelId({ modelId: selectedModel }) ? selectedModel : null } -async function resolveTierModel({ platformId, tierId, provider, scope, log }: { platformId: string, tierId: string, provider?: AIProviderName, scope: ProviderScope, log: FastifyBaseLogger }): Promise<{ model: LanguageModel, modelId: string, provider: AIProviderName }> { - const providerConfig = await resolveRunProvider({ platformId, scope, log, ...spreadIfDefined('provider', provider) }) +async function resolveTierModel({ platformId, tierId, provider, providerConfigId, scope, log }: { platformId: string, tierId: string, provider?: AIProviderName, providerConfigId?: string, scope: ProviderScope, log: FastifyBaseLogger }): Promise<{ model: LanguageModel, modelId: string, provider: AIProviderName }> { + const providerConfig = await resolveRunProvider({ platformId, scope, log, ...spreadIfDefined('provider', provider), ...spreadIfDefined('providerConfigId', providerConfigId) }) const modelId = resolveModelIdForProvider({ provider: providerConfig.provider, selectedModel: tierId }) return { model: agentAiUtils.createChatModel({ @@ -198,16 +199,16 @@ async function resolveTierModel({ platformId, tierId, provider, scope, log }: { } } -async function resolveFastModel({ platformId, provider, scope, log }: { platformId: string, provider?: AIProviderName, scope: ProviderScope, log: FastifyBaseLogger }): Promise { - return (await resolveTierModel({ platformId, tierId: FAST_TIER_ID, scope, log, ...spreadIfDefined('provider', provider) })).model +async function resolveFastModel({ platformId, provider, providerConfigId, scope, log }: { platformId: string, provider?: AIProviderName, providerConfigId?: string, scope: ProviderScope, log: FastifyBaseLogger }): Promise { + return (await resolveTierModel({ platformId, tierId: FAST_TIER_ID, scope, log, ...spreadIfDefined('provider', provider), ...spreadIfDefined('providerConfigId', providerConfigId) })).model } function resolveFastModelId({ provider }: { provider: AIProviderName }): string { return resolveModelIdForProvider({ provider, selectedModel: FAST_TIER_ID }) } -async function resolveEmbeddingModel({ platformId, provider, scope, log }: { platformId: string, provider?: AIProviderName, scope: ProviderScope, log: FastifyBaseLogger }): Promise<{ model: EmbeddingModel, providerOptions: SharedV3ProviderOptions }> { - const providerConfig = await resolveRunProvider({ platformId, scope, log, ...spreadIfDefined('provider', provider) }) +async function resolveEmbeddingModel({ platformId, provider, providerConfigId, scope, log }: { platformId: string, provider?: AIProviderName, providerConfigId?: string, scope: ProviderScope, log: FastifyBaseLogger }): Promise<{ model: EmbeddingModel, providerOptions: SharedV3ProviderOptions }> { + const providerConfig = await resolveRunProvider({ platformId, scope, log, ...spreadIfDefined('provider', provider), ...spreadIfDefined('providerConfigId', providerConfigId) }) return agentAiUtils.createEmbeddingModel({ provider: providerConfig.provider, auth: providerConfig.auth, diff --git a/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts b/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts index ad5d9d6fc9f4..cb112a4e3a49 100644 --- a/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts +++ b/packages/server/api/src/app/ee/agent/agent-rpc-handlers.ts @@ -119,7 +119,7 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ // it then adopted. A flow step reads its own conversation's project rather than the // selection above, which narrows to what the owner can still see in chat. const runProjectId = isFlowStep ? conversation.projectId ?? null : selectedProjectId - const providerConfig = await agentHelpers.resolveRunProvider({ platformId, log, scope: agentHelpers.runScopeOrThrow({ projectId: runProjectId }), ...spreadIfDefined('provider', input.provider) }) + const providerConfig = await agentHelpers.resolveRunProvider({ platformId, log, scope: agentHelpers.runScopeOrThrow({ projectId: runProjectId }), ...spreadIfDefined('provider', input.provider), ...spreadIfDefined('providerConfigId', input.providerConfigId) }) const attachmentRefs = files && files.length > 0 && !isNil(selectedProjectId) ? await persistAgentAttachments({ files, projectId: selectedProjectId, platformId, log }) @@ -270,6 +270,7 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ return { provider: providerConfig.provider, + providerConfigId: providerConfig.configId, auth: providerConfig.auth as Record, providerConfig: providerConfig.config as Record, modelId: resolvedModelId, @@ -411,6 +412,7 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ fromProjectId: conversation.projectId ?? null, toProjectId: input.projectId, ...spreadIfDefined('provider', input.provider), + ...spreadIfDefined('providerConfigId', input.providerConfigId), log, }) } @@ -456,7 +458,7 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, params: { message: 'This run is not allowed to run a configured piece tool' } }) } const { projectId, platformId } = conversation - const model = await agentHelpers.resolveFastModel({ platformId, scope: { type: 'project', projectId }, log, ...spreadIfDefined('provider', input.provider) }) + const model = await agentHelpers.resolveFastModel({ platformId, scope: { type: 'project', projectId }, log, ...spreadIfDefined('provider', input.provider), ...spreadIfDefined('providerConfigId', input.providerConfigId) }) const { data: run, error: runError } = await tryCatch(() => pieceToolRunner.runFromInstruction({ model, piece: { pieceName: input.piece.pieceName, actionName: input.piece.actionName, pieceVersion: input.piece.pieceVersion }, @@ -482,7 +484,7 @@ export const agentRpcHandlers = (log: FastifyBaseLogger) => ({ } const { projectId, platformId } = conversation await knowledgeBaseService(log).getFileOrThrow({ projectId, id: input.knowledgeBaseFileId }) - const { model, providerOptions } = await agentHelpers.resolveEmbeddingModel({ platformId, scope: { type: 'project', projectId }, log, ...spreadIfDefined('provider', input.provider) }) + const { model, providerOptions } = await agentHelpers.resolveEmbeddingModel({ platformId, scope: { type: 'project', projectId }, log, ...spreadIfDefined('provider', input.provider), ...spreadIfDefined('providerConfigId', input.providerConfigId) }) const { embedding } = await embed({ model, value: input.query, providerOptions }) const results = await knowledgeBaseService(log).search({ projectId, diff --git a/packages/server/api/src/app/ee/agent/agent-run-controller.ts b/packages/server/api/src/app/ee/agent/agent-run-controller.ts index ce28c8107fa8..23ab53745c9e 100644 --- a/packages/server/api/src/app/ee/agent/agent-run-controller.ts +++ b/packages/server/api/src/app/ee/agent/agent-run-controller.ts @@ -1,4 +1,4 @@ -import { ActivepiecesError, apId, ApId, assertNotNullOrUndefined, ErrorCode, isNil, unique } from '@activepieces/core-utils' +import { ActivepiecesError, apId, ApId, assertNotNullOrUndefined, ErrorCode, isNil, spreadIfDefined, unique } from '@activepieces/core-utils' import { AgentFlowTool, AgentOutputField, AgentRunSource, AgentTool, AgentToolType, AIProviderName, LATEST_JOB_DATA_SCHEMA_VERSION, MAX_AGENT_OUTPUT_FIELDS, MAX_AGENT_STEP_BUDGET, MAX_AGENT_TEXT_LENGTH, MAX_AGENT_TOOLS, PrincipalType, ResolvedAgentFlowTool, TASK_COMPLETION_TOOL_NAME, WorkerJobType } from '@activepieces/shared' import { FastifyBaseLogger } from 'fastify' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' @@ -16,7 +16,7 @@ const RUN_PRINCIPALS = [PrincipalType.ENGINE] as const export const agentRunController: FastifyPluginAsyncZod = async (app) => { app.post('/runs', StartAgentRunRoute, async (request, reply) => { - const { instruction, modelName, provider, flowRunId, waitpointId, tools, structuredOutput, maxSteps } = request.body + const { instruction, modelName, provider, providerConfigId, flowRunId, waitpointId, tools, structuredOutput, maxSteps } = request.body if (request.principal.type !== PrincipalType.ENGINE) { throw new ActivepiecesError({ code: ErrorCode.AUTHORIZATION, @@ -48,7 +48,7 @@ export const agentRunController: FastifyPluginAsyncZod = async (app) => { }) } const flowTools = await resolveFlowTools({ projectId, flowToolRequests, log: request.log }) - await agentHelpers.assertRunProviderConfigured({ platformId: platform.id, provider, scope: agentHelpers.runScopeOrThrow({ projectId }), log: request.log }) + await agentHelpers.assertRunProviderConfigured({ platformId: platform.id, provider, providerConfigId, scope: agentHelpers.runScopeOrThrow({ projectId }), log: request.log }) await assertCreditsAndAppSumoNotExceeded({ platformId: platform.id, log: request.log }) const { ownerId } = await projectService(request.log).getOneOrThrow(projectId) @@ -77,6 +77,7 @@ export const agentRunController: FastifyPluginAsyncZod = async (app) => { structuredOutput, maxSteps, provider, + ...spreadIfDefined('providerConfigId', providerConfigId), }, }) @@ -141,6 +142,7 @@ const StartAgentRunRequest = z.object({ maxSteps: z.number().int().positive().max(MAX_AGENT_STEP_BUDGET).optional(), modelName: z.string().optional(), provider: z.enum(AIProviderName).optional(), + providerConfigId: z.string().optional(), }) const StartAgentRunResponse = z.object({ diff --git a/packages/server/api/test/integration/ce/ai-provider/ai-provider.test.ts b/packages/server/api/test/integration/ce/ai-provider/ai-provider.test.ts index df5b3938ec77..0016f62591a9 100644 --- a/packages/server/api/test/integration/ce/ai-provider/ai-provider.test.ts +++ b/packages/server/api/test/integration/ce/ai-provider/ai-provider.test.ts @@ -2,12 +2,12 @@ import { AIProviderName, apId } from '@activepieces/core-utils' import { AIProviderModelType, DefaultProjectRole, PrincipalType, ProviderModelConfig } from '@activepieces/shared' import { FastifyInstance } from 'fastify' import { StatusCodes } from 'http-status-codes' +import { aiProviderService } from '../../../../src/app/ai/ai-provider-service' import { generateMockToken } from '../../../helpers/auth' import { db } from '../../../helpers/db' import { createMockProject, mockAndSaveAIProvider } from '../../../helpers/mocks' import { createMemberContext, createTestContext, TestContext } from '../../../helpers/test-context' import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' -import { aiProviderService } from '../../../../src/app/ai/ai-provider-service' let app: FastifyInstance | null = null let ctx: TestContext @@ -232,7 +232,10 @@ describe('AI Providers API', () => { expect(response?.statusCode).toBe(StatusCodes.OK) for (const entry of response?.json()) { - expect(Object.keys(entry).sort()).toEqual(['enabledForChat', 'name', 'provider']) + expect(Object.keys(entry).sort()).toEqual(['enabledForChat', 'keys', 'name', 'provider']) + for (const key of entry.keys) { + expect(Object.keys(key).sort()).toEqual(['id', 'name']) + } } }) @@ -660,6 +663,206 @@ describe('AI Providers API', () => { expect(adminResponse?.statusCode).toBe(StatusCodes.OK) expect(adminResponse?.json().map((m: { id: string }) => m.id).sort()).toEqual(['model-a', 'model-b']) }) + + it('carries every eligible key of a provider on its single project-list entry', async () => { + const first = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Key A', + config: customConfig('https://a.example.com'), + }) + const second = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Key B', + config: customConfig('https://b.example.com'), + }) + + const response = await engineGet('/api/v1/ai-providers', ctx.project.id) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const customRow = response?.json().find((p: { provider: string }) => p.provider === AIProviderName.CUSTOM) + expect(customRow.name).toBe('OpenAI Compatible') + expect(customRow.keys.map((key: { id: string }) => key.id).sort()).toEqual([first.id, second.id].sort()) + expect(customRow.keys.map((key: { name: string }) => key.name).sort()).toEqual(['Key A', 'Key B']) + }) + }) + + describe('pinning a specific key', () => { + const customConfig = (baseUrl: string, models: ProviderModelConfig[] = []) => ({ + baseUrl, + apiKeyHeader: 'Authorization', + models, + }) + + const engineGet = async (path: string, projectId: string) => { + const engineToken = await generateMockToken({ + type: PrincipalType.ENGINE, + id: apId(), + projectId, + platform: { id: ctx.platform.id }, + }) + return app!.inject({ + method: 'GET', + url: path, + headers: { authorization: `Bearer ${engineToken}` }, + }) + } + + it('serves the named key rather than the deterministic winner', async () => { + const older = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Older', + config: customConfig('https://older.example.com'), + created: '2026-08-01T00:00:00.000Z', + }) + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Newer', + config: customConfig('https://newer.example.com'), + created: '2026-08-10T00:00:00.000Z', + }) + + const pinned = await engineGet(`/api/v1/ai-providers/${AIProviderName.CUSTOM}/config?configId=${older.id}`, ctx.project.id) + expect(pinned?.statusCode).toBe(StatusCodes.OK) + expect(pinned?.json().config.baseUrl).toBe('https://older.example.com') + expect(pinned?.json().configId).toBe(older.id) + + const automatic = await engineGet(`/api/v1/ai-providers/${AIProviderName.CUSTOM}/config`, ctx.project.id) + expect(automatic?.json().config.baseUrl).toBe('https://newer.example.com') + }) + + it('refuses a key whose project scope excludes the caller project', async () => { + const excluded = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Other project only', + config: customConfig('https://other.example.com'), + projectScope: 'selected', + projectIds: [apId()], + }) + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'This project', + config: customConfig('https://mine.example.com'), + }) + + const response = await engineGet(`/api/v1/ai-providers/${AIProviderName.CUSTOM}/config?configId=${excluded.id}`, ctx.project.id) + + expect(response?.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + + it('refuses a key that belongs to another provider than the path names', async () => { + const openai = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.OPENAI, + displayName: 'OpenAI key', + }) + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Custom key', + config: customConfig('https://custom.example.com'), + }) + + const response = await engineGet(`/api/v1/ai-providers/${AIProviderName.CUSTOM}/config?configId=${openai.id}`, ctx.project.id) + + expect(response?.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + + it('applies the named key model allow-list', async () => { + const models = [ + { modelId: 'model-a', modelName: 'Model A', modelType: AIProviderModelType.TEXT }, + { modelId: 'model-b', modelName: 'Model B', modelType: AIProviderModelType.TEXT }, + ] + const restricted = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Restricted', + config: customConfig('https://restricted.example.com', models), + modelScope: 'selected', + modelIds: ['model-a'], + created: '2026-08-01T00:00:00.000Z', + }) + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Unrestricted', + config: customConfig('https://unrestricted.example.com', models), + created: '2026-08-10T00:00:00.000Z', + }) + + const pinned = await engineGet(`/api/v1/ai-providers/${AIProviderName.CUSTOM}/models?configId=${restricted.id}`, ctx.project.id) + expect(pinned?.statusCode).toBe(StatusCodes.OK) + expect(pinned?.json().map((m: { id: string }) => m.id)).toEqual(['model-a']) + + const automatic = await engineGet(`/api/v1/ai-providers/${AIProviderName.CUSTOM}/models`, ctx.project.id) + expect(automatic?.json().map((m: { id: string }) => m.id).sort()).toEqual(['model-a', 'model-b']) + }) + }) + + describe('key names are unique per provider', () => { + const customConfig = (baseUrl: string) => ({ + baseUrl, + apiKeyHeader: 'Authorization', + models: [], + }) + + const createCustom = (displayName: string, baseUrl: string) => ctx.post('/v1/ai-providers', { + provider: AIProviderName.CUSTOM, + displayName, + config: customConfig(baseUrl), + auth: { apiKey: 'test-key' }, + }) + + it('rejects a second key of the same provider with the same name', async () => { + expect((await createCustom('Anthropic key', 'https://one.example.com'))?.statusCode).toBe(StatusCodes.OK) + + const duplicate = await createCustom(' anthropic KEY ', 'https://two.example.com') + + expect(duplicate?.statusCode).toBe(StatusCodes.CONFLICT) + }) + + it('allows the same name on a different provider', async () => { + await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.OPENAI, + displayName: 'Shared name', + }) + + const response = await createCustom('Shared name', 'https://one.example.com') + + expect(response?.statusCode).toBe(StatusCodes.OK) + }) + + it('allows the same credentials under two different names', async () => { + expect((await createCustom('Key one', 'https://one.example.com'))?.statusCode).toBe(StatusCodes.OK) + expect((await createCustom('Key two', 'https://two.example.com'))?.statusCode).toBe(StatusCodes.OK) + }) + + it('rejects renaming a key onto another key of the same provider', async () => { + const first = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Taken', + config: customConfig('https://one.example.com'), + }) + const second = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.CUSTOM, + displayName: 'Free', + config: customConfig('https://two.example.com'), + }) + + const clash = await ctx.post(`/v1/ai-providers/${second.id}`, { displayName: 'Taken' }) + expect(clash?.statusCode).toBe(StatusCodes.CONFLICT) + + const sameName = await ctx.post(`/v1/ai-providers/${first.id}`, { displayName: 'Taken' }) + expect(sameName?.statusCode).toBe(StatusCodes.OK) + }) }) describe('keyServesScope (a run that changes project mid-turn)', () => { diff --git a/packages/server/api/test/unit/app/ee/agent/agent-fast-model-provider.test.ts b/packages/server/api/test/unit/app/ee/agent/agent-fast-model-provider.test.ts index c5a48a08d400..0a44291b14b1 100644 --- a/packages/server/api/test/unit/app/ee/agent/agent-fast-model-provider.test.ts +++ b/packages/server/api/test/unit/app/ee/agent/agent-fast-model-provider.test.ts @@ -29,6 +29,7 @@ describe('resolveFastModel', () => { mockGetChatProvider.mockClear().mockResolvedValue(null) mockGetConfigOrThrow.mockClear().mockResolvedValue({ provider: AIProviderName.OPENROUTER, + configId: 'config-1', auth: { apiKey: 'k' }, config: {}, }) @@ -42,6 +43,12 @@ describe('resolveFastModel', () => { expect(model).toMatchObject({ provider: AIProviderName.OPENROUTER, modelId: 'anthropic/claude-haiku-4.5' }) }) + it('asks for the pinned key when the run names one', async () => { + await agentHelpers.resolveFastModel({ platformId, provider: AIProviderName.OPENROUTER, providerConfigId: 'config-2', scope, log }) + + expect(mockGetConfigOrThrow).toHaveBeenCalledWith({ platformId, provider: AIProviderName.OPENROUTER, scope, configId: 'config-2' }) + }) + it('still refuses when no provider is named and the platform has no chat provider', async () => { await expect(agentHelpers.resolveFastModel({ platformId, scope, log })).rejects.toMatchObject({ error: { code: 'ENTITY_NOT_FOUND', params: { entityType: 'ChatAiProvider' } }, diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts index 7f875f1fa6cb..a8c9e6985e6f 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/execute-agent-run.ts @@ -89,6 +89,7 @@ export const executeAgentRunJob: JobHandler log: JobContext['log'] phaseState: { phase: AgentPhase } @@ -556,7 +559,7 @@ function buildToolSet({ ctx, eventEmitter, log, phaseState, taintState, mcpToolS const selectedConnectionByPiece = new Map() const localTools = agentWorkerTools.createLocalTools({ onSetProjectContext: async (projectId) => { - const { error } = await tryCatch(() => ctx.apiClient.updateProjectContext({ conversationId, runId, projectId, provider })) + const { error } = await tryCatch(() => ctx.apiClient.updateProjectContext({ conversationId, runId, projectId, provider, providerConfigId })) if (!isNil(error)) { log.warn({ error, conversation: { id: conversationId }, project: projectId ? { id: projectId } : undefined }, '[executeAgentRun] Refused a project switch') return { success: false, error: 'Could not switch to that project on this chat — the AI provider key this chat runs on is not available there. Start a new chat in that project instead.' } @@ -638,7 +641,7 @@ function buildToolSet({ ctx, eventEmitter, log, phaseState, taintState, mcpToolS // answer, and an agent that asks an empty room reads the silence as a refusal and stops. const configuredTools = agentWorkerTools.createConfiguredPieceTools({ tools: dryRun || discoveryOnly ? [] : configuredPieceTools, - runPieceTool: ({ toolName, instruction, piece }) => ctx.apiClient.executePieceTool({ conversationId, toolName, instruction, piece, provider }), + runPieceTool: ({ toolName, instruction, piece }) => ctx.apiClient.executePieceTool({ conversationId, toolName, instruction, piece, provider, providerConfigId }), log, }) const configuredFlowToolSet = agentWorkerTools.createConfiguredFlowTools({ @@ -648,7 +651,7 @@ function buildToolSet({ ctx, eventEmitter, log, phaseState, taintState, mcpToolS }) const knowledgeBaseTools = agentWorkerTools.createConfiguredKnowledgeBaseTools({ tools: dryRun || discoveryOnly ? [] : configuredKnowledgeBaseTools, - runKnowledgeBaseTool: ({ toolName, knowledgeBaseFileId, query }) => ctx.apiClient.executeKnowledgeBaseTool({ conversationId, toolName, knowledgeBaseFileId, query, provider }), + runKnowledgeBaseTool: ({ toolName, knowledgeBaseFileId, query }) => ctx.apiClient.executeKnowledgeBaseTool({ conversationId, toolName, knowledgeBaseFileId, query, provider, providerConfigId }), log, }) const completionTool = structuredOutput.length === 0 diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index dc638fc5ad2a..aec77bdbcc3d 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -2474,5 +2474,6 @@ "Activate trial key": "Activate trial key", "Activate Trial Key": "Activate Trial Key", "Enter your trial key to unlock enterprise features.": "Enter your trial key to unlock enterprise features.", - "Enter your trial key": "Enter your trial key" + "Enter your trial key": "Enter your trial key", + "Could not save this key": "Could not save this key" } diff --git a/packages/web/src/app/builder/step-settings/agent-settings/index.tsx b/packages/web/src/app/builder/step-settings/agent-settings/index.tsx index ecf2f903e327..b4e59845c93e 100644 --- a/packages/web/src/app/builder/step-settings/agent-settings/index.tsx +++ b/packages/web/src/app/builder/step-settings/agent-settings/index.tsx @@ -137,10 +137,12 @@ const selectAgentFormComponentForProperty = ( case AgentPieceProps.AI_PROVIDER_MODEL: { const provider = (field.value as AgentProviderModel).provider; const model = (field.value as AgentProviderModel).model; + const configId = (field.value as AgentProviderModel).configId; return ( diff --git a/packages/web/src/app/routes/agents/id/index.tsx b/packages/web/src/app/routes/agents/id/index.tsx index edbe857a08e0..bb5f0c45d426 100644 --- a/packages/web/src/app/routes/agents/id/index.tsx +++ b/packages/web/src/app/routes/agents/id/index.tsx @@ -336,13 +336,17 @@ const ConfigureFields = ({ { + defaultConfigId={form.watch('draft.providerConfigId') ?? undefined} + onChange={({ provider, model, configId }) => { form.setValue('draft.provider', parseProvider(provider), { shouldDirty: true, }); form.setValue('draft.modelName', model ?? null, { shouldDirty: true, }); + form.setValue('draft.providerConfigId', configId ?? null, { + shouldDirty: true, + }); }} /> diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/index.tsx b/packages/web/src/app/routes/platform/setup/ai/providers-tab/index.tsx index 1deba6d1b249..27219d53f367 100644 --- a/packages/web/src/app/routes/platform/setup/ai/providers-tab/index.tsx +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/index.tsx @@ -12,6 +12,7 @@ import { } from 'lucide-react'; import { useState } from 'react'; import { useSearchParams } from 'react-router-dom'; +import { toast } from 'sonner'; import { ConfirmationDeleteDialog } from '@/components/custom/delete-dialog'; import { Button } from '@/components/ui/button'; @@ -84,6 +85,14 @@ export function ProvidersTab() { const { mutate: updateProvider, isPending: isSaving } = aiProviderMutations.useUpdateAiProvider({ onSuccess: () => refetch(), + onError: (error) => { + const data = error.response?.data; + toast.error( + t( + data?.params?.message ?? data?.message ?? 'Could not save this key', + ), + ); + }, }); const openConfig = (id: string) => { diff --git a/packages/web/src/features/agents/ai-model/hooks.ts b/packages/web/src/features/agents/ai-model/hooks.ts index 32a1019ba4d2..5645a1147e47 100644 --- a/packages/web/src/features/agents/ai-model/hooks.ts +++ b/packages/web/src/features/agents/ai-model/hooks.ts @@ -61,10 +61,10 @@ export const aiModelHooks = { }); }, - useGetModelsForProvider: (provider?: AIProviderName) => { + useGetModelsForProvider: (provider?: AIProviderName, configId?: string) => { const projectId = authenticationSession.getProjectId(); return useQuery({ - queryKey: ['ai-models', provider, projectId], + queryKey: ['ai-models', provider, configId, projectId], enabled: !isNil(provider) && !isNil(projectId), queryFn: async () => { if (isNil(provider) || isNil(projectId)) return []; @@ -72,6 +72,7 @@ export const aiModelHooks = { const allModels = await aiProviderApi.listModelsForProvider( provider, projectId, + configId, ); return getAllowedModelsForProvider(provider, allModels, 'text'); diff --git a/packages/web/src/features/agents/ai-model/index.tsx b/packages/web/src/features/agents/ai-model/index.tsx index 5f21863d30bb..f7256246ed53 100644 --- a/packages/web/src/features/agents/ai-model/index.tsx +++ b/packages/web/src/features/agents/ai-model/index.tsx @@ -34,8 +34,13 @@ export const PROVIDER_EMBEDDING_MODELS: Partial< type AIModelSelectorProps = { defaultProvider?: AIProviderName; defaultModel?: string; + defaultConfigId?: string; disabled?: boolean; - onChange: (value: { provider?: string; model?: string }) => void; + onChange: (value: { + provider?: string; + model?: string; + configId?: string; + }) => void; }; const ACTIVEPIECES_PROVIDER_CONFIG = { @@ -50,6 +55,7 @@ const ALL_PROVIDERS = [...SUPPORTED_AI_PROVIDERS, ACTIVEPIECES_PROVIDER_CONFIG]; export function AIModelSelector({ defaultProvider, defaultModel, + defaultConfigId, disabled = false, onChange, }: AIModelSelectorProps) { @@ -61,47 +67,52 @@ export function AIModelSelector({ const [selectedModel, setSelectedModel] = React.useState( defaultModel, ); + const [selectedConfigId, setSelectedConfigId] = React.useState< + string | undefined + >(defaultConfigId); const { data: providers = [], isLoading: providersLoading } = aiModelHooks.useListProviders(); const { data: models = [], isLoading: modelsLoading } = - aiModelHooks.useGetModelsForProvider(selectedProvider); + aiModelHooks.useGetModelsForProvider(selectedProvider, selectedConfigId); const getProviderLogo = React.useCallback((providerName: string) => { return ALL_PROVIDERS.find((p) => p.provider === providerName)?.logoUrl; }, []); - const getProviderName = React.useCallback( - (providerName: string) => { - return ( - providers.find((p) => p.provider === providerName)?.name ?? providerName + const providerKeyOptions = React.useMemo(() => { + return [...providers] + .sort((a, b) => { + if (a.provider === AIProviderName.ACTIVEPIECES) return -1; + if (b.provider === AIProviderName.ACTIVEPIECES) return 1; + return 0; + }) + .flatMap((entry) => + entry.keys.map((key) => ({ + provider: entry.provider, + configId: key.id, + label: + entry.keys.length > 1 ? `${entry.name}: ${key.name}` : entry.name, + })), ); - }, - [providers], - ); - - const activepiecesProvider = React.useMemo( - () => providers.find((p) => p.provider === AIProviderName.ACTIVEPIECES), - [providers], - ); - - const sortedProviders = React.useMemo(() => { - return [...providers].sort((a, b) => { - if (a.provider === AIProviderName.ACTIVEPIECES) return -1; - if (b.provider === AIProviderName.ACTIVEPIECES) return 1; - return 0; - }); }, [providers]); + const selectedOptionLabel = providerKeyOptions.find( + (option) => + option.provider === selectedProvider && + option.configId === selectedConfigId, + )?.label; + React.useEffect(() => { - if (!selectedProvider && !providersLoading && providers.length > 0) { + if (!selectedProvider && !providersLoading && providerKeyOptions.length) { const preferred = - activepiecesProvider?.provider || providers[0]?.provider; - if (preferred) { - setSelectedProvider(preferred as AIProviderName); - } + providerKeyOptions.find( + (option) => option.provider === AIProviderName.ACTIVEPIECES, + ) ?? providerKeyOptions[0]; + setSelectedProvider(preferred.provider); + setSelectedConfigId(preferred.configId); } - }, [providers, providersLoading, selectedProvider, activepiecesProvider]); + }, [providerKeyOptions, providersLoading, selectedProvider]); React.useEffect(() => { if ( @@ -112,9 +123,20 @@ export function AIModelSelector({ ) { const firstModel = models[0].id; setSelectedModel(firstModel); - onChange({ provider: selectedProvider, model: firstModel }); + onChange({ + provider: selectedProvider, + model: firstModel, + configId: selectedConfigId, + }); } - }, [models, modelsLoading, selectedProvider, selectedModel, onChange]); + }, [ + models, + modelsLoading, + selectedProvider, + selectedModel, + selectedConfigId, + onChange, + ]); React.useEffect(() => { if ( @@ -125,20 +147,36 @@ export function AIModelSelector({ ) { const fallback = models[0]?.id; setSelectedModel(fallback); - onChange({ provider: selectedProvider, model: fallback }); + onChange({ + provider: selectedProvider, + model: fallback, + configId: selectedConfigId, + }); } - }, [models, selectedModel, selectedProvider, onChange, defaultModel]); + }, [ + models, + selectedModel, + selectedProvider, + selectedConfigId, + onChange, + defaultModel, + ]); - const handleProviderChange = (provider: AIProviderName) => { + const handleProviderChange = (provider: AIProviderName, configId: string) => { setSelectedProvider(provider); + setSelectedConfigId(configId); setSelectedModel(undefined); - onChange({ provider, model: undefined }); + onChange({ provider, model: undefined, configId }); setProviderOpen(false); }; const handleModelChange = (modelId: string) => { setSelectedModel(modelId); - onChange({ provider: selectedProvider, model: modelId }); + onChange({ + provider: selectedProvider, + model: modelId, + configId: selectedConfigId, + }); setModelOpen(false); }; @@ -171,7 +209,7 @@ export function AIModelSelector({ /> )} - {getProviderName(selectedProvider)} + {selectedOptionLabel ?? selectedProvider}
) : ( @@ -192,29 +230,29 @@ export function AIModelSelector({ {t('No provider found.')} - {sortedProviders.map((provider) => ( + {providerKeyOptions.map((option) => ( - handleProviderChange(provider.provider as AIProviderName) + handleProviderChange(option.provider, option.configId) } className="cursor-pointer" >
- {getProviderLogo(provider.provider) && ( + {getProviderLogo(option.provider) && ( {provider.provider} )} - {provider.name} + {option.label}
(`/v1/ai-providers/${provider}/models`, { projectId, + ...(configId === undefined ? {} : { configId }), }); }, listModelsForConfig(configId: string) { From c5ac55b0f485aee61e4e054aba6c45e2e68f6f3d Mon Sep 17 00:00:00 2001 From: Kishan Parmar <135701940+kishanprmr@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:20:35 +0530 Subject: [PATCH 5/5] chore(pieces): add outputSchema to PDF, File Helper, Facebook Pages, and Approval actions (#15009) --- bun.lock | 8 +-- .../community/facebook-pages/package.json | 2 +- .../src/lib/actions/create-photo-post.ts | 2 + .../src/lib/actions/create-post.ts | 2 + .../src/lib/actions/create-video-post.ts | 2 + .../facebook-pages/src/lib/output-schemas.ts | 16 +++++ packages/pieces/core/approval/package.json | 2 +- .../src/lib/actions/create-approval-link.ts | 2 + .../src/lib/actions/wait-for-approval.ts | 2 + .../core/approval/src/lib/output-schemas.ts | 12 ++++ packages/pieces/core/file-helper/package.json | 2 +- .../src/lib/actions/change-file-encoding.ts | 2 + .../src/lib/actions/check-file-type.ts | 2 + .../src/lib/actions/create-file.ts | 2 + .../src/lib/actions/get-file-name.ts | 2 + .../file-helper/src/lib/actions/read-file.ts | 2 + .../file-helper/src/lib/actions/unzip-file.ts | 2 + .../file-helper/src/lib/actions/zip-files.ts | 2 + .../file-helper/src/lib/output-schemas.ts | 61 +++++++++++++++++++ packages/pieces/core/pdf/package.json | 2 +- .../pdf/src/lib/actions/add-image-to-pdf.ts | 2 + .../pdf/src/lib/actions/add-text-to-pdf.ts | 2 + .../pdf/src/lib/actions/convert-to-image.ts | 2 + .../pdf/src/lib/actions/extract-pdf-pages.ts | 2 + .../core/pdf/src/lib/actions/extract-text.ts | 2 + .../core/pdf/src/lib/actions/image-to-pdf.ts | 2 + .../core/pdf/src/lib/actions/merge-pdfs.ts | 2 + .../pdf/src/lib/actions/pdf-page-count.ts | 2 + .../core/pdf/src/lib/actions/text-to-pdf.ts | 2 + .../pieces/core/pdf/src/lib/output-schemas.ts | 54 ++++++++++++++++ 30 files changed, 193 insertions(+), 8 deletions(-) create mode 100644 packages/pieces/community/facebook-pages/src/lib/output-schemas.ts create mode 100644 packages/pieces/core/approval/src/lib/output-schemas.ts create mode 100644 packages/pieces/core/file-helper/src/lib/output-schemas.ts create mode 100644 packages/pieces/core/pdf/src/lib/output-schemas.ts diff --git a/bun.lock b/bun.lock index 561fc2df4ede..f04ca2851ceb 100644 --- a/bun.lock +++ b/bun.lock @@ -3017,7 +3017,7 @@ }, "packages/pieces/community/facebook-pages": { "name": "@activepieces/piece-facebook-pages", - "version": "0.2.7", + "version": "0.2.8", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10195,7 +10195,7 @@ }, "packages/pieces/core/approval": { "name": "@activepieces/piece-approval", - "version": "0.1.27", + "version": "0.1.28", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10316,7 +10316,7 @@ }, "packages/pieces/core/file-helper": { "name": "@activepieces/piece-file-helper", - "version": "0.1.29", + "version": "0.1.30", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10430,7 +10430,7 @@ }, "packages/pieces/core/pdf": { "name": "@activepieces/piece-pdf", - "version": "0.5.10", + "version": "0.5.11", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/pieces/community/facebook-pages/package.json b/packages/pieces/community/facebook-pages/package.json index 7bf78d95b2f3..e5f02644c083 100644 --- a/packages/pieces/community/facebook-pages/package.json +++ b/packages/pieces/community/facebook-pages/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-facebook-pages", - "version": "0.2.7", + "version": "0.2.8", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/create-photo-post.ts b/packages/pieces/community/facebook-pages/src/lib/actions/create-photo-post.ts index fda8a8280fa2..751c49fa325b 100644 --- a/packages/pieces/community/facebook-pages/src/lib/actions/create-photo-post.ts +++ b/packages/pieces/community/facebook-pages/src/lib/actions/create-photo-post.ts @@ -2,6 +2,7 @@ import { createAction } from '@activepieces/pieces-framework'; import { facebookPagesCommon, FacebookPageDropdown } from '../common/common'; import { facebookPagesAuth } from '../..'; +import { createPhotoPostActionOutputSchema } from '../output-schemas'; export const createPhotoPost = createAction({ auth: facebookPagesAuth, @@ -12,6 +13,7 @@ export const createPhotoPost = createAction({ description: 'Create a photo on a Facebook Page you manage', audience: 'both', aiMetadata: { description: 'Publishes a photo post to a Facebook Page the connected account manages by uploading an image from a publicly reachable URL, with an optional caption. Choose this when the post is an image rather than plain text or video. Requires a managed page and a photo URL that Facebook can fetch; not idempotent, as each call creates a new photo post.', idempotent: false }, + outputSchema: createPhotoPostActionOutputSchema, props: { page: facebookPagesCommon.page, photo: facebookPagesCommon.photo, diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/create-post.ts b/packages/pieces/community/facebook-pages/src/lib/actions/create-post.ts index 19150ebbcbe7..6d3c36aafeed 100644 --- a/packages/pieces/community/facebook-pages/src/lib/actions/create-post.ts +++ b/packages/pieces/community/facebook-pages/src/lib/actions/create-post.ts @@ -1,6 +1,7 @@ import { createAction } from '@activepieces/pieces-framework'; import { facebookPagesCommon, FacebookPageDropdown } from '../common/common'; import { facebookPagesAuth } from '../..'; +import { createPostActionOutputSchema } from '../output-schemas'; export const createPost = createAction({ auth: facebookPagesAuth, @@ -10,6 +11,7 @@ export const createPost = createAction({ description: 'Create a post on a Facebook Page you manage', audience: 'both', aiMetadata: { description: 'Publishes a text status update to the feed of a Facebook Page the connected account manages, optionally attaching a link that Facebook will render as a preview card. Choose this for plain-text or link announcements; use the photo or video post actions for media uploads. Requires selecting a managed page (which supplies the page-scoped access token) and a message; not idempotent, as each call publishes a separate post.', idempotent: false }, + outputSchema: createPostActionOutputSchema, props: { page: facebookPagesCommon.page, message: facebookPagesCommon.message, diff --git a/packages/pieces/community/facebook-pages/src/lib/actions/create-video-post.ts b/packages/pieces/community/facebook-pages/src/lib/actions/create-video-post.ts index a556acbb761a..be734c15decd 100644 --- a/packages/pieces/community/facebook-pages/src/lib/actions/create-video-post.ts +++ b/packages/pieces/community/facebook-pages/src/lib/actions/create-video-post.ts @@ -1,6 +1,7 @@ import { createAction } from '@activepieces/pieces-framework'; import { FacebookPageDropdown, facebookPagesCommon } from '../common/common'; import { facebookPagesAuth } from '../..'; +import { createVideoPostActionOutputSchema } from '../output-schemas'; export const createVideoPost = createAction({ auth: facebookPagesAuth, @@ -10,6 +11,7 @@ export const createVideoPost = createAction({ description: 'Create a video on a Facebook Page you manage', audience: 'both', aiMetadata: { description: 'Publishes a video post to a Facebook Page the connected account manages by uploading a video from a publicly reachable URL, with an optional title and description. Choose this for video content rather than text or photo posts. Requires a managed page and a video URL Facebook can fetch (limit 1GB or 20 minutes); not idempotent, as each call uploads a new video.', idempotent: false }, + outputSchema: createVideoPostActionOutputSchema, props: { page: facebookPagesCommon.page, video: facebookPagesCommon.video, diff --git a/packages/pieces/community/facebook-pages/src/lib/output-schemas.ts b/packages/pieces/community/facebook-pages/src/lib/output-schemas.ts new file mode 100644 index 000000000000..6a78c3433c3e --- /dev/null +++ b/packages/pieces/community/facebook-pages/src/lib/output-schemas.ts @@ -0,0 +1,16 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +export const createPostActionOutputSchema: OutputSchema = { + fields: [{ key: 'id', label: 'Post ID' }], +}; + +export const createPhotoPostActionOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'Photo ID' }, + { key: 'post_id', label: 'Post ID' }, + ], +}; + +export const createVideoPostActionOutputSchema: OutputSchema = { + fields: [{ key: 'id', label: 'Video ID' }], +}; diff --git a/packages/pieces/core/approval/package.json b/packages/pieces/core/approval/package.json index e842289baacc..0fde7c3cada1 100644 --- a/packages/pieces/core/approval/package.json +++ b/packages/pieces/core/approval/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-approval", - "version": "0.1.27", + "version": "0.1.28", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/core/approval/src/lib/actions/create-approval-link.ts b/packages/pieces/core/approval/src/lib/actions/create-approval-link.ts index 3f9823c13912..3a58c816e2b8 100644 --- a/packages/pieces/core/approval/src/lib/actions/create-approval-link.ts +++ b/packages/pieces/core/approval/src/lib/actions/create-approval-link.ts @@ -1,5 +1,6 @@ import { createAction, PieceAuth, Property } from '@activepieces/pieces-framework'; import { MarkdownVariant } from '@activepieces/pieces-framework'; +import { createApprovalLinkActionOutputSchema } from '../output-schemas'; export const createApprovalLink = createAction({ audience: 'both', @@ -10,6 +11,7 @@ export const createApprovalLink = createAction({ description: 'Create links only without pausing the flow, use wait for approval to pause', aiMetadata: { description: 'Mints a resumable approval/disapproval link for the current flow run and returns it immediately without pausing execution, so a later step can email or post it. Pick this when the flow must continue after handing out the link; use Wait for Approval instead when the run should block until someone responds. Takes no inputs and is a legacy piece that remains supported because, unlike the approval actions in the Approvals tab, it hands out a link without pausing the run. Not idempotent, since each call creates a new waitpoint and the previously issued link is not returned.', idempotent: false }, + outputSchema: createApprovalLinkActionOutputSchema, props: { markdown: Property.MarkDown({ variant: MarkdownVariant.INFO, diff --git a/packages/pieces/core/approval/src/lib/actions/wait-for-approval.ts b/packages/pieces/core/approval/src/lib/actions/wait-for-approval.ts index c957252e2194..2515d8ef0585 100644 --- a/packages/pieces/core/approval/src/lib/actions/wait-for-approval.ts +++ b/packages/pieces/core/approval/src/lib/actions/wait-for-approval.ts @@ -1,5 +1,6 @@ import { createAction, PieceAuth, Property } from '@activepieces/pieces-framework'; import { ExecutionType, MarkdownVariant } from '@activepieces/pieces-framework'; +import { waitForApprovalActionOutputSchema } from '../output-schemas'; export const waitForApprovalLink = createAction({ audience: 'both', @@ -9,6 +10,7 @@ export const waitForApprovalLink = createAction({ displayName: 'Wait for Approval', description: 'Pauses the flow and wait for the approval from the user', aiMetadata: { description: 'Pauses the current flow run at a human approval gate and resumes only when the run\'s waitpoint resume URL is called with an approve or disapprove response, reporting the choice. Pick this when execution must block on a human decision; it returns no link itself, so use Create Approval Links when the flow should keep running and hand a link out. Takes no inputs and is a legacy piece: for a new flow prefer the approval actions in the Approvals tab, which deliver the request and wait in one step. Not idempotent, since each execution creates a new waitpoint.', idempotent: false }, + outputSchema: waitForApprovalActionOutputSchema, props: { markdown: Property.MarkDown({ variant: MarkdownVariant.INFO, diff --git a/packages/pieces/core/approval/src/lib/output-schemas.ts b/packages/pieces/core/approval/src/lib/output-schemas.ts new file mode 100644 index 000000000000..8bd82f8a04c8 --- /dev/null +++ b/packages/pieces/core/approval/src/lib/output-schemas.ts @@ -0,0 +1,12 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +export const createApprovalLinkActionOutputSchema: OutputSchema = { + fields: [ + { key: 'approvalLink', label: 'Approval Link', format: 'url' }, + { key: 'disapprovalLink', label: 'Disapproval Link', format: 'url' }, + ], +}; + +export const waitForApprovalActionOutputSchema: OutputSchema = { + fields: [{ key: 'approved', label: 'Approved', format: 'boolean' }], +}; diff --git a/packages/pieces/core/file-helper/package.json b/packages/pieces/core/file-helper/package.json index f257509602d4..0bfbf82ad8bc 100644 --- a/packages/pieces/core/file-helper/package.json +++ b/packages/pieces/core/file-helper/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-file-helper", - "version": "0.1.29", + "version": "0.1.30", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/core/file-helper/src/lib/actions/change-file-encoding.ts b/packages/pieces/core/file-helper/src/lib/actions/change-file-encoding.ts index 637992a347d1..58ed2d5446eb 100644 --- a/packages/pieces/core/file-helper/src/lib/actions/change-file-encoding.ts +++ b/packages/pieces/core/file-helper/src/lib/actions/change-file-encoding.ts @@ -1,5 +1,6 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import { encodings } from '../common/encodings'; +import { changeFileEncodingActionOutputSchema } from '../output-schemas'; export const changeFileEncoding = createAction({ audience: 'both', @@ -8,6 +9,7 @@ export const changeFileEncoding = createAction({ displayName: 'Change File Encoding', description: 'Changes the encoding of a file', aiMetadata: { description: 'Re-encodes the bytes of a file from one character encoding to another (e.g. latin1 to utf8), writing the result to a new file under the output name you give. Use it when a downstream step mis-reads a file because of its character set; use Read File to simply get the content as text, or Create File to build a file from a string. The declared source encoding must actually match the file, since it is decoded blindly - a wrong choice silently corrupts characters rather than failing; deterministic and idempotent.', idempotent: true }, + outputSchema: changeFileEncodingActionOutputSchema, props: { inputFile: Property.File({ displayName: 'Source file', diff --git a/packages/pieces/core/file-helper/src/lib/actions/check-file-type.ts b/packages/pieces/core/file-helper/src/lib/actions/check-file-type.ts index 41ff5984beb8..cb895db73b1f 100644 --- a/packages/pieces/core/file-helper/src/lib/actions/check-file-type.ts +++ b/packages/pieces/core/file-helper/src/lib/actions/check-file-type.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { predefinedMimeTypes } from '../common/mimeTypes'; import mime from 'mime-types'; +import { checkFileTypeActionOutputSchema } from '../output-schemas'; export const checkFileType = createAction({ audience: 'both', @@ -9,6 +10,7 @@ export const checkFileType = createAction({ displayName: 'Check file type', description: 'Check MIME type of a file and filter based on selected types', aiMetadata: { description: 'Determines the MIME type of a file from its extension and reports whether it matches the selected MIME type. Use it as a guard before a format-specific step (e.g. only continue for images or PDFs); use Get File Name if you only want the name, or Read File to get the contents. Detection is extension-based only - contents are never sniffed, so the extension is trusted as-is and only a missing or unrecognized one falls back to application/octet-stream; read-only and idempotent.', idempotent: true }, + outputSchema: checkFileTypeActionOutputSchema, props: { file: Property.File({ displayName: 'File to Check', diff --git a/packages/pieces/core/file-helper/src/lib/actions/create-file.ts b/packages/pieces/core/file-helper/src/lib/actions/create-file.ts index 2b4ac4400555..4c42e9555b91 100644 --- a/packages/pieces/core/file-helper/src/lib/actions/create-file.ts +++ b/packages/pieces/core/file-helper/src/lib/actions/create-file.ts @@ -1,5 +1,6 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { encodings } from '../common/encodings'; +import { createFileActionOutputSchema } from '../output-schemas'; export const createFile = createAction({ audience: 'both', @@ -9,6 +10,7 @@ export const createFile = createAction({ displayName: 'Create file', description: 'Create file from content', aiMetadata: { description: 'Writes a text string out to a new file with the given file name and character encoding (utf8 by default). Use it when a downstream step needs an actual file rather than a value - for example to attach generated CSV or JSON text to an email - and use Read File for the opposite direction. Requires the content and file name, and the encoding must be one of the supported Buffer encodings; nothing outside the run is changed, so it is idempotent.', idempotent: true }, + outputSchema: createFileActionOutputSchema, props: { content: Property.LongText({ displayName: 'Content', required: true }), fileName: Property.ShortText({ displayName: 'File name', required: true }), diff --git a/packages/pieces/core/file-helper/src/lib/actions/get-file-name.ts b/packages/pieces/core/file-helper/src/lib/actions/get-file-name.ts index 4ddf1bf9d28a..3655dfc29083 100644 --- a/packages/pieces/core/file-helper/src/lib/actions/get-file-name.ts +++ b/packages/pieces/core/file-helper/src/lib/actions/get-file-name.ts @@ -1,4 +1,5 @@ import { Property, createAction } from '@activepieces/pieces-framework'; +import { getFileNameActionOutputSchema } from '../output-schemas'; export const getFileName = createAction({ audience: 'both', @@ -7,6 +8,7 @@ export const getFileName = createAction({ displayName: 'Get File Name', description: 'Get the name of a file', aiMetadata: { description: 'Returns only the file name of an input file, without reading or decoding its contents. Use it when the name itself is what you need - to build a path, log it, or branch on the extension - and prefer Read File to get the content or Check File Type to resolve the MIME type. Requires a file input; read-only and idempotent.', idempotent: true }, + outputSchema: getFileNameActionOutputSchema, errorHandlingOptions: { continueOnFailure: { hide: true, diff --git a/packages/pieces/core/file-helper/src/lib/actions/read-file.ts b/packages/pieces/core/file-helper/src/lib/actions/read-file.ts index c7c4edc64132..31008a03bc53 100644 --- a/packages/pieces/core/file-helper/src/lib/actions/read-file.ts +++ b/packages/pieces/core/file-helper/src/lib/actions/read-file.ts @@ -1,5 +1,6 @@ import { Property, createAction } from '@activepieces/pieces-framework'; import mime from 'mime-types'; +import { readFileActionOutputSchema } from '../output-schemas'; export const filesOutput = { Text: 'text', @@ -13,6 +14,7 @@ export const readFileAction = createAction({ displayName: 'Read File', description: 'Read a file from the file system', aiMetadata: { description: 'Decodes an input file and returns its contents as either UTF-8 text or a base64 string, selected by the output-format option. Use it to turn a file from a trigger or earlier step into a usable value - Text for text-based files such as .txt/.json, Base64 for binary files or API payloads; use Create File for the reverse direction, and Convert CSV to JSON in the CSV piece when you need parsed rows. Requires a file input and an output format (any other value errors); read-only and idempotent.', idempotent: true }, + outputSchema: readFileActionOutputSchema, errorHandlingOptions: { continueOnFailure: { hide: true, diff --git a/packages/pieces/core/file-helper/src/lib/actions/unzip-file.ts b/packages/pieces/core/file-helper/src/lib/actions/unzip-file.ts index f6fcbbedbae0..4f611c0984c2 100644 --- a/packages/pieces/core/file-helper/src/lib/actions/unzip-file.ts +++ b/packages/pieces/core/file-helper/src/lib/actions/unzip-file.ts @@ -10,6 +10,7 @@ import { getMimeType, EntryGetDataOptions, } from '@zip.js/zip.js'; +import { unzipFileActionOutputSchema } from '../output-schemas'; interface Result { file: string; @@ -29,6 +30,7 @@ export const unzipFile = createAction({ displayName: 'Unzip File', description: 'Unzip compressed zip file', aiMetadata: { description: 'Extracts every file entry from a zip archive, writing each one out as its own file; supports password-protected archives. Use it to reach the contents of a zip attachment before processing them; use Zip Files for the reverse direction. Set Max Results to throw when an untrusted archive exceeds that entry count; directory entries are skipped, a wrong or missing password makes extraction fail, and the source archive is unchanged, so it is idempotent.', idempotent: true }, + outputSchema: unzipFileActionOutputSchema, props: { file: Property.File({ displayName: 'Zip File', diff --git a/packages/pieces/core/file-helper/src/lib/actions/zip-files.ts b/packages/pieces/core/file-helper/src/lib/actions/zip-files.ts index f8b93e2d6f6f..992d2ec32c9e 100644 --- a/packages/pieces/core/file-helper/src/lib/actions/zip-files.ts +++ b/packages/pieces/core/file-helper/src/lib/actions/zip-files.ts @@ -10,6 +10,7 @@ import { BlobReader, ZipWriterAddDataOptions, } from '@zip.js/zip.js'; +import { zipFilesActionOutputSchema } from '../output-schemas'; interface FileObject { file: ApFile; @@ -34,6 +35,7 @@ export const zipFiles = createAction({ displayName: 'Zip Files', description: 'Create compressed zip file from one or many files', aiMetadata: { description: 'Compresses one or many input files into a single zip archive, optionally placing each entry at a custom path inside the archive (e.g. foo/test.txt) and optionally password-protecting it with ZipCrypto or AES-256. Use it to bundle several files into one attachment or download; use Unzip File for the reverse direction. Requires the list of files and an output file name; entries default to their own file names, reusing the same in-zip path keeps only the later file, and there are no external side effects, so it is idempotent.', idempotent: true }, + outputSchema: zipFilesActionOutputSchema, props: { files: Property.Array({ displayName: 'Files', diff --git a/packages/pieces/core/file-helper/src/lib/output-schemas.ts b/packages/pieces/core/file-helper/src/lib/output-schemas.ts new file mode 100644 index 000000000000..ccde6f37ae20 --- /dev/null +++ b/packages/pieces/core/file-helper/src/lib/output-schemas.ts @@ -0,0 +1,61 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +// files.write() resolves to the new file's URL string, not an object -- value: +// '' labels the whole string so it isn't rendered as an unlabeled blob. +const fileUrlOutput = (description: string): OutputSchema => ({ + fields: [{ key: 'fileUrl', label: 'File URL', value: '', format: 'url', description }], +}); + +export const changeFileEncodingActionOutputSchema = fileUrlOutput( + 'Link to the re-encoded file.', +); + +export const zipFilesActionOutputSchema = fileUrlOutput( + 'Link to the zip archive.', +); + +export const checkFileTypeActionOutputSchema: OutputSchema = { + fields: [ + { key: 'mimeType', label: 'MIME Type' }, + { key: 'isMatch', label: 'Is Match', format: 'boolean' }, + ], +}; + +export const createFileActionOutputSchema: OutputSchema = { + fields: [ + { key: 'fileName', label: 'File Name' }, + { key: 'url', label: 'File URL', format: 'url' }, + ], +}; + +export const getFileNameActionOutputSchema: OutputSchema = { + fields: [{ key: 'fileName', label: 'File Name' }], +}; + +// Exactly one of these three is present, chosen by the Output Format dropdown. +export const readFileActionOutputSchema: OutputSchema = { + fields: [ + { key: 'text', label: 'Text', description: 'Present when Output Format is Text.' }, + { key: 'base64', label: 'Base64', description: 'Present when Output Format is Base64.' }, + { + key: 'base64WithMimeType', + label: 'Base64 Data URL', + description: 'Present when Output Format is Base64.', + }, + ], +}; + +export const unzipFileActionOutputSchema: OutputSchema = { + itemLabel: '{filePath}', + fields: [ + { + key: 'files', + label: 'Extracted Files', + value: '', + listItems: [ + { key: 'file', label: 'File URL', format: 'url' }, + { key: 'filePath', label: 'File Path' }, + ], + }, + ], +}; diff --git a/packages/pieces/core/pdf/package.json b/packages/pieces/core/pdf/package.json index 28a99afc2e20..97f798d14ae5 100644 --- a/packages/pieces/core/pdf/package.json +++ b/packages/pieces/core/pdf/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-pdf", - "version": "0.5.10", + "version": "0.5.11", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/core/pdf/src/lib/actions/add-image-to-pdf.ts b/packages/pieces/core/pdf/src/lib/actions/add-image-to-pdf.ts index c81754fc913a..fabc045e54a3 100644 --- a/packages/pieces/core/pdf/src/lib/actions/add-image-to-pdf.ts +++ b/packages/pieces/core/pdf/src/lib/actions/add-image-to-pdf.ts @@ -2,6 +2,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { PDFDocument, degrees } from 'pdf-lib'; import { getTargetPages, mapVisualToIntrinsic } from '../common'; import mime from 'mime-types'; +import { addImageToPdfActionOutputSchema } from '../output-schemas'; export const addImageToPdf = createAction({ audience: 'both', @@ -10,6 +11,7 @@ export const addImageToPdf = createAction({ displayName: 'Add Image to PDF', description: 'Stamps one or more images (PNG or JPG) at exact pixel distances from the top-left corner.', aiMetadata: { description: 'Stamps one or more PNG or JPEG images onto an existing PDF at exact point offsets from the top-left corner, each item targeting either a single page or every page. Use it for signatures, logos or watermarks — prefer Add Text to PDF for text, and Image to PDF to turn an image into a standalone document. Each image must have a positive scale and fit entirely within the page bounds, otherwise the call fails; the input file is never modified and repeating the call produces the same stamped content, so idempotent.', idempotent: true }, + outputSchema: addImageToPdfActionOutputSchema, props: { file: Property.File({ displayName: 'PDF File or URL', diff --git a/packages/pieces/core/pdf/src/lib/actions/add-text-to-pdf.ts b/packages/pieces/core/pdf/src/lib/actions/add-text-to-pdf.ts index 22cf6af1ce58..ffe362137caf 100644 --- a/packages/pieces/core/pdf/src/lib/actions/add-text-to-pdf.ts +++ b/packages/pieces/core/pdf/src/lib/actions/add-text-to-pdf.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { PDFDocument, rgb, StandardFonts, PDFFont, degrees } from 'pdf-lib'; import { getTargetPages, mapVisualToIntrinsic } from '../common'; +import { addTextToPdfActionOutputSchema } from '../output-schemas'; const fontOptions = Object.entries(StandardFonts).map(([key, value]) => { const formattedLabel = key.replace(/([A-Z])/g, ' $1').trim(); @@ -14,6 +15,7 @@ export const addTextToPdf = createAction({ displayName: 'Add Text to PDF', description: 'Stamps one or more text strings at exact pixel distances from the top-left corner.', aiMetadata: { description: 'Stamps one or more text strings onto an existing PDF at exact point offsets from the top-left corner, each item targeting either a single page or every page. Use it for headers, dates or reference numbers — prefer Add Image to PDF for signatures and logos, and Text to PDF to build a document from scratch. Only the 14 standard PDF fonts are available and the call fails if any text would run off a page edge; the input file is never modified and repeating the call produces the same stamped content, so idempotent.', idempotent: true }, + outputSchema: addTextToPdfActionOutputSchema, props: { file: Property.File({ displayName: 'PDF File or URL', diff --git a/packages/pieces/core/pdf/src/lib/actions/convert-to-image.ts b/packages/pieces/core/pdf/src/lib/actions/convert-to-image.ts index 0ad23fbcc435..f2115a70f240 100644 --- a/packages/pieces/core/pdf/src/lib/actions/convert-to-image.ts +++ b/packages/pieces/core/pdf/src/lib/actions/convert-to-image.ts @@ -6,6 +6,7 @@ import { exec } from 'child_process'; import { promisify } from 'util'; import { nanoid } from 'nanoid'; import Jimp from 'jimp'; +import { convertToImageActionOutputSchema } from '../output-schemas'; const execPromise = promisify(exec); const pdftoppmPath = '/usr/bin/pdftoppm'; @@ -73,6 +74,7 @@ export const convertToImage = createAction({ displayName: 'Convert to Image', description: 'Convert a PDF file or URL to an image', aiMetadata: { description: 'Rasterizes a PDF to PNG images, either as one tall combined image or as a separate image per page (selected via Output Image Type). Pick this when a downstream vision or OCR step needs pixels rather than a text layer; prefer Extract Text for text-based PDFs and Image to PDF for the reverse direction. Requires the pdftoppm binary on the worker and a PDF no larger than 16 MB; the render is deterministic, so idempotent.', idempotent: true }, + outputSchema: convertToImageActionOutputSchema, props: { file: Property.File({ displayName: 'PDF File or URL', diff --git a/packages/pieces/core/pdf/src/lib/actions/extract-pdf-pages.ts b/packages/pieces/core/pdf/src/lib/actions/extract-pdf-pages.ts index 5bdd5846c84e..a9b7058bd848 100644 --- a/packages/pieces/core/pdf/src/lib/actions/extract-pdf-pages.ts +++ b/packages/pieces/core/pdf/src/lib/actions/extract-pdf-pages.ts @@ -1,6 +1,7 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { PDFDocument } from 'pdf-lib'; import { MarkdownVariant } from '@activepieces/pieces-framework'; +import { extractPdfPagesActionOutputSchema } from '../output-schemas'; export function pageRangeToIndexes( startPage: number, @@ -66,6 +67,7 @@ export const extractPdfPages = createAction({ displayName: 'Extract PDF Pages', description: 'Extract or rearrange page(s)from PDF File.', aiMetadata: { description: 'Builds a new PDF from page ranges taken from a source PDF; because ranges are copied in the order given, it both extracts/trims and reorders pages. Use it to split or resequence one document — use Merge PDFs to join separate files, and PDF Page Count first if bounds are unknown. Ranges are 1-indexed and inclusive, negatives count from the end (start -5, end -1 is the last five pages), and no range may span across 0; the source file is untouched and repeating the call produces the same page content, so idempotent.', idempotent: true }, + outputSchema: extractPdfPagesActionOutputSchema, props: { markdown: Property.MarkDown({ variant: MarkdownVariant.INFO, diff --git a/packages/pieces/core/pdf/src/lib/actions/extract-text.ts b/packages/pieces/core/pdf/src/lib/actions/extract-text.ts index 104915fedb00..19de482115ac 100644 --- a/packages/pieces/core/pdf/src/lib/actions/extract-text.ts +++ b/packages/pieces/core/pdf/src/lib/actions/extract-text.ts @@ -1,5 +1,6 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { extractText as pdfExtractText, getDocumentProxy } from 'unpdf'; +import { extractTextActionOutputSchema } from '../output-schemas'; export const extractText = createAction({ audience: 'both', @@ -8,6 +9,7 @@ export const extractText = createAction({ displayName: 'Extract Text', description: 'Extract text from PDF file or url', aiMetadata: { description: 'Extracts the text layer of a PDF supplied as an uploaded file or a URL, merging every page into a single string. Use this to read a text-based PDF before parsing, classifying or summarizing it; for a scanned/rasterized PDF use Convert to Image plus an OCR or vision step instead, and use PDF Page Count when only the document length matters. Requires a loadable PDF and returns nothing useful when the pages are just embedded scan images; read-only and idempotent.', idempotent: true }, + outputSchema: extractTextActionOutputSchema, props: { file: Property.File({ displayName: 'PDF File or URL', diff --git a/packages/pieces/core/pdf/src/lib/actions/image-to-pdf.ts b/packages/pieces/core/pdf/src/lib/actions/image-to-pdf.ts index 6bea57297d03..519f62115d6b 100644 --- a/packages/pieces/core/pdf/src/lib/actions/image-to-pdf.ts +++ b/packages/pieces/core/pdf/src/lib/actions/image-to-pdf.ts @@ -1,5 +1,6 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { PDFDocument, PDFImage, RotationTypes, PageSizes } from 'pdf-lib'; +import { imageToPdfActionOutputSchema } from '../output-schemas'; export const imageToPdf = createAction({ audience: 'both', @@ -8,6 +9,7 @@ export const imageToPdf = createAction({ displayName: 'Image to PDF', description: 'Convert image to PDF', aiMetadata: { description: 'Wraps a single PNG or JPEG image into a new one-page A4 PDF, scaling it to fit inside the margins and correcting the EXIF orientation. Use it to make an image attachable or printable as a document; use Add Image to PDF to stamp an image onto an existing PDF, and Merge PDFs to combine several single-image results. Accepts one image per call and only the png/jpg/jpeg extensions; layout and page size are fixed and repeating the call produces the same document content, so idempotent.', idempotent: true }, + outputSchema: imageToPdfActionOutputSchema, props: { image: Property.File({ displayName: 'image', diff --git a/packages/pieces/core/pdf/src/lib/actions/merge-pdfs.ts b/packages/pieces/core/pdf/src/lib/actions/merge-pdfs.ts index 97aed1a6a5cb..0e9cd2cf9582 100644 --- a/packages/pieces/core/pdf/src/lib/actions/merge-pdfs.ts +++ b/packages/pieces/core/pdf/src/lib/actions/merge-pdfs.ts @@ -1,5 +1,6 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { PDFDocument } from 'pdf-lib'; +import { mergePdfsActionOutputSchema } from '../output-schemas'; export const mergePdfs = createAction({ audience: 'both', @@ -8,6 +9,7 @@ export const mergePdfs = createAction({ displayName: 'Merge PDFs', description: 'Merges multiple PDF files into a single PDF document.', aiMetadata: { description: 'Concatenates two or more PDF files into one new PDF, keeping the order in which the files are listed. Use it to join separate documents; use Extract PDF Pages to pull out or resequence pages inside a single document, and Image to PDF first if a source is an image rather than a PDF. Requires at least 2 files and each filename must end in .pdf; the source files are never modified and repeating the call produces the same merged content, so idempotent.', idempotent: true }, + outputSchema: mergePdfsActionOutputSchema, props: { pdfFiles: Property.Array({ displayName: 'PDF Files', diff --git a/packages/pieces/core/pdf/src/lib/actions/pdf-page-count.ts b/packages/pieces/core/pdf/src/lib/actions/pdf-page-count.ts index abf0e7572c33..aafdc3eed8b0 100644 --- a/packages/pieces/core/pdf/src/lib/actions/pdf-page-count.ts +++ b/packages/pieces/core/pdf/src/lib/actions/pdf-page-count.ts @@ -1,5 +1,6 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { PDFDocument } from 'pdf-lib'; +import { pdfPageCountActionOutputSchema } from '../output-schemas'; export const pdfPageCount = createAction({ audience: 'both', @@ -8,6 +9,7 @@ export const pdfPageCount = createAction({ displayName: 'PDF Page Count', description: 'Get page count of PDF file.', aiMetadata: { description: 'Returns the number of pages in a PDF given as an uploaded file or a URL. Use it as a cheap pre-check before any paging, splitting or looping logic — Extract PDF Pages performs the actual splitting and Extract Text pulls the content. Requires a loadable, non-encrypted PDF; read-only and idempotent.', idempotent: true }, + outputSchema: pdfPageCountActionOutputSchema, props: { file: Property.File({ displayName: 'PDF File or URL', diff --git a/packages/pieces/core/pdf/src/lib/actions/text-to-pdf.ts b/packages/pieces/core/pdf/src/lib/actions/text-to-pdf.ts index 1f7f416c5c1d..c3ff9cac39d7 100644 --- a/packages/pieces/core/pdf/src/lib/actions/text-to-pdf.ts +++ b/packages/pieces/core/pdf/src/lib/actions/text-to-pdf.ts @@ -1,5 +1,6 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { PDFDocument, StandardFonts } from 'pdf-lib'; +import { textToPdfActionOutputSchema } from '../output-schemas'; export const textToPdf = createAction({ audience: 'both', @@ -8,6 +9,7 @@ export const textToPdf = createAction({ displayName: 'Text to PDF', description: 'Convert text to PDF', aiMetadata: { description: 'Renders a plain-text string into a brand-new A4 PDF, word-wrapping and paginating automatically at a fixed Helvetica 12pt layout. Use it to turn text into an attachable document; use Add Text to PDF to stamp text onto an existing PDF instead, and Image to PDF when the source is an image. Text is the only input and newlines are treated as paragraph breaks; the same text always produces the same document content, so idempotent.', idempotent: true }, + outputSchema: textToPdfActionOutputSchema, props: { text: Property.LongText({ displayName: 'text', diff --git a/packages/pieces/core/pdf/src/lib/output-schemas.ts b/packages/pieces/core/pdf/src/lib/output-schemas.ts new file mode 100644 index 000000000000..2aa0f6a3d100 --- /dev/null +++ b/packages/pieces/core/pdf/src/lib/output-schemas.ts @@ -0,0 +1,54 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +// files.write() resolves to the new file's URL string, not an object -- value: +// '' labels the whole string so it isn't rendered as an unlabeled blob. +const pdfUrlOutput = (description: string): OutputSchema => ({ + fields: [{ key: 'fileUrl', label: 'PDF File URL', value: '', format: 'url', description }], +}); + +export const addImageToPdfActionOutputSchema = pdfUrlOutput( + 'Link to the PDF with the stamped image(s).', +); + +export const addTextToPdfActionOutputSchema = pdfUrlOutput( + 'Link to the PDF with the stamped text.', +); + +export const extractPdfPagesActionOutputSchema = pdfUrlOutput( + 'Link to the new PDF built from the selected page ranges.', +); + +export const imageToPdfActionOutputSchema = pdfUrlOutput( + 'Link to the generated one-page PDF.', +); + +export const mergePdfsActionOutputSchema = pdfUrlOutput('Link to the merged PDF.'); + +export const textToPdfActionOutputSchema = pdfUrlOutput('Link to the generated PDF.'); + +export const extractTextActionOutputSchema: OutputSchema = { + fields: [{ key: 'text', label: 'Extracted Text', value: '' }], +}; + +export const pdfPageCountActionOutputSchema: OutputSchema = { + fields: [{ key: 'pageCount', label: 'Page Count', value: '', format: 'number' }], +}; + +// Exactly one of these is present, chosen by the Output Image Type dropdown. +export const convertToImageActionOutputSchema: OutputSchema = { + fields: [ + { + key: 'image', + label: 'Combined Image URL', + format: 'url', + description: 'Present when Output Image Type is Single Combined Image.', + }, + { + key: 'images', + label: 'Image URLs', + format: 'url', + description: + 'Present when Output Image Type is Separate Image for Each Page. One URL per page.', + }, + ], +};