From 520cd75255c1377c8d056b3bcf098f320c0b39db Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Wed, 23 Sep 2026 19:11:51 +0300 Subject: [PATCH 1/8] feat(ai-providers): connect AWS Bedrock with temporary credentials from SSO or STS (#15745) --- .../knowledge/ai-intelligence/ai-providers.md | 3 ++ .../src/lib/create-language-model.test.ts | 34 ++++++++++++++++--- .../src/lib/create-language-model.ts | 8 ++--- .../core/piece-types/src/lib/ai-providers.ts | 1 + .../utils/src/lib/ai-provider-credentials.ts | 1 + .../test/ai-provider-credentials.test.ts | 9 +++++ .../src/app/ai/providers/bedrock-provider.ts | 1 + .../web/public/locales/en/translation.json | 2 ++ .../providers-tab/connect-provider-dialog.tsx | 15 ++++---- .../ai/providers-tab/provider-credentials.ts | 10 ++++++ .../ai/providers-tab/provider-request.ts | 1 + 11 files changed, 67 insertions(+), 18 deletions(-) diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md index 9e31aaf14ec4..8eddf3ab0887 100644 --- a/brain/knowledge/ai-intelligence/ai-providers.md +++ b/brain/knowledge/ai-intelligence/ai-providers.md @@ -54,6 +54,9 @@ renders and preserves every real price; the cheapest in the set is 0.01. - **The catalog object must be published before the code that reads it ships.** There is no bundled copy, so until a `workflow_dispatch` run puts it on the CDN, every install — including local dev — shows no metadata at all. - **A fresh Cloud platform cannot connect any AI provider, and the UI says nothing about why.** The admin page at `/platform/setup/ai` sets `allowWrite = platform.plan.aiProvidersEnabled`, and that column defaults to **false** on the `free` plan (migration `1776…AddDefaultToAiProvidersEnabled`), so the connect button is simply absent rather than disabled-with-a-reason. On Cloud the flag is owned by Autumn (`autumn-utils.ts` lists it among the synced features), so there is nothing to configure locally and no `DEV_ENTERPRISE_PLAN` escape hatch on main. For local Cloud testing, flip it directly: `UPDATE platform_plan SET "aiProvidersEnabled" = true`. Then connect a **BYO key**, not the managed `ACTIVEPIECES` provider — per *Provider visibility* above, the managed row is hidden whenever `OPENROUTER_PROVISION_KEY` is unset, which is the normal local state, and a hidden provider makes `getChatProvider()` return null. Anything gated on a chat provider (chat itself, personalization research) stays silently disabled until a BYO row exists. +- **Every provider auth schema is written twice, and only one copy is the one the server reads.** `packages/core/utils/src/lib/ai-provider-credentials.ts` (plain zod) backs the API and the web app; `packages/core/piece-types/src/lib/ai-providers.ts` (`zod/mini`, so `.check(z.minLength(1))` and `z.optional(x)` instead of `.min(1)` / `.optional()`) is what `@activepieces/pieces-framework` re-exports to pieces and the engine. Adding a credential field to only one leaves the other silently stripping it — zod objects drop unknown keys, so the upsert still returns 200 and the failure surfaces later as a provider-side auth error. Change both. +- **A schema change in `packages/core/utils` is invisible to another package's tests until you build it.** Sibling packages resolve `@activepieces/core-utils` through its `main` (`dist/src/index.js`), so vitest in `core/ai-providers` keeps parsing against the stale build and a new optional field reads back as dropped. `cd packages/core/utils && npm run build` first. The API dev server is the opposite: `serve` runs under tsx with the tsconfig path alias, so it reads `src` directly — but it only watches `packages/core/shared/src/**`, not `core/utils`, so a core-utils-only edit needs a restart to take effect. The same staleness makes a **branch** look green when it is not: `dist` is gitignored, so after `git checkout` it still holds whatever the previous branch built, and a test that depends on a field added by a sibling PR passes locally and fails in CI. Rebuild `core/utils` after every branch switch before trusting a run. + - **`getEffectiveProviderAndModel` exists for one provider, and skipping it makes Cloudflare Gateway fall off the end of any per-provider switch.** It looks like generic provider resolution; it is not. It returns its input untouched for everything except `CLOUDFLARE_GATEWAY`, where it splits the model id and maps the submodel prefix (`anthropic`, `openai`, `google-ai-studio`, `google-vertex-ai`) back to the real provider. So a gateway model of `anthropic/claude-…` only reaches an Anthropic-keyed branch if you called it first. Any table or lookup keyed on `AIProviderName` — the web-search tool table, capability checks — has no `CLOUDFLARE_GATEWAY` entry and returns nothing for one that skipped the call: no tool, no error, no log. The AI piece calls it at the top of `buildWebSearchConfig`; server-side code that dispatches per provider needs the same call or the same resolution built in. - **The Agent and the Direct AI steps disagree about whether OpenAI does web search, and both are right.** `WEB_SEARCH_MODE_BY_PROVIDER` has no `OPENAI` entry, so `supportsWebSearch(OPENAI)` is `false` — the Agent asks that question before offering the tool and therefore has never had OpenAI web search (deliberately: it needs the Responses API, which breaks legacy BYOK models). Ask AI never asks it; it builds the tool straight from its own props and has had OpenAI web search all along. So `supportsWebSearch` is the *agent's* policy, not a statement of fact about the provider — reusing it as a general gate silently removes a capability Ask AI users have today. Whoever unifies the two paths has to keep the OpenAI branch reachable without routing it through that gate. The two paths also disagree about the unsupported tail: the piece's `webSearch` prop is a plain checkbox with no provider gating, and `createWebSearchTool` ends in `default: throw new Error('Provider X is not supported for web search')` — so ticking it on Azure, Mistral, xAI, or a Cloudflare Gateway model whose submodel prefix is not one of the four in `CF_GATEWAY_SUBMODEL_TO_PROVIDER` **fails the step**. The server's `buildWebSearchTools` returns `{}` for the same input, because the agent gates before calling it. The builder cannot simply be made to throw: `supportsWebSearch` is true for the `plugin` providers (`openrouter`, `activepieces`), the agent calls the builder for them, and there is no native entry for either because the plugin rides on the model settings instead — so `{}` is the correct answer there. The piece has the same shape, returning early for OpenRouter before the switch that throws. Hence two entry points: `buildWebSearchTools` stays total for callers that gate first, `buildWebSearchToolsOrThrow` reproduces the piece's error for callers that do not. diff --git a/packages/core/ai-providers/src/lib/create-language-model.test.ts b/packages/core/ai-providers/src/lib/create-language-model.test.ts index 5da38ca71f2a..644a1dff1b9a 100644 --- a/packages/core/ai-providers/src/lib/create-language-model.test.ts +++ b/packages/core/ai-providers/src/lib/create-language-model.test.ts @@ -20,10 +20,7 @@ function identifyCustom(model: unknown): CustomModelIdentity { return model as CustomModelIdentity } -async function captureHeaders({ patchedFetch, headers }: { - patchedFetch?: typeof globalThis.fetch - headers: Record -}): Promise { +async function captureSentHeaders(send: () => Promise): Promise { const original = globalThis.fetch let seen = new Headers() globalThis.fetch = (_input, init) => { @@ -31,7 +28,7 @@ async function captureHeaders({ patchedFetch, headers }: { return Promise.resolve(new Response('{}')) } try { - await patchedFetch?.('https://example.test/v1/responses', { headers }) + await send().catch(() => undefined) } finally { globalThis.fetch = original @@ -39,6 +36,20 @@ async function captureHeaders({ patchedFetch, headers }: { return seen } +function captureHeaders({ patchedFetch, headers }: { + patchedFetch?: typeof globalThis.fetch + headers: Record +}): Promise { + return captureSentHeaders(async () => patchedFetch?.('https://example.test/v1/responses', { headers })) +} + +function captureBedrockHeaders(auth: Record): Promise { + const model = createLanguageModel({ + credentials: aiProviderCredentials({ provider: AIProviderName.BEDROCK, auth, config: { region: 'us-east-1' } }), + modelId: 'some-model-id', + }) + return captureSentHeaders(() => model.doGenerate({ prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }] })) +} const authFor: Partial> = { [AIProviderName.BEDROCK]: { accessKeyId: 'a', secretAccessKey: 'b' }, @@ -101,6 +112,19 @@ describe('createLanguageModel', () => { expect(identify(buildFor(AIProviderName.OPENROUTER)).provider).toBe('openrouter') }) + it('signs Bedrock requests with the session token, so temporary AWS credentials are accepted', async () => { + const headers = await captureBedrockHeaders({ accessKeyId: 'ASIA', secretAccessKey: 'b', sessionToken: 'temporary' }) + + expect(headers.get('x-amz-security-token')).toBe('temporary') + }) + + it('omits the security token header when Bedrock runs on a long-term access key', async () => { + const headers = await captureBedrockHeaders({ accessKeyId: 'AKIA', secretAccessKey: 'b' }) + + expect(headers.get('authorization')).toContain('AWS4-HMAC-SHA256') + expect(headers.has('x-amz-security-token')).toBe(false) + }) + it('uses the OpenAI Chat API by default and the Responses API when asked', () => { expect(identify(buildFor(AIProviderName.OPENAI)).provider).toBe('openai.chat') expect(identify(buildFor(AIProviderName.OPENAI, { openaiResponsesModel: true })).provider).toBe('openai.responses') diff --git a/packages/core/ai-providers/src/lib/create-language-model.ts b/packages/core/ai-providers/src/lib/create-language-model.ts index e0f81fbdf8eb..1df745cc47ac 100644 --- a/packages/core/ai-providers/src/lib/create-language-model.ts +++ b/packages/core/ai-providers/src/lib/create-language-model.ts @@ -39,9 +39,9 @@ export function createLanguageModel({ credentials, modelId, options = {} }: Crea return createAzure({ resourceName, apiKey, apiVersion, ...observed }).chat(modelId) } case AIProviderName.BEDROCK: { - const { accessKeyId, secretAccessKey } = credentials.auth + const { accessKeyId, secretAccessKey, sessionToken } = credentials.auth const { region } = credentials.config - return createAmazonBedrock({ region, accessKeyId, secretAccessKey, ...observed })(modelId) + return createAmazonBedrock({ region, accessKeyId, secretAccessKey, sessionToken, ...observed })(modelId) } case AIProviderName.VERTEX: { const { serviceAccountJson } = credentials.auth @@ -120,9 +120,9 @@ export function createImageModel({ credentials, modelId, options = {} }: CreateI return createAzure({ resourceName, apiKey, apiVersion, ...observed }).imageModel(modelId) } case AIProviderName.BEDROCK: { - const { accessKeyId, secretAccessKey } = credentials.auth + const { accessKeyId, secretAccessKey, sessionToken } = credentials.auth const { region } = credentials.config - return createAmazonBedrock({ region, accessKeyId, secretAccessKey, ...observed }).imageModel(modelId) + return createAmazonBedrock({ region, accessKeyId, secretAccessKey, sessionToken, ...observed }).imageModel(modelId) } case AIProviderName.VERTEX: { const { serviceAccountJson } = credentials.auth diff --git a/packages/core/piece-types/src/lib/ai-providers.ts b/packages/core/piece-types/src/lib/ai-providers.ts index 3d43a882f8b2..ea9e8810452f 100644 --- a/packages/core/piece-types/src/lib/ai-providers.ts +++ b/packages/core/piece-types/src/lib/ai-providers.ts @@ -32,6 +32,7 @@ export type VertexProviderAuthConfig = z.infer export const BedrockProviderAuthConfig = z.object({ accessKeyId: z.string().check(z.minLength(1)), secretAccessKey: z.string().check(z.minLength(1)), + sessionToken: z.optional(z.string()), }) export type BedrockProviderAuthConfig = z.infer diff --git a/packages/core/utils/src/lib/ai-provider-credentials.ts b/packages/core/utils/src/lib/ai-provider-credentials.ts index c9bcf5bb0549..9c7e39414ae8 100644 --- a/packages/core/utils/src/lib/ai-provider-credentials.ts +++ b/packages/core/utils/src/lib/ai-provider-credentials.ts @@ -46,6 +46,7 @@ export type OpenRouterProviderAuthConfig = z.infer diff --git a/packages/core/utils/test/ai-provider-credentials.test.ts b/packages/core/utils/test/ai-provider-credentials.test.ts index 004dc77353c9..8fb501eb0130 100644 --- a/packages/core/utils/test/ai-provider-credentials.test.ts +++ b/packages/core/utils/test/ai-provider-credentials.test.ts @@ -28,6 +28,15 @@ describe('aiProviderCredentials', () => { expect(credentials.config).toEqual({}) }) + it('carries a bedrock session token through, so temporary credentials survive the read', () => { + const credentials = aiProviderCredentials({ + provider: AIProviderName.BEDROCK, + auth: { accessKeyId: 'ASIA', secretAccessKey: 'secret', sessionToken: 'temporary' }, + config: { region: 'us-east-1' }, + }) + expect(credentials.auth).toEqual({ accessKeyId: 'ASIA', secretAccessKey: 'secret', sessionToken: 'temporary' }) + }) + it('returns an empty row when the auth itself cannot be parsed', () => { const credentials = aiProviderCredentials({ provider: AIProviderName.CUSTOM, diff --git a/packages/server/api/src/app/ai/providers/bedrock-provider.ts b/packages/server/api/src/app/ai/providers/bedrock-provider.ts index 4a73343c81ed..842f66c8a60c 100644 --- a/packages/server/api/src/app/ai/providers/bedrock-provider.ts +++ b/packages/server/api/src/app/ai/providers/bedrock-provider.ts @@ -34,6 +34,7 @@ BedrockProviderConfig credentials: { accessKeyId: authConfig.accessKeyId, secretAccessKey: authConfig.secretAccessKey, + sessionToken: authConfig.sessionToken, }, }) diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 49b6e8b3c3d0..c14153dca25b 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -1515,6 +1515,8 @@ "AWS access key ID": "AWS access key ID", "AWS region": "AWS region", "AWS secret access key": "AWS secret access key", + "AWS session token": "AWS session token", + "Only for temporary credentials from AWS SSO or STS. They expire.": "Only for temporary credentials from AWS SSO or STS. They expire.", "Add key": "Add key", "All except": "All except", "All models": "All models", 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 index f52fab14dab8..85eabf442ff2 100644 --- 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 @@ -163,7 +163,7 @@ function ConnectProviderForm({ name="provider" render={({ field }) => ( - {t('Provider')} + {t('Provider')} @@ -277,13 +277,8 @@ function CredentialFieldInput({ name={`credentials.${field.key}`} render={({ field: formField }) => ( - + {field.label} - {field.optional && ( - - {t('optional')} - - )} {field.options ? (