From 6b007e4f765b62be8bcdecbe6d66835e11239715 Mon Sep 17 00:00:00 2001 From: Kishan Parmar <135701940+kishanprmr@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:08:11 +0530 Subject: [PATCH 01/11] fix(notion): resolve lint errors (#15880) --- packages/pieces/community/notion/package.json | 2 +- .../pieces/community/notion/src/lib/actions/add-comment.ts | 3 +-- .../notion/src/lib/actions/archive-database-item.ts | 3 +-- .../notion/src/lib/actions/create-database-item.ts | 3 +-- .../pieces/community/notion/src/lib/actions/find-page.ts | 6 ++---- .../community/notion/src/lib/actions/get-page-comments.ts | 3 +-- .../community/notion/src/lib/actions/list-database-pages.ts | 3 +-- .../notion/src/lib/actions/restore-database-item.ts | 3 +-- .../community/notion/src/lib/actions/retrieve-database.ts | 3 +-- .../notion/src/lib/actions/update-database-item.ts | 3 +-- packages/pieces/community/notion/src/lib/common/index.ts | 3 ++- .../pieces/community/notion/src/lib/triggers/new-comment.ts | 3 +-- .../community/notion/src/lib/triggers/updated-page.ts | 3 +-- 13 files changed, 15 insertions(+), 26 deletions(-) diff --git a/packages/pieces/community/notion/package.json b/packages/pieces/community/notion/package.json index 73f1f1425e8f..8b540f455c49 100644 --- a/packages/pieces/community/notion/package.json +++ b/packages/pieces/community/notion/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-notion", - "version": "0.7.2", + "version": "0.7.3", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/notion/src/lib/actions/add-comment.ts b/packages/pieces/community/notion/src/lib/actions/add-comment.ts index b05a75c4d5cc..1c91079a166b 100644 --- a/packages/pieces/community/notion/src/lib/actions/add-comment.ts +++ b/packages/pieces/community/notion/src/lib/actions/add-comment.ts @@ -9,8 +9,7 @@ export const addComment = createAction({ name: 'add_comment', classification: 'WRITE', displayName: 'Add Comment', - description: - 'Add a comment to a page.', + description: 'Add a comment to a page.', audience: 'human', aiMetadata: { description: diff --git a/packages/pieces/community/notion/src/lib/actions/archive-database-item.ts b/packages/pieces/community/notion/src/lib/actions/archive-database-item.ts index edb2df2b0e3f..e0ad6f7fe6ce 100644 --- a/packages/pieces/community/notion/src/lib/actions/archive-database-item.ts +++ b/packages/pieces/community/notion/src/lib/actions/archive-database-item.ts @@ -9,8 +9,7 @@ export const archiveDatabaseItem = createAction({ name: 'archive_database_item', classification: 'DESTRUCTIVE', displayName: 'Archive Database Item', - description: - 'Archive a database item. It can be restored later.', + description: 'Archive a database item. It can be restored later.', audience: 'human', aiMetadata: { description: diff --git a/packages/pieces/community/notion/src/lib/actions/create-database-item.ts b/packages/pieces/community/notion/src/lib/actions/create-database-item.ts index 8324e833ed42..b9128cb680b2 100644 --- a/packages/pieces/community/notion/src/lib/actions/create-database-item.ts +++ b/packages/pieces/community/notion/src/lib/actions/create-database-item.ts @@ -14,8 +14,7 @@ export const createDatabaseItem = createAction({ name: 'create_database_item', classification: 'WRITE', displayName: 'Create Database Item', - description: - 'Add an item to a database and set its fields.', + description: 'Add an item to a database and set its fields.', audience: 'human', aiMetadata: { description: diff --git a/packages/pieces/community/notion/src/lib/actions/find-page.ts b/packages/pieces/community/notion/src/lib/actions/find-page.ts index 1a170a51de1e..9d225b0ffea9 100644 --- a/packages/pieces/community/notion/src/lib/actions/find-page.ts +++ b/packages/pieces/community/notion/src/lib/actions/find-page.ts @@ -9,8 +9,7 @@ export const findPage = createAction({ name: 'find_page', classification: 'SEARCH', displayName: 'Find Page', - description: - 'Find pages by title, with exact or partial matching.', + description: 'Find pages by title, with exact or partial matching.', audience: 'human', aiMetadata: { description: @@ -26,8 +25,7 @@ export const findPage = createAction({ }), exact_match: Property.Checkbox({ displayName: 'Exact Match', - description: - 'Only return pages whose title equals the search exactly.', + description: 'Only return pages whose title equals the search exactly.', required: false, defaultValue: false, }), diff --git a/packages/pieces/community/notion/src/lib/actions/get-page-comments.ts b/packages/pieces/community/notion/src/lib/actions/get-page-comments.ts index 6e7471d7ff17..253de79d1dde 100644 --- a/packages/pieces/community/notion/src/lib/actions/get-page-comments.ts +++ b/packages/pieces/community/notion/src/lib/actions/get-page-comments.ts @@ -9,8 +9,7 @@ export const getPageComments = createAction({ name: 'get_page_comments', classification: 'READ', displayName: 'Get Page Comments', - description: - 'Get all comments on a page, grouped by thread.', + description: 'Get all comments on a page, grouped by thread.', audience: 'human', aiMetadata: { description: diff --git a/packages/pieces/community/notion/src/lib/actions/list-database-pages.ts b/packages/pieces/community/notion/src/lib/actions/list-database-pages.ts index ea1e963e9ca1..d5abdce5b5cf 100644 --- a/packages/pieces/community/notion/src/lib/actions/list-database-pages.ts +++ b/packages/pieces/community/notion/src/lib/actions/list-database-pages.ts @@ -15,8 +15,7 @@ export const listDatabasePages = createAction({ name: 'list_database_pages', classification: 'SEARCH', displayName: 'List Pages', - description: - 'List pages in a database, optionally filtered by field values.', + description: 'List pages in a database, optionally filtered by field values.', audience: 'human', aiMetadata: { description: diff --git a/packages/pieces/community/notion/src/lib/actions/restore-database-item.ts b/packages/pieces/community/notion/src/lib/actions/restore-database-item.ts index beb47986caa8..7b21bcdc154c 100644 --- a/packages/pieces/community/notion/src/lib/actions/restore-database-item.ts +++ b/packages/pieces/community/notion/src/lib/actions/restore-database-item.ts @@ -9,8 +9,7 @@ export const restoreDatabaseItem = createAction({ name: 'restore_database_item', classification: 'WRITE', displayName: 'Restore Database Item', - description: - 'Restore an archived database item.', + description: 'Restore an archived database item.', audience: 'human', aiMetadata: { description: diff --git a/packages/pieces/community/notion/src/lib/actions/retrieve-database.ts b/packages/pieces/community/notion/src/lib/actions/retrieve-database.ts index bce34e65a374..28375be895b6 100644 --- a/packages/pieces/community/notion/src/lib/actions/retrieve-database.ts +++ b/packages/pieces/community/notion/src/lib/actions/retrieve-database.ts @@ -14,8 +14,7 @@ export const retrieveDatabase = createAction({ name: 'retrieve_database', classification: 'READ', displayName: 'Retrieve Database Structure', - description: - "Get a database's properties, field types and options.", + description: "Get a database's properties, field types and options.", audience: 'human', aiMetadata: { description: diff --git a/packages/pieces/community/notion/src/lib/actions/update-database-item.ts b/packages/pieces/community/notion/src/lib/actions/update-database-item.ts index 9a8602dc9aad..746f95d76f70 100644 --- a/packages/pieces/community/notion/src/lib/actions/update-database-item.ts +++ b/packages/pieces/community/notion/src/lib/actions/update-database-item.ts @@ -13,8 +13,7 @@ export const updateDatabaseItem = createAction({ name: 'update_database_item', classification: 'WRITE', displayName: 'Update Database Item', - description: - 'Change field values on an existing database item.', + description: 'Change field values on an existing database item.', audience: 'human', aiMetadata: { description: diff --git a/packages/pieces/community/notion/src/lib/common/index.ts b/packages/pieces/community/notion/src/lib/common/index.ts index bc65e36f22a9..db0fdad6d6fd 100644 --- a/packages/pieces/community/notion/src/lib/common/index.ts +++ b/packages/pieces/community/notion/src/lib/common/index.ts @@ -155,7 +155,8 @@ export const notionCommon = { } catch (error: any) { return { disabled: true, - placeholder: 'Could not load items. Check the integration has access.', + placeholder: + 'Could not load items. Check the integration has access.', options: [], }; } diff --git a/packages/pieces/community/notion/src/lib/triggers/new-comment.ts b/packages/pieces/community/notion/src/lib/triggers/new-comment.ts index 14dc6f10411e..f6f62df216eb 100644 --- a/packages/pieces/community/notion/src/lib/triggers/new-comment.ts +++ b/packages/pieces/community/notion/src/lib/triggers/new-comment.ts @@ -19,8 +19,7 @@ export const newComment = createTrigger({ name: 'new_comment', classification: 'READ', displayName: 'New Comment', - description: - 'Triggers when a comment is added to a page.', + description: 'Triggers when a comment is added to a page.', aiMetadata: { description: 'Fires when a new comment is posted on the selected Notion page, emitting the comment. Use to react to feedback or discussion activity on a specific page; requires read-comments capability on the integration.', diff --git a/packages/pieces/community/notion/src/lib/triggers/updated-page.ts b/packages/pieces/community/notion/src/lib/triggers/updated-page.ts index b84c9f6c981d..0503fdf8ea74 100644 --- a/packages/pieces/community/notion/src/lib/triggers/updated-page.ts +++ b/packages/pieces/community/notion/src/lib/triggers/updated-page.ts @@ -19,8 +19,7 @@ export const updatedPage = createTrigger({ name: 'updated_page', classification: 'READ', displayName: 'Updated Page', - description: - 'Triggers when any page shared with the integration is edited.', + description: 'Triggers when any page shared with the integration is edited.', aiMetadata: { description: 'Fires whenever any page shared with the integration in the Notion workspace is modified, emitting the updated page. Use to track content changes workspace-wide rather than within a single database or page.', From f783bf3bb4f1d42df71ac96cb166b2f13a762987 Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Mon, 28 Sep 2026 14:07:28 +0300 Subject: [PATCH 02/11] fix(chat): ask before deleting tables, records, fields or flows (#15881) --- .../jobs/ee/agent/agent-worker-tools.ts | 3 +- .../jobs/ee/agent/execute-agent-run.ts | 27 +++--- .../jobs/ee/agent/tools/flow-gate-tools.ts | 54 ++++++++++++ .../execute/jobs/ee/agent/delete-gate.test.ts | 82 +++++++++++++++++++ .../web/src/features/chat/lib/use-chat.ts | 13 ++- 5 files changed, 162 insertions(+), 17 deletions(-) create mode 100644 packages/server/worker/test/lib/execute/jobs/ee/agent/delete-gate.test.ts 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 a88d6f43c8b5..0120dc0b03e5 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 @@ -3,7 +3,7 @@ import { createCrossProjectTools } from './tools/cross-project-tools' import { createDisplayTools } from './tools/display-tools' import { createEmailTools } from './tools/email-tools' import { createEventEmitter } from './tools/event-emitter' -import { wrapTestFlowGate } from './tools/flow-gate-tools' +import { wrapDeleteGate, wrapTestFlowGate } from './tools/flow-gate-tools' import { createAgentSurfaceTools, createBuildPlanTools, createLocalTools, createPhaseTools, createStructuredOutputTool, createThinkingTools } from './tools/session-tools' import { extractResultText, extractUserFacingError, isSuccessResult, normalizePieceName, TOOL_EXECUTION_TIMEOUT_MS, truncateLargeResult, withToolTimeout, wrapToolsWithTaint } from './tools/tool-primitives' import { createImageTools, createProviderSearchTools, createScrapeTools, createSearchTools, createWebTools, falImageGenerator, providerImageGenerator } from './tools/web-media-tools' @@ -26,6 +26,7 @@ export const agentWorkerTools = { providerImageGenerator, createEmailTools, wrapTestFlowGate, + wrapDeleteGate, createThinkingTools, createPhaseTools, createBuildPlanTools, 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 dabef7c7f83b..379a5b9ce3d2 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 @@ -697,20 +697,21 @@ function buildToolSet({ ctx, eventEmitter, log, phaseState, taintState, mcpToolS eventEmitter, getProjectId: () => projectState.projectId, }) + const timedMcpTools = agentMcpClient.withToolTimeouts({ + mcpToolSet, + brokenConnectors, + taintState, + getSelectedAuth: ({ pieceName }) => selectedConnectionByPiece.get(pieceName), + saveLargeResult: async ({ json, fileName }) => { + const { data: saved } = await tryCatch(() => ctx.apiClient.saveAgentFile({ + platformId, conversationId, data: Buffer.from(json, 'utf8'), mediaType: 'application/json', + ...spreadIfDefined('projectId', projectState.projectId ?? undefined), fileName, + })) + return saved?.fileId ?? null + }, + }) const mcpTools = agentWorkerTools.wrapTestFlowGate({ - mcpTools: agentMcpClient.withToolTimeouts({ - mcpToolSet, - brokenConnectors, - taintState, - getSelectedAuth: ({ pieceName }) => selectedConnectionByPiece.get(pieceName), - saveLargeResult: async ({ json, fileName }) => { - const { data: saved } = await tryCatch(() => ctx.apiClient.saveAgentFile({ - platformId, conversationId, data: Buffer.from(json, 'utf8'), mediaType: 'application/json', - ...spreadIfDefined('projectId', projectState.projectId ?? undefined), fileName, - })) - return saved?.fileId ?? null - }, - }), + mcpTools: agentWorkerTools.wrapDeleteGate({ mcpTools: timedMcpTools, waitForApproval, storePendingGate, eventEmitter }), checkFlowWrites: async (flowId) => { const response = await ctx.apiClient.executeAgentTool({ toolName: '__flow_write_check', toolInput: { flowId }, platformId, userId, source, conversationId }) return response.result diff --git a/packages/server/worker/src/lib/execute/jobs/ee/agent/tools/flow-gate-tools.ts b/packages/server/worker/src/lib/execute/jobs/ee/agent/tools/flow-gate-tools.ts index cf6f71fc56a7..9b89f2d4b747 100644 --- a/packages/server/worker/src/lib/execute/jobs/ee/agent/tools/flow-gate-tools.ts +++ b/packages/server/worker/src/lib/execute/jobs/ee/agent/tools/flow-gate-tools.ts @@ -64,3 +64,57 @@ export function wrapTestFlowGate({ mcpTools, checkFlowWrites, waitForApproval, s return { ...mcpTools, ap_test_flow: wrapped } } +export function wrapDeleteGate({ mcpTools, waitForApproval, storePendingGate, eventEmitter }: { + mcpTools: Record + waitForApproval: (params: { gateId: string, timeoutMs?: number }) => Promise + storePendingGate: (params: { gateId: string, toolName: string, displayName: string, toolInput: Record }) => Promise + eventEmitter: AgentEventEmitter +}): Record { + const gated = Object.entries(DELETE_GATES).flatMap(([toolName, { labelOf, deletes }]) => { + const deleteTool = mcpTools[toolName] + if (!isObject(deleteTool) || !toolHasExecute(deleteTool)) { + return [] + } + const originalExecute = deleteTool.execute.bind(deleteTool) + return [[toolName, Object.assign({}, deleteTool, { + execute: async (args: unknown, options?: ToolExecutionOptions) => { + const gateId = options?.toolCallId + const toolInput = isObject(args) ? args : {} + if (!gateId || !deletes(toolInput)) { + return originalExecute(args, options) + } + const label = labelOf(toolInput) + eventEmitter.emitActionPreview({ toolCallId: gateId, pieceName: '', actionName: toolName, actionDisplayName: label, input: toolInput, isBatch: false }) + await tryCatch(() => storePendingGate({ gateId, toolName, displayName: label, toolInput })) + const decision = await waitForApproval({ gateId }) + if (decision.outcome === 'approved') { + return originalExecute(args, options) + } + const text = decision.outcome === 'timeout' ? gateNoResponseMessage('delete approval') : 'The user declined this delete. Do not retry it; ask what they want instead.' + return { content: [{ type: 'text', text }] } + }, + })]] + }) + return { ...mcpTools, ...Object.fromEntries(gated) } +} + +function textField({ toolInput, key }: { toolInput: Record, key: string }): string { + const value = toolInput[key] + return typeof value === 'string' ? value : 'unknown' +} + +const DELETE_GATES: Record) => string, deletes: (toolInput: Record) => boolean }> = { + ap_delete_records: { + labelOf: (toolInput) => { + const count = Array.isArray(toolInput['recordIds']) ? toolInput['recordIds'].length : 0 + return `Delete ${count} ${count === 1 ? 'record' : 'records'} from table ${textField({ toolInput, key: 'tableId' })}` + }, + deletes: () => true, + }, + ap_delete_table: { labelOf: (toolInput) => `Delete table ${textField({ toolInput, key: 'tableId' })}`, deletes: () => true }, + ap_delete_flow: { labelOf: (toolInput) => `Delete flow ${textField({ toolInput, key: 'flowId' })}`, deletes: () => true }, + ap_manage_fields: { + labelOf: (toolInput) => `Delete field ${textField({ toolInput, key: 'fieldId' })} from table ${textField({ toolInput, key: 'tableId' })}`, + deletes: (toolInput) => toolInput['operation'] === 'DELETE', + }, +} diff --git a/packages/server/worker/test/lib/execute/jobs/ee/agent/delete-gate.test.ts b/packages/server/worker/test/lib/execute/jobs/ee/agent/delete-gate.test.ts new file mode 100644 index 000000000000..b58979757c5d --- /dev/null +++ b/packages/server/worker/test/lib/execute/jobs/ee/agent/delete-gate.test.ts @@ -0,0 +1,82 @@ +import { isObject } from '@activepieces/core-utils' +import { SendAgentEventRequest } from '@activepieces/shared' +import { describe, expect, it, vi } from 'vitest' +import { agentWorkerTools, GateDecision } from '../../../../../../src/lib/execute/jobs/ee/agent/agent-worker-tools' +import { toolHasExecute } from '../../../../../../src/lib/execute/jobs/ee/agent/tools/tool-primitives' + +describe('wrapDeleteGate', () => { + it('asks the user before every delete, and deletes only after they approve', async () => { + const { tools, deleteRecords, waitForApproval, sentEvents } = setup({ decision: 'approved' }) + + await tools.ap_delete_records.execute({ tableId: 't1', recordIds: ['r1', 'r2'], displayName: 'Delete a harmless temp row' }, { toolCallId: 'call-1' }) + + expect(JSON.stringify(sentEvents)).toContain('Delete 2 records from table t1') + expect(JSON.stringify(sentEvents.map((event) => event.type))).toContain('ACTION_PREVIEW') + expect(waitForApproval).toHaveBeenCalledWith({ gateId: 'call-1' }) + expect(deleteRecords).toHaveBeenCalledTimes(1) + }) + + it('does not delete when the user declines', async () => { + const { tools, deleteRecords } = setup({ decision: 'declined' }) + + const result = await tools.ap_delete_records.execute({ tableId: 't1' }, { toolCallId: 'call-1' }) + + expect(deleteRecords).not.toHaveBeenCalled() + expect(JSON.stringify(result)).toContain('declined') + }) + + it('asks before deleting a table field, but not before renaming one', async () => { + const { tools, manageFields, waitForApproval } = setup({ decision: 'approved' }) + + await tools.ap_manage_fields.execute({ tableId: 't1', operation: 'UPDATE', fieldId: 'f1', name: 'Title' }, { toolCallId: 'call-2' }) + expect(waitForApproval).not.toHaveBeenCalled() + + await tools.ap_manage_fields.execute({ tableId: 't1', operation: 'DELETE', fieldId: 'f1' }, { toolCallId: 'call-3' }) + expect(waitForApproval).toHaveBeenCalledWith({ gateId: 'call-3' }) + expect(manageFields).toHaveBeenCalledTimes(2) + }) + + it('leaves other writes alone', async () => { + const { tools, insertRecords, waitForApproval } = setup({ decision: 'approved' }) + + await tools.ap_insert_records.execute({ tableId: 't1' }, { toolCallId: 'call-4' }) + + expect(waitForApproval).not.toHaveBeenCalled() + expect(insertRecords).toHaveBeenCalledTimes(1) + }) +}) + +function setup({ decision }: { decision: GateDecision['outcome'] }): { + tools: Record Promise }> + deleteRecords: ReturnType + insertRecords: ReturnType + manageFields: ReturnType + waitForApproval: ReturnType + sentEvents: SendAgentEventRequest['event'][] +} { + const deleteRecords = vi.fn(async () => TOOL_RESULT) + const insertRecords = vi.fn(async () => TOOL_RESULT) + const manageFields = vi.fn(async () => TOOL_RESULT) + const waitForApproval = vi.fn(async (): Promise => ({ outcome: decision })) + const sentEvents: SendAgentEventRequest['event'][] = [] + const eventEmitter = agentWorkerTools.createEventEmitter({ + sendEvent: async ({ event }) => { + sentEvents.push(event) + }, + userId: 'user-1', + conversationId: 'conv-1', + }) + const wrapped = agentWorkerTools.wrapDeleteGate({ + mcpTools: { ap_delete_records: { execute: deleteRecords }, ap_insert_records: { execute: insertRecords }, ap_manage_fields: { execute: manageFields } }, + waitForApproval, + storePendingGate: async () => undefined, + eventEmitter, + }) + return { tools: toolsOf(wrapped), deleteRecords, insertRecords, manageFields, waitForApproval, sentEvents } +} + +function toolsOf(wrapped: Record): Record Promise }> { + return Object.fromEntries(Object.entries(wrapped).flatMap(([name, value]) => isObject(value) && toolHasExecute(value) ? [[name, value]] : [])) +} + +const TOOL_RESULT = { content: [{ type: 'text', text: 'done' }] } diff --git a/packages/web/src/features/chat/lib/use-chat.ts b/packages/web/src/features/chat/lib/use-chat.ts index 434289656f59..fe1c1676a1e2 100644 --- a/packages/web/src/features/chat/lib/use-chat.ts +++ b/packages/web/src/features/chat/lib/use-chat.ts @@ -89,6 +89,13 @@ function restoreReceiptsIntoStore({ const MAX_FILE_SIZE = 10 * 1024 * 1024; const AGENT_POLL_INTERVAL_MS = 5_000; +const TOOL_GATES_WITHOUT_A_PIECE = new Set([ + 'ap_test_flow', + 'ap_delete_records', + 'ap_delete_table', + 'ap_delete_flow', + 'ap_manage_fields', +]); function buildToolCallMetaFromGate( gate: PendingGate, @@ -126,13 +133,13 @@ function buildToolCallMetaFromGate( ? (gateInput.items as Record[]).slice(0, 3) : undefined, }; - } else if (gate.toolName === 'ap_test_flow') { + } else if (TOOL_GATES_WITHOUT_A_PIECE.has(gate.toolName)) { actionPreview = { toolCallId: gate.gateId, pieceName: '', - actionName: 'ap_test_flow', + actionName: gate.toolName, actionDisplayName: gate.displayName, - input: {}, + input: gate.toolName === 'ap_test_flow' ? {} : gateInput, isBatch: false, }; } From fecc1e0a369c8b5357d9c5baccadf0ef0225d293 Mon Sep 17 00:00:00 2001 From: Othman Abu Ajamieh <52608229+othmanemad@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:15:46 +0300 Subject: [PATCH 03/11] fix(ai): model dropdowns offer only models a step can call (#15497) --- .../knowledge/ai-intelligence/ai-providers.md | 3 + bun.lock | 4 +- packages/core/piece-types/package.json | 2 +- .../piece-types/src/lib/ai-providers.test.ts | 96 ++++++++++++++ .../core/piece-types/src/lib/ai-providers.ts | 62 +++++++++ packages/pieces/community/openai/package.json | 2 +- .../community/openai/src/lib/common/common.ts | 46 +------ packages/pieces/framework/src/index.ts | 1 + .../api/src/app/ai/ai-provider-service.ts | 9 +- .../api/src/app/ai/providers/ai-provider.ts | 1 + .../src/app/ai/providers/azure-provider.ts | 20 ++- .../src/app/ai/providers/google-provider.ts | 21 +++- .../ai/providers/openai-compatible-vendor.ts | 73 +++++++++-- .../ce/ai-provider/chat-model-filter.test.ts | 119 ++++++++++++++++++ .../app/ai/providers/azure-provider.test.ts | 29 +++++ .../app/ai/providers/google-provider.test.ts | 45 +++++++ .../openai-compatible-vendor.test.ts | 94 +++++++++++++- 17 files changed, 556 insertions(+), 71 deletions(-) create mode 100644 packages/server/api/test/integration/ce/ai-provider/chat-model-filter.test.ts diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md index 9718d93288f3..0f4148f782bb 100644 --- a/brain/knowledge/ai-intelligence/ai-providers.md +++ b/brain/knowledge/ai-intelligence/ai-providers.md @@ -191,6 +191,9 @@ the URL. The file is published from the console in `ap-analytics`; see decision - **The AI Center providers page renders its whole state machine off one query, so every branch needs an `isLoading` gate above it.** `ProvidersTab` derives the empty state, the grouped list *and* the `?config=` detail view from `useAiProviderConfigs()`. While that query is pending, `data` is `undefined` — `configs` is `[]`, so the empty "Connect your first provider" screen paints for a frame, and a hard refresh on a `?config=` URL finds no matching row and falls through to the list before flipping to the detail page. The parent `SuspenseWrapper` does not cover it: the route chunk and the suspense-backed projects collection resolve first, then the component mounts with no providers yet. Any new branch added to this page belongs below the `if (isLoading) return ` gate, not beside it. - **A save bar in the AI Center needs a full-height column, not `fixed` and not bare `sticky`.** The page sits inside *two* nested scrollers — `PlatformLayout`'s `flex flex-col flex-1 overflow-auto` and the AI Center's own `flex-1 overflow-auto` column — so `fixed inset-x-0` centres the bar on the viewport, roughly a sidebar-width left of the content column it belongs to. Swapping it to `sticky bottom-4` fixes the centring for free but pins nothing when the content is shorter than the viewport: the bar just lands wherever the content ends, mid-page. The working shape is `min-h-full` + `flex flex-col` on the tab-content wrapper, `flex-1` on the `TabsContent`, `grow` on the panel root, then `sticky bottom-4 mt-auto` on the bar — `mt-auto` handles the short page, `sticky` handles the long one. `min-h-full` resolves only because every ancestor up to `SidebarInset h-full` has a definite height; that chain is what makes the whole thing work, so check it before reusing this elsewhere. - **A chat *tier* is our own idea, so it has no meaning on a provider we ship no curated model list for — resolve it by asking the key what it offers.** `ACTIVEPIECES_CHAT_TIERS` (fast/smart/premium) only maps to a real id through `ALLOWED_CHAT_MODELS_BY_PROVIDER`, which covers Anthropic, OpenAI, Google, Vertex and Activepieces. Everything else — Bedrock, Azure, Mistral, xAI, DeepSeek and the other OpenAI-compatible vendors — has an empty candidate list. The resolver used to invent an id from the tier (strip the vendor prefix, dots to dashes: `anthropic/claude-haiku-4.5` -> `claude-haiku-4-5`), which Bedrock rejected as "The provided model identifier is invalid" (GIT-1840, Pylon 5836). #15355 removed the invention and left a throw in its place, which was worse: every agent turn on those providers died with `ENTITY_NOT_FOUND: this AI provider key allows no text model a chat turn can run on` before the configured model handled anything (Pylon 6069). **The missing ingredient was never a smarter guess, it was the key's own model list.** `agentHelpers.resolveModelId` is now the single resolver for every surface: it tries our catalogue first and, only when that has nothing to say, reads `aiProviderService.listModels` (process-cached, cleared nightly by cron, already filtered by the key's `modelScope`), preferring a model whose id *contains* the tier's `nativeModelId` — Bedrock spells them `eu.anthropic.claude-sonnet-4-6-...`, so a tier still selects the model it names wherever the vendor hosts it, and otherwise degrades to the first text model the way `pickAllowedModel` already does for an admin-listed catalogue. Picking from a list the provider returned is the opposite of the fabrication #15355 removed. **Two traps, both learned the hard way.** First, an agent surface and generic chat are not symmetric: an agent *carries* a real model id (its picker lists `listModels` output unfiltered, precisely because these providers have no `ALLOWED_CHAT_MODELS_BY_PROVIDER` entry) while chat carries only a tier name, because `ChatModelSelector` falls back to tier options when `getCuratedChatModels` returns `undefined`. Second, **being listed proves the key can see a model, not that the account can invoke it** — so where a model is already settled the fast round reuses it rather than choosing again (`getAgentConfig` passes the model the turn resolved; the tool RPC gets the run's own model from the worker, `modelId` on `executePieceTool`). One more trap, found in review on 2026-09-27: a stored name is not the model the run ran on. The worker stamps the resolved *tier id* on an agentless inline run's conversation, so reading it back as a model hands a bare `smart` to a catalogue-less own-key provider, and no rule on the string can save it, because a deployment may be named `smart` too. So nothing is read off the conversation or the agent for the fast round any more: the worker says which model the turn resolved, `modelId` on `ExecutePieceToolRequest` (core-execution), and the API borrows exactly that. The field is required, not optional, because the dispatch gate keeps worker and app on one release, so an older worker never reaches a newer API. Resolving the fast tier independently once picked a Haiku the account could not invoke and failed a turn whose configured Sonnet worked fine. A borrowed model still has to pass the key's current `modelScope`, or resolution falls through to the list, which applies it. Nothing crosses the worker, so a rolling deploy has no version-skew window. +- **Non-chat models are filtered out centrally in `fetchModels`, not per provider strategy.** OpenAI's `/v1/models` returns `whisper-1`, `tts-1`, `gpt-4o-transcribe`, `text-embedding-3-*`, `omni-moderation-latest` and `sora-*`, and every strategy that cannot tell them apart typed them `TEXT` — so they were offered in Ask AI / Summarize / Classify / Extract and died at run time on chat completion (#14496). One `aiProviderUtils.isChatModelId` denylist now runs inside `fetchModels`, which is the single choke point behind both the project route and the admin `configs/:id/models` route, so a new provider is covered without opting in. Three things about it are load-bearing: it tests **only `TEXT`-typed rows**, so image models are untouched; it **never judges a name the customer chose** — admin-typed configs (`'models' in config` — VERTEX, CUSTOM, CLOUDFLARE_GATEWAY) skip it because an admin who typed an id meant it, Azure skips it (`modelIdsAreCustomerNamed` on the strategy) because it lists deployment names like `support-voice-agent` and filters on `deployment.model` itself, and a fine-tune is judged by its base model only (`ft:::