diff --git a/.github/workflows/sync-model-catalog.yml b/.github/workflows/sync-model-catalog.yml new file mode 100644 index 000000000000..e9e3b267c525 --- /dev/null +++ b/.github/workflows/sync-model-catalog.yml @@ -0,0 +1,62 @@ +name: Sync AI Model Catalog + +on: + schedule: + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + sync: + if: github.repository == 'activepieces/activepieces' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Cache dependencies + uses: actions/cache@v5 + with: + path: ~/.bun/install/cache + key: bun-${{ hashFiles('bun.lock', 'package.json') }} + restore-keys: bun- + + - name: Setup nodejs + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Regenerate catalog + run: npm run sync-model-catalog + + - name: Upload to CDN + env: + AWS_ACCESS_KEY_ID: ${{ secrets.CDN_S3_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.CDN_S3_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: us-east-1 + AWS_REQUEST_CHECKSUM_CALCULATION: when_required + AWS_RESPONSE_CHECKSUM_VALIDATION: when_required + BUCKET: ${{ secrets.CDN_S3_BUCKET }} + ENDPOINT: ${{ secrets.CDN_S3_ENDPOINT }} + run: | + aws s3 cp dist/model-catalog.json \ + "s3://$BUCKET/ai/model-catalog.json" \ + --endpoint-url "$ENDPOINT" \ + --content-type "application/json" \ + --cache-control "max-age=3600" \ + --acl public-read + + - name: Verify publish + run: | + STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://cdn.activepieces.com/ai/model-catalog.json?run=$GITHUB_RUN_ID" || true) + [ "$STATUS" = "200" ] || { echo "Publish verification failed: HTTP $STATUS"; exit 1; } diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md index 099aaa58a408..7bc31579bdbe 100644 --- a/brain/knowledge/ai-intelligence/ai-providers.md +++ b/brain/knowledge/ai-intelligence/ai-providers.md @@ -28,7 +28,29 @@ Lets platform admins configure one or more LLM backends for AI pieces in flows. - `isActivepiecesAiProviderHidden` hides the managed provider when the `aiCreditsEnabled` flag is off (`OPENROUTER_PROVISION_KEY` unset — typical self-hosted) or when `shouldHideActivepiecesAiProvider` returns true, which is gated only on `plan.embeddingEnabled`. - Hidden means treated as absent everywhere: `listProviders()` omits the row and `getChatProvider()`/`getChatProviderName()` return null (`findAvailableChatProviderRow`). This keeps a stale `enabledForChat` (e.g. from the 0.82.1 migration) from pinning chat to a provider that 402s with no top-up path (GIT-1620). +### Model catalog + +Per-model metadata (context window, max output, release date, per-million input/output price, +tool-calling / reasoning / vision) for the models a provider lists. Sourced from +[models.dev](https://models.dev) (MIT), generated by `npm run sync-model-catalog` and published by a +weekly workflow to `https://cdn.activepieces.com/ai/model-catalog.json` — **nothing is committed and no +process imports it**. `modelCatalog.lookup({ provider, modelId })` is the single accessor: it is async, +fetches the object once and caches it for 24h, dedupes concurrent callers on one in-flight promise, and +backs off for 5 minutes after a failure so a CDN outage cannot slow the models endpoint. Enrichment +happens once, in the `modelsCache` re-map inside `fetchModels`, so the web picker, the AI piece dropdown +and `ap_list_ai_models` all get it from the same place. See decision 000032. + +Prices are rounded to three decimals in the generator, both to kill float artefacts +(`0.049999999999999996`) and because a handful of OpenRouter models — `deepseek/deepseek-v4-flash` +among them — carry continuously floating five-decimal prices. Three decimals is below anything the UI +renders and preserves every real price; the cheapest in the set is 0.01. + ### Gotchas +- **A catalog change takes up to ~2 days to reach a dropdown, through three caches in series.** CDN edge (`--cache-control max-age=3600`, 1h) → the server's in-memory catalog (`CATALOG_TTL_MS`, 24h) → `modelsCache` (flushed by the nightly `cron.schedule('0 0 * * *')`). So "I republished but the UI still shows the old price" is expected, not a bug; restarting the API short-circuits the two in-process layers. The edge TTL is deliberately 1h and not the 604800 that `publish-embed-sdk.yml` uses — that path is version-stamped, ours is a stable key rewritten in place, so a week-long edge cache would pin stale prices for a week. +- **Publishing is an overwrite of one S3 key, and it only ever happens on the Monday cron or a manual `workflow_dispatch`.** Never on merge, deploy or release. There is no versioning and no history: last write wins and the previous contents are gone, which is why the object carries `generatedAt` — `curl -s https://cdn.activepieces.com/ai/model-catalog.json | jq .generatedAt` is the only way to tell how fresh what you are serving is. Editing the generator changes nothing in production until someone dispatches the workflow. +- **No egress to the CDN means no model metadata, permanently and silently.** Air-gapped installs, networks with an outbound allowlist, and CDN outages all fall back to the plain `{ id, name }` row with no message explaining it — every metadata field is optional, so nothing throws. `AP_MODEL_CATALOG_URL` points at a self-hosted mirror and is the only fix. This is a knowing exception to `.claude/rules/self-hosting.md` (decision 000032), so treat "self-hoster says prices are missing" as a network question, not a bug. +- **A new provider needs an entry in the generator's `MODELS_DEV_PROVIDER` map or it silently ships with no metadata.** models.dev provider ids do not match ours: `bedrock` → `amazon-bedrock`, `qwen` → `alibaba`, `moonshot` → `moonshotai`, `activepieces` → aliased onto `openrouter` at lookup time. The six OpenAI-compatible vendors were merged before the map was updated and produced exactly this — the run prints `no upstream source: …`, which is the thing to read after adding a provider. `cloudflare-gateway` and `custom` legitimately have no source. +- **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. - **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. @@ -44,7 +66,28 @@ Lets platform admins configure one or more LLM backends for AI pieces in flows. - **A failed credential validation tells the admin nothing, for every provider except Cloudflare Gateway.** `aiProviderService.validateProviderCredentials` gates the upstream message behind `includeHttpErrorInMessage`, which is `provider === CLOUDFLARE_GATEWAY` and nothing else, so everyone else gets a bare `Failed to validate credentials for `. The cause is not lost — it is logged one line earlier (`log.error({ error }, '[aiProviderService#validateProviderCredentials] ...')`) and passed as the `httpErrorResponse` error param — but **web never renders `httpErrorResponse`**, so the only way to diagnose a rejected key is the server log. Grep the log for `validateProviderCredentials` before assuming the provider integration is broken — that text is the whole diagnosis, and it is often not about credentials at all. Confirmed case: a brand-new xAI team with no credits purchased answers `GET /v1/models` with `403 permission-denied — Your newly created team doesn't have any credits or licenses yet`, naming the console page that fixes it, and we render that as "Failed to validate credentials for xAI" — sending the admin off to regenerate a key that was never wrong. Vendors also phrase real key failures inconsistently (xAI uses `400 Incorrect API key provided`, not a 401). The corollary: **a provider that saves without error is not a working provider.** A no-credits 403 and a bad key are indistinguishable in the UI, so only an actual generation proves a key end to end. This is an admin-only surface (`platformAdminOnly`), so there is little reason to keep hiding it. - **A provider's logo is an asset someone has to upload, not something the code ships.** `AiProviderInfo.logoUrl` in `packages/web/src/features/agents/ai-providers.ts` is a plain string rendered into an ``, and every provider points at `https://cdn.activepieces.com/pieces/.png` — nothing is bundled. Adding a provider therefore carries a cross-team dependency with no compile-time or test signal: a slug with no asset behind it renders a broken-image icon in the platform admin list, and only a live request tells you. Check the URL with `curl -o /dev/null -w '%{http_code}'` before assuming it works — a vendor that already ships as a *piece* usually has its logo there already (`deepseek.png`, `grok-xai.png` did), so start the upload request only for the genuinely missing ones. A Vite asset import also satisfies `logoUrl` (see `GoogleIcon` in `platform/security/sso/index.tsx`) and removes the runtime CDN dependency for air-gapped installs, but it diverges from every other provider — treat it as a fallback, not the default. - **`AIProviderConfig` is an *untagged* `z.union`, so a new provider's config schema must sit ahead of the empty ones — and "empty" includes a schema whose every field is optional.** Zod strips unknown keys and a union returns the first member that parses, so `AnthropicProviderConfig` (`z.object({})`) matches *any* object: list it before a `{ baseUrl?: string }` config and a configured base URL is silently reduced to `{}` — no error, no log, the admin's override just stops existing on the next read. The file carries an `Order matters` comment, but it says "empty ones last", which reads as though only a literal `z.object({})` is at risk. The safe rule is to insert any new config immediately after the last schema with a *required* field (today `BedrockProviderConfig`). `ProviderConfigUnion` is discriminated on `provider` and so is immune; only the two untagged unions (`AIProviderConfig`, `AIProviderAuthConfig`) bite. Both live twice — `packages/core/shared/.../management/ai-providers/index.ts` (zod classic) and `packages/core/piece-types/.../ai-providers.ts` (zod/mini, the copy pieces use) — and every provider edit has to land in both. **There is a third copy the shared package does not own:** `createFormSchema` in the admin dialog (`.../setup/ai/universal-pieces/upsert-provider-dialog.tsx`) re-declares a per-provider schema, branching explicitly on Azure / Cloudflare / Custom / Bedrock and falling through to a generic case whose `config` is a union of three empty objects. **A provider with a non-empty config and no branch there loses that config entirely** — `zodResolver` hands react-hook-form the *parsed* value, so the strip happens before submit and the setting is never sent, with no error anywhere. Fixing the shared union does not fix this one; grep for every union of config schemas when adding a provider. The dialog only diverges from the correct `ProviderConfigUnion` to make auth optional in edit mode, so collapsing it onto the shared discriminated union is the real repair. (Testing that file directly is awkward: importing it pulls in a transitive dep that touches `document` at import time, which a node-env vitest cannot load — the schema factory would have to move out of the component file first.) -- **A failed `enrichWithKeysIfNeeded()` is self-sustaining, and it takes chat down with it.** `createKey` runs on the chat hot path — `agentHelpers.resolveChatProvider` → `aiProviderService.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 `getAgentConfig` before the first token, with no recovery until the bucket reset at 00:00 UTC. +- **Every catalog field is optional, and two providers never match at all.** Azure's `listModels` + returns *deployment names* (arbitrary admin-chosen strings), and CUSTOM / CLOUDFLARE_GATEWAY ids are + hand-typed, so `modelCatalog.lookup` returns `undefined` for them by design. Any UI reading + `model.metadata` must degrade to the bare `{ id, name }` row rather than render an empty unit. + Azure *could* be matched — `azure-provider.ts` discards the upstream `model` field, which is the + underlying base model id. +- **Bedrock ids arrive region-prefixed.** `bedrock-provider.ts` returns an inference-profile id + (`us.anthropic.claude-…-v1:0`) when one exists, but models.dev keys the foundation id + (`anthropic.claude-…-v1:0`). The lookup strips `us.`/`eu.`/`apac.`/`global.` and keeps the `:N` + version suffix, which is part of the upstream key. +- **`PROVIDER_MAX_CONTEXT_TOKENS` is still a per-provider guess and still drives compaction.** The + catalog exposes the real per-model window on the API, but `aiProviderUtils.getMaxContextTokens` + was not rewired: all five call sites (`shouldCompact` / `compactMessages` in `ee/agent/agent-compaction.ts`, + `runawayTokenCeiling` / `boundContextForStep` in the worker's `run-agent-turn.ts`) thread + `provider` and no `modelId`. So EE agent compaction still fires at, say, 200k for every Anthropic + model including the 1M ones. Fixing it is plumbing plus an `agent-evals` run. +- **`MANAGED_MODEL_WEIGHTS` is a pricing ladder, not a cache of cost — don't derive it from the catalog.** + It tracks real output price but is not a function of it (`claude-opus-4` $75/M → weight 45; + `claude-opus-4.7-fast` $150/M → weight 200). Deriving it would silently re-price customers. Billing + never reads `AIProviderModel` at all: `flow-run-ai-usage-tracker` computes credits from run-log + telemetry times that static table. +- **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. diff --git a/brain/knowledge/connections-auth/ce-authentication.md b/brain/knowledge/connections-auth/ce-authentication.md index b4a3bfeb5810..8667d91df6f4 100644 --- a/brain/knowledge/connections-auth/ce-authentication.md +++ b/brain/knowledge/connections-auth/ce-authentication.md @@ -18,13 +18,21 @@ The core (all-editions) auth layer: user identity creation, sign-in, and JWT ses - First sign-up side effects: creates identity → User (PlatformRole.ADMIN) → default PERSONAL project; sends OTP on Cloud prod, auto-verifies otherwise; fires `USER_CREATED` flag + `SIGNED_UP` telemetry. - **`signUp` has two arms and only one of them can create a platform.** When `params.platformId` is set (self-hosted, or a custom domain) the member joins that existing platform through `getOrCreateWithProject` and no platform is ever created or named. When it is nil (Cloud only) the identity is created first, then `getPreferredPlatformId` looks for a platform the identity already belongs to; finding none it hands off to `authenticationUtils.provisionOrOnboard`, which creates the platform straight away when the identity already carries a name someone gave us, and only falls back to an ONBOARDING response (finished at `/create-platform`) when the name is the placeholder derived from the email. `getPreferredPlatformId` returns null on every non-Cloud edition. **The member never types a platform name; they type their own, and the platform name is derived from it.** `completeSignUp` takes a single `fullName` field (that is the whole of `CompleteSignUpRequest`) and calls `signupNames.platformNameFromSignup`, which prefers the company read off a work email domain (`"Activepieces"`) and falls back to the person (`"'s Platform"`, then the capitalised first token of the email local part, then `"My Platform"`). The project name follows from the platform name via `personalProjectName`. - **ONBOARDING** is the pre-platform principal: `authenticationUtils.getOnboardingResponse` mints it with `platformId: null, projectId: null` for a verified identity that belongs to no platform yet **and whose name we only guessed**, so the member can call `POST /v1/platforms` (`securityAccess.unscoped([ONBOARDING, USER])`) and land on `/create-platform`. It is Cloud-only in practice, because on self-hosted `platformUtils.getPlatformIdForRequest` falls back to `getOldestPlatform()` and there is always a platform to join. `accessTokenManager.assertUserSession` still revalidates it against `tokenVersion` + `verified`. +- **Sign-up address validation** is one call to ZeroBounce (`zerobounce.maySignUp`), from `signUp` for the EMAIL provider and from `requestCode` for an address with no identity yet. It runs only when `AP_ZEROBOUNCE_API_KEY` is set, refuses the abuse half of `do_not_mail` plus `spamtrap`/`abuse`, and fails open on anything it cannot read. Both call sites refuse **silently**, and the lib throws nothing: `requestCode` returns the same `204` as a success (no identity, no code), and `signUp` throws `EMAIL_IS_NOT_VERIFIED`, the response a genuine unverified Cloud sign-up already produces. `DOMAIN_NOT_ALLOWED` is not used here at all. See [000032](../decisions/000032-a-signup-address-is-checked-against-zerobounce-not-a-bundled-blocklist.md). - **Passwordless sign-in** (`EMAIL_LOGIN`) is a typed 6-digit code on the same OTP primitive, offered only when `ApFlagId.SMTP_CONFIGURED` is true, with password as the fallback path. See [000027](../decisions/000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md) for the code-not-link, edition-reach and anti-enumeration reasoning. ### Gotchas - Email-auth checks and domain allow-listing guards are **skipped on Community** edition. - **OTP verification is sent on Cloud unless `AP_ENVIRONMENT` is exactly `dev`, in which case the identity is silently auto-verified and no email goes out.** `sendVerificationOrAutoVerify` compares against `ApEnvironment.DEVELOPMENT`, whose value is the string **`dev`** — not `development`, which an earlier version of this line claimed. The distinction is not cosmetic: `AP_ENVIRONMENT=dev` on Cloud takes the `verify()` branch and the email-code flow becomes untestable locally, while any other value (including a typo like `development`, which fails the system validator with a warning and nothing more) falls through to `otpService.createAndSend` and really does email. So to exercise sign-up email locally on Cloud, `AP_ENVIRONMENT` must not be `dev`; `prod` also works but switches on the newsletter POST to a live endpoint. CE/EE take the other edition arm entirely. +- **`status: invalid` is deliberately NOT refused at sign-up.** A non-existent mailbox is allowed to create an (unverified) identity, because refusing it would make the public sign-up endpoint a mailbox-existence oracle for any address at any domain. So a random-address bot still gets a row; what it cannot get is a verified session. Do not "tighten" this without reading [000032](../decisions/000032-a-signup-address-is-checked-against-zerobounce-not-a-bundled-blocklist.md). +- **`do_not_mail` is not a rejection on its own.** It covers `role_based`, `role_based_catch_all` and `mx_forward` as well as the abuse sub-statuses, so refusing the whole status would refuse `info@` and `sales@` — normal ways a team signs up. Only four sub-statuses are refused: `disposable`, `toxic`, `possible_trap`, `global_suppression`. Note those four are **not** all the same shape: `disposable` is a property of the domain, while `toxic` and `global_suppression` (and `spamtrap`/`abuse`) describe one address. Any future caching or batching of verdicts has to respect that — a per-domain cache is sound only for `disposable`, and caching an *allow* verdict per domain is never sound, because it would skip the address-level checks for every other mailbox on that domain. +- **`turnstile.assertSolved` must stay ahead of the ZeroBounce call in `requestCode`.** Each validation costs a ZeroBounce credit, and the captcha is what stops an unsolved request from spending one. Reordering those two lines turns the endpoint into a way to drain the credit balance. +- **A refused address creates nothing, so a bot can re-hit the same address forever.** That used to cost a credit per attempt, which made draining the balance a bypass (fail-open means an empty balance passes everything). `disposable` verdicts are now cached in one `distributedStore` key, `zerobounce:disposable-domains:v1` — an insertion-ordered array of at most 500 domains, oldest dropped on overflow, no TTL — so repeat abuse on one domain costs a single credit fleet-wide. Rotating across *new* domains still costs a credit each — that half is bounded by the captcha, the auth rate limits and whatever the edge enforces, not by the cache. +- **ZeroBounce does not answer a bad key the way its docs say.** The documented failure is `HTTP 200` with `{"error": "Invalid API Key or your account ran out of credits"}`, and `isRefused` does check that body — but an unrecognised key is rejected at the Cloudflare edge with **`403` + `error code: 1020`**, on `api`/`api-us`/`api-eu` alike and for any User-Agent, so the axios-error branch is the one that fires. Both fail open, so the outcome is the same; what matters is that `1020` means "this key is not accepted", NOT "we are blocked". The block is scoped to the `api_key`-taking paths — `https://api.zerobounce.net/` answers `200` and `/v2/` answers `404` from the same host — so do not read a `1020` as a network or geo problem without checking those two first. +- **A mimicked response must match the real one down to value normalization.** `signUp`'s silent refusal echoes the address lowercased and trimmed, the way the identity service stores it. The first cut echoed it as submitted, so a mixed-case address came back verbatim on a refusal and lowercased on a real sign-up — a working oracle. The test asserts `toEqual` on the whole body, not just the code, which is what caught it. - Telemetry PII (email/name) sent only on Cloud; CE/EE send non-PII fields (`pickTelemetryPii`). Sign-in telemetry covers password sign-in only, not SSO. - Sessions are invalidated by rotating `tokenVersion` on `UserIdentity`. +- **An SMTP failure in `otpService.createAndSend` answers `500` *after* the identity row is committed.** The code path creates the identity, then sends; a rejected send throws out of the request, so the caller sees an error while a verified-nothing identity persists and no code exists for it. The failure also arrives with `[evlog] log.error() called after the wide event was emitted — Keys dropped: route, error`, so it never reaches observability either — meaning this is invisible in dashboards and only findable in raw container logs. Seen on a Cloud preview 2026-08-26; not fixed. - **A new unauthenticated endpoint must be added to `disallowedRoutes` in `packages/web/src/lib/api.ts`**, otherwise the SPA attaches whatever stale bearer token is still in storage and the call fails in exactly the situation the endpoint exists for. - **The three signup guards in `authentication-utils.ts` differ in what they leak.** `assertEmailAuthIsEnabled` and `assertDomainIsAllowed` describe platform configuration, so surfacing their errors is safe. `assertUserIsInvitedToPlatformOrProject` describes one address, so surfacing it turns any public auth endpoint into an invitation oracle. All three are also inert unless `plan.ssoEnabled`. - **We ask for a name only when we do not already have one, and `signupNames.isPlaceholderName` is what decides.** A name counts as a placeholder when the last name is empty *and* the first name matches `firstNameFromEmail` for that address case-insensitively — exactly what `requestCode` seeds an emailed-code identity with. Anything else provisions the platform without a second question, and the two other producers of a name cannot collide with the placeholder shape: `SignUpRequest` types `firstName`/`lastName` as `SAFE_STRING_PATTERN` (`^[^./]+$`, so an empty last name is a 400 at the schema, not just a required field in the form), and the Google callback substitutes `'john'`/`'doe'` when the provider omits a name. The comparison must stay case-insensitive: `requestCode` derives the name from the raw address while the identity stores it lowercased, so `AhmadTash@…` would otherwise look like a name its owner typed. diff --git a/brain/knowledge/decisions/000032-a-signup-address-is-checked-against-zerobounce-not-a-bundled-blocklist.md b/brain/knowledge/decisions/000032-a-signup-address-is-checked-against-zerobounce-not-a-bundled-blocklist.md new file mode 100644 index 000000000000..c500090ce941 --- /dev/null +++ b/brain/knowledge/decisions/000032-a-signup-address-is-checked-against-zerobounce-not-a-bundled-blocklist.md @@ -0,0 +1,39 @@ +--- +status: accepted +--- + +# A sign-up address is checked against ZeroBounce, not a bundled blocklist + +## Decision +`zerobounce.maySignUp` — the module's only export — asks ZeroBounce's `GET /v2/validate` about every address that has no account yet, and refuses the sign-up on `spamtrap`, `abuse`, or `do_not_mail` with a sub-status of `disposable`, `toxic`, `possible_trap` or `global_suppression`. The `disposable-email-domains` package leaves the sign-up path. The check runs only when `AP_ZEROBOUNCE_API_KEY` is set, and `AP_ALLOW_DISPOSABLE_EMAILS` is deleted — the key's presence is the switch. + +## Context +Cloud was taking botted sign-ups. The defence was a `Set` built from `disposable-email-domains` at module load, consulted by `disposableEmail.assertMaySignUp`. That list is a build artefact: it is fixed at release time, so a domain registered after the build is invisible until the next release, and an operator running an older tag never sees it at all. The purge script that cleans up the platforms those sign-ups leave behind needs an explicit `--domains` list precisely because "the package does not know about" the evasion domains actually in use. + +## Why + +**A blocklist that ships with the release is always behind the attacker.** Throwaway domains are registered continuously and cost nothing; a list compiled weeks earlier and frozen into a tag cannot see them. A hosted service answers about the domain as it is today, which is the only version that matters. + +**Junk, not non-existence.** `status: invalid` — a mailbox that does not exist — is deliberately allowed through, even though random-address bots at real domains are exactly what it would catch. Refusing it turns the public sign-up endpoint into a mailbox-existence oracle for any address at any domain, and it refuses the ordinary member who typos their own address. Bots that get in this way still cannot verify a code, so they get an unverified identity row and nothing else, which the purge path already handles. + +**Role addresses are accepted.** `do_not_mail` also covers `role_based`, `role_based_catch_all` and `mx_forward`. Refusing those would refuse `info@`, `sales@` and `automation@` — ordinary ways a team signs up for an automation tool. The reject set is deliberately the abuse half of `do_not_mail`, not the whole status. + +**Fail open, always.** An unreachable endpoint, a timeout, an invalid key, an exhausted credit balance — every one of these lets the address through with a log line. A validation outage that blocked sign-up would be a worse outage than the abuse it prevents, and ZeroBounce answers `HTTP 200` with an `{"error": ...}` body for the key and credit cases, so that body is checked explicitly rather than left to the status code. This mirrors the posture `turnstile.ts` takes in the same directory, with one difference: Turnstile refuses when siteverify *answers* with an error status, and there is no equivalent here, because any ZeroBounce answer we cannot read is simply no verdict. + +**The key is the feature flag.** `.claude/rules/self-hosting.md` forbids anything that looks enabled but is silently broken without setup, and a paid API cannot be a default. Gating on the key means an instance with no ZeroBounce account sees no check and no error, and one with a key gets it with no second switch to discover. `AP_ALLOW_DISPOSABLE_EMAILS` therefore had nothing left to control and was removed rather than kept as a knob that only ever agrees with the key. + +**The captcha comes first.** In `requestCode`, `turnstile.assertSolved` runs before the validation call, so no credit is spent on a request that has not solved a challenge. That ordering is load-bearing, not incidental: it is what makes per-address credit spend bounded by solved captchas rather than by packets. + +## Consequences +The sign-up path now depends on a third party, and self-hosters lose a check they had by default. + +- **An instance with no key accepts throwaway addresses where it previously refused them.** This is a deliberate loosening for self-hosters, and deliberately *not* recorded in `breaking-changes.mdx`: nothing errors, nothing has to be done on upgrade, and the call was that no self-hoster was leaning on the bundled list. The cost is that the loss is silent — anyone who *was* relying on it learns from throwaway sign-ups rather than the upgrade guide. `AP_ALLOW_DISPOSABLE_EMAILS` is removed on the same reasoning: an instance that still sets it boots fine and the value is ignored. Self-hosted instances are not the ones under sign-up attack, and the alternative — shipping a stale list to everyone — is what was being replaced. +- **Burning the credit balance is the bypass, so disposable domains are cached.** Fail-open plus an exhaustible balance means an attacker who spends the credits makes every subsequent address pass. A refusal creates no identity, so the same address can be re-tried indefinitely; without a cache each attempt cost a credit. `disposable` verdicts therefore accumulate in one `distributedStore` key, `zerobounce:disposable-domains:v1`, holding an ordered array of at most 500 domains: a hit is an array membership test, and a new domain is appended with the oldest dropped once the list is full. Hammering one domain costs one credit, ever, rather than one per attempt. This bounds *repeat* abuse, not *breadth*: rotating across new domains still costs a credit each, which is the edge's job (a Cloudflare rate-limiting rule on the sign-up paths), not the app's. +- **The cache is a bounded list with no TTL, not one key per domain.** One key means one read on the hot path and a size cap that is simply `slice(-500)`, where per-domain keys would need a separate index to bound them. It is insertion-ordered, not true LRU — a hit does not promote its entry, because that would put a Redis write on every refused sign-up. The read-modify-write is not atomic, so two instances adding different domains at once can lose one; the cost is one credit later, which is the right trade for a cache. Dropping the TTL makes an entry live until 500 newer domains evict it, so correcting a wrong verdict means deleting that single key and letting the list rebuild — cheap, but someone has to know the key exists. Redis is a cache, not a record: a flush or an `allkeys-lru` eviction silently drops the list, which costs credits and nothing else. +- **Only a domain-shaped verdict may be cached by domain.** `disposable` is a property of the domain; `toxic`, `global_suppression`, `spamtrap` and `abuse` describe one mailbox, so caching those by domain would refuse every account on `gmail.com` after a single bad one. Allow-verdicts are not cached either, since a cached "this domain is fine" would skip the address-level checks for every other mailbox on it. Four tests pin each half of this. +- **One credit per address that has no account yet.** `requestCode` skips the check entirely for an existing identity, so a repeat sign-in costs nothing. A refused address creates no identity, so a bot re-hitting the same address does spend a credit per attempt; the rate limits and the captcha in front are what bound that. Running out of credits degrades to fail-open, not to an outage. +- **Both paths refuse silently, each by mimicking its own legitimate outcome.** The lib only answers the question — `maySignUp` returns a boolean and throws nothing — because the two call sites need different disguises. `requestCode` returns the same `204` as a success, creating no identity and sending no code, so the card advances to "Enter your code" and none arrives. `signUp` throws `EMAIL_IS_NOT_VERIFIED` with the address, which is *exactly* what a real Cloud sign-up returns (the identity is created unverified, then `getOnboardingResponse` throws it), so the form renders its ordinary `CheckEmailNote`. Both mirror the silent-return [000027](./000027-email-sign-in-is-a-typed-code-on-the-existing-otp-primitive.md) already uses for the invitation check, for the same reason: a distinguishable answer turns a public endpoint into an oracle, here for "is this address disposable". `DOMAIN_NOT_ALLOWED` is therefore absent from this path entirely and means only the platform domain allow-list again. The cost is that a false positive is invisible to the member *and* to support: the only trace is the `address refused` log line, so that log is the sole way to answer "why did no code arrive". +- **A mimicked response has to match the real one down to value normalization.** The first cut echoed `params.email` as submitted while the genuine path echoes it lowercased, having read it back from the stored identity — so a mixed-case address came back `Zb-Disp@YopMail.com` on a refusal and `zb-disp@yopmail.com` on a real sign-up, which is a perfectly good oracle. Any future mimicry needs the same `toLowerCase().trim()` the identity service applies. The test that pins this asserts `toEqual` against the whole body, not just the code, which is what caught it. +- **The reject set is domain-flavoured, so the error stayed `DOMAIN_NOT_ALLOWED`.** No new error code and no web change. If `invalid` is ever added to the reject set, that message ("Email domain is disallowed") becomes wrong for the address-level cases and needs its own code. +- **The offline blocklist survives only inside the purge script**, which classifies existing identities in bulk and must run without spending a credit per row. `disposable-email-domains` stays a runtime dependency for it — the runtime image installs with `bun install --production`, so a `devDependency` would not exist where that script runs. +- A member who already holds an accepted invitation is never refused, unchanged from the blocklist behaviour. diff --git a/brain/knowledge/decisions/000032-the-model-catalog-is-served-from-the-cdn.md b/brain/knowledge/decisions/000032-the-model-catalog-is-served-from-the-cdn.md new file mode 100644 index 000000000000..14787ef864a2 --- /dev/null +++ b/brain/knowledge/decisions/000032-the-model-catalog-is-served-from-the-cdn.md @@ -0,0 +1,63 @@ +--- +icon: 📇 +status: accepted +--- + +# The model catalog is served from the CDN + +## Decision + +Per-model metadata is published to `https://cdn.activepieces.com/ai/model-catalog.json` by a weekly +workflow and fetched at runtime by `modelCatalog.lookup()`. Nothing is committed to the repo and no +process imports it. The source is [models.dev](https://models.dev) and only models.dev. + +## Context + +The model picker needed cost, context window, release date and capability flags, none of which existed +in the tree. The first implementation vendored a generated JSON into `@activepieces/server-utils` and +imported it. Review pushed back on shipping a 213 KB file that every process parses at boot. + +## Why + +The measured cost of the vendored file was small — **0.7 ms to parse, ~452 KB heap** — but the +objection had a real defect behind it: `packages/server/worker` depends on `server-utils`, so the +worker paid that cost while never calling the catalog at all. + +The CDN buys two things the file could not. The catalog **refreshes without a redeploy**, so a new +model or a price cut reaches every install within the hour instead of at the next release. And with +nothing to commit, the weekly job no longer pushes a branch or opens a PR — which removed the +`GITHUB_TOKEN`-cannot-create-PRs problem entirely (see the CI PR Review Hygiene page). + +The rejected alternative was keeping the file in the image as an offline fallback, read lazily only +when the fetch fails. That would have answered the parse-cost objection just as well and kept +air-gapped installs working, at about ten extra lines. It was turned down in favour of the simpler +single-source design. + +## Consequences + +**An install with no egress to `cdn.activepieces.com` gets no model metadata, ever** — air-gapped +deployments, networks with an outbound allowlist, or a CDN outage. It degrades to the plain +`{ id, name }` row rather than breaking, because every metadata field is optional, but there is no +message explaining the absence. `AP_MODEL_CATALOG_URL` is the escape hatch: point it at a self-hosted +mirror. This is a knowing exception to `.claude/rules/self-hosting.md`, not an oversight. + +**Pricing data reaches production weekly with nobody reviewing the diff.** The generator's truncation +guard — refuse to publish if a provider block disappears or the model count falls more than 20% against +the currently published copy — is the only thing between a partial models.dev payload and every +install. It compares **per provider**, not just in aggregate: a collapse inside one provider is +otherwise hidden by growth in another — a previous catalog with openai at 200 against a regenerate at +47 passes an aggregate check (794 of a required 554) while openai loses three quarters of its models. +A loss of at most two models is tolerated regardless of ratio, so a three-model provider like deepseek +does not trip the guard on one legitimate removal. Because it is the only check, it **fails closed**: only a `404` (nothing published yet) is +allowed to skip it and bootstrap the first upload. A network error, a 5xx, or a non-JSON body all abort +the run, because "cannot read the current catalog" is not the same as "there is no current catalog" — +treating them alike lets a transient CDN blip disable the guard at exactly the moment it matters. +That rule has two layers and both were needed: rejecting a body that is not JSON is not enough, because +valid JSON of the wrong shape (`{"foo": 1}`) leaves `providers` undefined and reads as "no prior +catalog" all the same. The document is validated structurally before it is trusted. `{"providers": {}}` +is deliberately allowed through — an empty catalog is a recoverable state, not an unknown one, and +blocking it would strand the next publish after a bad one. The +published object carries `generatedAt` so staleness is diagnosable with a `curl`. + +**The object must exist before the code ships.** Until the first `workflow_dispatch` run lands it, +every install silently shows no metadata, including local dev. diff --git a/brain/knowledge/engineering/architecture-spine.md b/brain/knowledge/engineering/architecture-spine.md index 29c29d4e1b5a..05270196462d 100644 --- a/brain/knowledge/engineering/architecture-spine.md +++ b/brain/knowledge/engineering/architecture-spine.md @@ -38,6 +38,8 @@ Activepieces: open-source AI-first workflow automation platform (self-hosted or **`distributedLock().runExclusive` waits for the *whole* `timeoutInSeconds` under contention — never put one on a request path.** `distributed-lock-factory.ts` configures Redlock with `retryCount = Math.ceil(timeout / 200)` and `retryDelay: 200`, so the retry budget is exactly the lock TTL: a `timeoutInSeconds: 15` lock retries 75 times before giving up, and each retry is its own Redis round-trip. N concurrent requests contending on one key therefore generate up to N×75 pure-retry commands against shared Redis *while* every one of them stalls for up to 15s. Read-mostly checks belong on the cache with the fetch scheduled behind the response (`rejectedPromiseHandler` + `distributedStore.runOnceWithin` gives cluster-wide dedupe without a lock); reserve `runExclusive` for genuine write serialization off the hot path. Surfaced 2026-08 in the Autumn credits gate (PR #14436, `f0638438`), where an exhausted or cold platform made every webhook, AI-proxy call and chat turn take a reverify lock plus a `platform_plan` SELECT plus a 5s Autumn HTTP call inline — a ~20s worst case on the highest-volume path in the product. Related: [[ee-platform-plans-billing]]. +**`distributedStore.putBoolean` cannot take a TTL, so it writes a key that never expires.** `put(key, value, ttlInSeconds?)` takes one and uses `SETEX` when given it; `putBoolean`/`putBooleanBatch` take only the value and always `SET`. Reaching for `putBoolean` for a cheap boolean cache therefore leaks a permanent key per distinct cache key, and a later rename orphans every one of them — the same trap `packages/server/CLAUDE.md` warns about for a TTL-less `put`, except here there is no parameter to forget. Use `put(key, true, ttl)` and accept the JSON byte, or set the expiry yourself. + **Don't `.max()` a business limit on a request body — cap server-side.** A `.max()` on a request-body field rejects the *whole* request with a 400 the moment a user crosses it, so a user editing a list that reaches 50 items loses their entire save. Reserve `.max()` for a true trust-boundary DoS guard (Fastify's global body limit already covers gross abuse) and let business limits just *apply*: accept the input and `slice(0, MAX)` in the service layer, so the write always succeeds with the limit quietly enforced. Surfaced 2026-07 on `POST /v1/chat/memory`, where the schema's `.max(50)`/`.max(280)` duplicated a `slice` the save helper already did — redundant *and* a data-loss bug. **`unique()` from `core-utils` is O(n²) over `JSON.stringify` — never put it on a hot path.** It is `filter` + `findIndex` with a `JSON.stringify` on *both* sides of every comparison, so it blocks the event loop: 1k items → 42ms, 5k → 889ms, 10k → 3.6s, during which health checks, websockets and webhook dispatch all stall. It exists for deep-equality dedupe of objects; for primitives use `[...new Set(xs)]`. Found 2026-07 as the first statement of the bulk record delete the same PR was trying to speed up (GIT-1652). diff --git a/brain/knowledge/engineering/ci-pr-review-hygiene.md b/brain/knowledge/engineering/ci-pr-review-hygiene.md index c2779187e649..566a7f0bc9dd 100644 --- a/brain/knowledge/engineering/ci-pr-review-hygiene.md +++ b/brain/knowledge/engineering/ci-pr-review-hygiene.md @@ -27,12 +27,19 @@ Enforcement is the **`Codeowners review` repository ruleset** (active on the def - **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. +- **A workflow that opens a PR must authenticate with `secrets.CROWDIN_PRS`, not `GITHUB_TOKEN`.** Despite the name, that PAT is this repo's open-a-PR-as-a-bot token: `crowdin-pr-merger.yml`, `reusable-finalize-translations-pr.yml` and — the tell — `release-self-hosted.yml`, which has nothing to do with Crowdin and uses it for both `actions/checkout`'s `token:` and `gh pr create`'s `GH_TOKEN`. Those jobs declare only `permissions: contents: read`, because the PAT does the pushing and the PR-opening; raising `GITHUB_TOKEN` to `contents: write` / `pull-requests: write` instead is treating the symptom, since *Allow GitHub Actions to create and approve pull requests* is evidently off for the org (not readable without `admin:org`). The failure mode is nasty because it is half-done and unattended: the branch pushes fine and only `pulls.create` fails, leaving an orphan `auto/*` branch every scheduled run. Copy `release-self-hosted.yml`, and have the job delete its own branch on failure so a bad week retries clean instead of accumulating. - **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`. +- **A version bump that merges cleanly can still be wrong — check what `main`'s number *means*, not whether it conflicts.** Two branches bumping the same package to the same number do not conflict, so git takes it silently; but if `main`'s copy of `0.5.0` is another PR's content and yours adds further exports on top, you ship new exports under an already-published version and nothing catches it. Seen merging [#15001](https://github.com/activepieces/activepieces/pull/15001) after the six-providers PR landed: `core-piece-types` and `pieces-framework` auto-merged at `0.5.0` / `0.37.0` and both needed a further bump. Only a *conflicting* version (like `core/shared` `0.140.0` vs `0.141.0`) forces you to think; the clean ones are the dangerous ones. After any merge, re-check every package you bumped against `git show origin/main:/package.json`. The reverse also happens: when review makes you *delete* code, the bump it justified can become dead — after acting on review, `git diff origin/main...HEAD -- /src` and drop the bump if it is empty. On #15001 two packages ended up byte-identical to `main` while still carrying a bump, which is noise at best and a version collision at worst. +- **`@activepieces/shared` re-exports from `@activepieces/core-execution`, so a partial rebuild produces phantom "has no exported member" errors in unrelated files.** Rebuilding `core/shared` against a stale `core/execution` dist drops those re-exports, and the API typecheck then fails in `ee/agent/*` on symbols like `GetPersonalizationConfigRequest` — which live in `core/execution/src/lib/workers/worker-contract.ts`, not in shared at all. It reads exactly like a bad merge. The dependency order that actually works is `core/utils` → `core/piece-types` → `core/formula` → `core/execution` → `core/shared` → `server/utils` → `pieces/framework` → `core/ai-providers`; skipping a link silently poisons everything downstream of it. The same staleness makes an editor report missing enum members that exist in the source. +- **To pull a file back out of a PR, restore it from the merge-base, never from `origin/main`.** A PR's diff is computed against the merge-base, so `git checkout origin/main -- ` does not "revert" the file — it imports every change `main` made to it since the fork and attributes them to you. Dropping one web file from [#15001](https://github.com/activepieces/activepieces/pull/15001) that way would have silently added 82 insertions / 44 deletions of somebody else's work. `git checkout $(git merge-base origin/main HEAD) -- ` makes it byte-identical to where the branch started, so it leaves the diff entirely and merges cleanly instead of conflicting. Verify with `git diff --quiet $(git merge-base origin/main HEAD) -- ` before committing, and read `git status` first — a `bun.lock` left dirty by an earlier `bun install` loves to ride along on a commit like this. - **Retargeting a stacked PR to `main` does not drop its base branch — it merges the whole thing.** A PR opened against a long-lived feature branch shows a small diff *relative to that base*, but `gh pr edit --base main` only moves the target; the branch still contains every commit of its old base. [#14593](https://github.com/activepieces/activepieces/pull/14593) read as 2 docs files against `feat/autumn-billing-integration` and as 198 commits / 211 files / +12k lines against `main`. Check with `git diff --stat origin/main...` **before** retargeting, and if it disagrees with the PR page, cherry-pick that PR's own commits onto `main` and force-push instead. A "conflict" on such a PR is often against the feature base only — those same commits can apply to `main` cleanly. - **A decision authored on a long-lived branch will collide on its number.** `brain/decisions/` numbers are assigned once and never reused, but the next free number is only knowable against `main` — two branches in flight both grab it. #14593 carried a `000024` that `main` had since filled, and `000025` too, so it landed as `000026`. Renumber against `main` at merge time and update every referring link; nothing in CI catches a duplicate number or a dead decision link. - **Preview environments resurrect on PR close because `setup-environment.yml` also triggers on `closed`.** Both workflows fire on the same close event; Remove Environment tears the env down correctly (compose down, nginx, repo), then Setup Environment sees the `preview` label (labels survive merge) and re-provisions the whole thing minutes later — verified on #14832: remove finished 11:20, setup rebuilt it by 11:30. This is why merged PRs kept live zombie environments on the preview box. Both workflows are thin SSH wrappers; the real setup/remove logic lives in `/root/environments` on the preview server (`secrets.PREVIEW_HOST`), not in this repo. Fixed by dropping `closed` from setup's trigger list. - **The preview-server remove tool can't clean containers once the repo dir is gone.** Its `stop()` skips `docker compose down` when `repos//docker-compose.yml` doesn't exist, so an env whose repo folder was deleted first leaves containers running forever — re-running `remove` is a no-op for them. Clean those manually via compose labels: `docker ps -aq --filter "label=com.docker.compose.project="` (same filter works for `docker volume ls`). When auditing envs against PR state: read the real branch from the clone's HEAD (`git -C repos/ symbolic-ref --short HEAD`) since subdomains flatten `/` to `-`; a clone sitting on `main` means the branch was deleted after merge; and an env with **no PR at all** is a manual `workflow_dispatch` preview — don't auto-delete those (bulk cleanup 2026-08-20 removed 27 closed-PR envs, reclaimed 32.5GB). +- **The same integration test can exist once per edition, so changing a shared service means grepping the assertion, not trusting the file you already edited.** `passwordless-authn.test.ts` lives under `test/integration/ce/authentication/` on main, and a branch may carry its own copy elsewhere — a behaviour change to `requestCode` or `signUp` has to update every copy. This bites hardest after rebuilding a branch onto a different base, which resurrects files the old base had moved: the edit list from the first attempt is then silently incomplete, and because api unit tests do not gate CI (below), the edition copy is the only thing that catches it. Grep the *assertion* (`DOMAIN_NOT_ALLOWED`, the fixture domain) across `test/` rather than the filename. +- **When a refusal and a success deliberately share a status code, a status-only assertion passes for the wrong reason.** The invited-member test kept asserting `204` and kept passing after the guard it covered stopped running at all. Any silent-failure design has to be pinned on side effects — rows created, mail sent, spies called — because the response is by construction indistinguishable. +- **In a vitest unit test, import the module under test statically — `vi.mock` is hoisted above imports.** The existing `worker-group.service.test.ts` reaches for `await import(...)` to load its subject after the mocks, which is unnecessary and, if you copy it to the *top level* of a file rather than inside a function, fails `tsc -p tsconfig.spec.json` with `TS1378: Top-level 'await' expressions are only allowed when the 'module' option is set to …`. Vitest itself runs it happily and lint says nothing, so the only thing that catches it is a typecheck nobody gates on. A plain `import { thing } from '…'` alongside the `vi.mock` calls works and typechecks. - **A unit test added under `packages/server/api/test/unit/` never runs in CI.** `ci.yml` runs exactly two test commands: `turbo run test` filtered to engine/shared/sandbox/ai-providers/pieces-framework/web, and `turbo run test-ce test-ee test-cloud check-migrations --filter=api`. The api package *has* a `test-unit` script (`vitest run test/unit`), but no workflow invokes it and the root `test-unit` filter list does not include api — so the 10+ files already sitting in `test/unit/**` are dead weight, and a new one passes review while protecting nothing. `packages/core/execution` is in the same position. Until the wiring changes, put api coverage that must actually gate merges in `test/integration/ce|ee|cloud`, and if you do add a unit test, say in the PR that you ran it locally and paste the result. - **`tools/scripts/` is outside the lint and test wiring.** ESLint ignores it, and `npm run test-unit` only covers engine/shared/web. A script there with real policy logic must run its own tests from its own workflow — `pr-size.yml` runs `bun test tools/scripts/pr-size-check.test.ts` as a step before the check itself. - **Reopening a bot-closed external PR is futile until a core member adds `keep-open` first.** `close-external-prs.yml` triggers on `pull_request_target` `[opened, reopened]`, so every reopen re-runs the same comment-then-close step; its `if` exempts OWNER/MEMBER/COLLABORATOR, bots, and the `keep-open` label, and nothing else. A docs PR from an outside contributor ([#15031](https://github.com/activepieces/activepieces/pull/15031)) was reopened 13 times over two days and closed 13 times within seconds of each, until a member labelled it `keep-open` and reopened it once. The same job also runs a nightly `actions/stale` pass that closes any PR idle 60 days. The lasting fix for a change worth keeping is to re-open it from a branch owned by someone with write access — author association, not the diff, is what the gate reads. diff --git a/brain/knowledge/engineering/server-module-anatomy.md b/brain/knowledge/engineering/server-module-anatomy.md index b2c2b1695a55..ae47f2f0354c 100644 --- a/brain/knowledge/engineering/server-module-anatomy.md +++ b/brain/knowledge/engineering/server-module-anatomy.md @@ -130,9 +130,11 @@ Verify with `npm run lint-dev` and `npm run test-api`. **`packages/server/api/test/unit/**` runs in no pipeline — do not trust it as a safety net.** The package defines a `test-unit` script, but CI only runs `turbo run test-ce test-ee test-cloud check-migrations --filter=api` (`ci.yml`), and the root `npm run test-unit` filters to `engine`/`shared`/`sandbox`/`core-utils`/`server-utils`/`pieces-framework`/`web`/`ee-embed-sdk` — `api` is not in that list. So those specs are only ever run by hand, and they rot: measured Aug 2026 on a clean `main`, **18 tests across 4 files already failed** (`workers/job-queue/job-broker`, `workers/machine/machine-service`, `core/canary/worker-group.service`, `knowledge-base/file-service-delete`). Two consequences: put a server test you actually want enforced under `test/integration/ce`, and when a local `test/unit` run goes red, check `main` before assuming your branch caused it. ## Gotchas +- **`z.record()` over an enum key is exhaustive in zod v4 — a sparse map needs `z.partialRecord()`.** `z.record(z.enum(SomeEnum), value)` demands *every* member of the enum and fails with `expected record, received undefined` for each missing key, which is the opposite of the `Partial>` shape this codebase uses everywhere (provider maps, capability tables, per-edition config). It bit the AI model catalog: the schema wanted all 16 `AIProviderName` members while the real payload carries 13, so validation added to protect production would instead have rejected it on the first fetch. The unit-test fixture passed either way, because fixtures are written to match the schema. **Validate a schema against the real generated artefact, not only against a fixture** — that is the only step that catches this class of bug. - **The API needs Node 22.15+ and dies at import time on Node 20, with an error that names neither Node nor a version.** `app/file/file-compressor.ts` calls `promisify(zlib.zstdDecompress)` at module load, and zstd only reached Node's `zlib` in 22.15. On Node 20 that argument is `undefined`, so the process exits with `TypeError [ERR_INVALID_ARG_TYPE]: The "original" argument must be of type function` before a single line of server code runs, and the stack points at `file-compressor.ts` rather than at your change. `nvm use 24` is the fix. Worth knowing because the repo does not pin this anywhere the shell will notice, so an inherited Node 20 shell reads as "my branch broke the server". - **A fresh `bun install --ignore-scripts` cannot boot the API, even on Postgres.** `--ignore-scripts` is the usual way past `isolated-vm` failing to compile on macOS, but it also skips every other native build, and the server crashes on a missing `node_sqlite3.node`: the sqlite driver is imported eagerly regardless of `AP_DB_TYPE`. Copying the prebuilt `.node` files across from a working checkout is enough (47 of them, under `node_modules/.bun/**`), provided both checkouts run the same Node major, since the binding is ABI-locked. +- **Add a field to a shared response schema and the running dev API will silently strip it until you rebuild `packages/core/shared`.** The API resolves `@activepieces/shared` through node_modules to `main: ./dist/src/index.js`, while the web app resolves the same specifier through the tsconfig path to `src/` — so a new key on, say, `projectAnalytics` type-checks in the browser code and is absent from the actual payload, because Fastify serialises the response against the *stale dist* zod schema and drops what that schema does not declare. `tsx watch` does not save you: the api `serve` script watches `packages/core/shared/src/**` and restarts, but the restarted process still imports `dist`. Run `npx turbo run build --filter=@activepieces/shared` after editing shared, then verify the field is really on the wire (`curl` the endpoint) rather than trusting the types. The failure looks like a frontend bug — the field reads `undefined` with nothing logged anywhere. - **`getEntities()` and `getMigrations()` are both manual.** Nothing is auto-discovered. A missing entity registration fails silently at runtime; a missing migration registration means the migration simply never runs. - **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. diff --git a/brain/knowledge/engineering/web-feature-anatomy.md b/brain/knowledge/engineering/web-feature-anatomy.md index 70d7bfdb7369..69006e7b6c60 100644 --- a/brain/knowledge/engineering/web-feature-anatomy.md +++ b/brain/knowledge/engineering/web-feature-anatomy.md @@ -73,6 +73,9 @@ Verify with `npx turbo run lint --filter=web`, or `npm run lint-dev` for the who - **`Alert`'s `warning` and `destructive` variants ship without a background tint, so a tinted banner has to add one at the call site.** `components/ui/alert.tsx` gives `primary` and `success` a `bg-*-100/10` wash but leaves `warning` and `destructive` transparent (`destructive` sets `bg-card`, which reads as a plain panel on a page background, and unlike `warning` it sets no border colour either). A banner that needs to look like a banner rather than a bordered paragraph passes `bg-warning-100/10` / `bg-destructive-100/10 border-destructive/50` itself — that is what the credits usage alert does. Don't "fix" it in the variant without looking: eight-plus existing warning alerts sit inside dialogs on card backgrounds and were designed against the untinted look. Note also that `--warning-100` and `--destructive-100` are *not* redefined in the `.dark` block of `styles.css` (unlike `--primary-100`), so in dark mode both tints are a very pale hue at 10% over near-black — subtle by accident, not by design. - **`npx turbo run serve --filter=web -- --mode=cloud` cannot do OAuth2 connections.** The provider redirects to `cloud.activepieces.com` after sign-in instead of your local frontend. Use API-key or basic-auth connections, or run a fully local backend. - **`--mode=cloud` also floods the terminal with `[vite] http proxy error: /ingest/... ETIMEDOUT 127.0.0.1:3000`.** The mode only redirects the API (`API_BASE_URL` → `https://cloud.activepieces.com` in `lib/api.ts`); PostHog still posts to the *relative* `api_host: '/ingest'` (a same-origin reverse proxy so ad blockers don't drop ingestion — `providers/telemetry-provider.tsx`, mirrored in prod by the `fastifyHttpProxy` in `server.ts`). Vite proxies `/ingest` to `127.0.0.1:3000`, which isn't running. Cloud flags also turn telemetry *on* (`TELEMETRY_ENABLED` + `EDITION=cloud`), unlike a local CE backend — so posthog-js keeps polling `/ingest/flags` and flushing `/ingest/e` every few seconds. Harmless, but note the same setup sends real dev clicks to production PostHog whenever `/ingest` does resolve; the clean fix is skipping `posthog.init` under `import.meta.env.DEV`. +- **A motion `layout` animation fired from inside a mutation's `.then()` fast-forwards and reads as a jump — defer the state write two frames.** Motion measures the FLIP offset at the commit that reorders the DOM, then tweens from the first animation frame. When the write happens synchronously after a mutation resolves, that frame arrives tens of ms late (the same commit is refetching a table, tearing down a dialog, re-rendering the page), motion sees a huge time delta and skips most of the tween: a rail row travelling 228px was measured collapsing to 103px in one 6ms frame, then limping through 13 frames. Wrapping the write in `requestAnimationFrame(() => requestAnimationFrame(write))` lets the mutation's re-render settle first, and the same interaction then gives up only 7.6% on the first frame and eases properly. Two traps when checking this: driving the write yourself from a console eval runs on a *quiet* main thread and always looks smooth, so it proves nothing — reproduce through the real UI action; and a route change in the same tick (creating a flow navigates straight to the builder) interrupts the projection outright, which no deferral fixes. +- **`projectCollection` runs on its own private `QueryClient`, fetches once, and never refetches — so any server-derived field on `ProjectWithLimits` is frozen at page load.** `features/projects/stores/project-collection.ts` builds the collection with `queryCollectionOptions({ queryKey: ['projects'], queryClient: collectionQueryClient })`, where `collectionQueryClient` is a `new QueryClient()` local to that module, *not* the app's. So `invalidateQueries(['projects'])` from anywhere else is a no-op, there is no `refetchOnWindowFocus`, and a field like `analytics.lastFlowUpdated` keeps its page-load value until something calls `projectCollectionUtils.refetchProjects()`. Two ways to keep such a field live, and the choice matters: `refetchProjects()` refetches every project (fine for a rare event like a piece-set change — its four existing callers — but wrong on a hot path such as the builder's per-edit autosave), or `projectCollection.utils.writeUpdate({ ...project, ... })` patches the row locally with no request, letting the next natural refetch restore server truth. Note `projectCollection.update()` is a *different* thing: it routes through `onUpdate` and POSTs, and its field allowlist silently drops anything not named there. **A local patch of a server-side *aggregate* also has to reproduce that aggregate's semantics, or it desyncs in two directions.** `analytics.lastFlowUpdated` is a `MAX(flow.updated)` over living flows, so: stamp the value from the mutation response, never `new Date()` (a skewed browser clock reorders against every server-supplied sibling); write only when the incoming value is *newer*, because concurrent mutations on one project resolve out of timestamp order — builder autosaves, and the bulk paths in `use-automations-mutations.ts` that fan out `flowIds.map(id => flowsApi.update(...))` — and an unconditional write lets a late older response move the row backwards; and when the aggregate can *decrease*, a local patch cannot express it at all, so refetch instead (deleting the newest flow lowers the MAX to a value only the server knows — cheap there because deletes are user-initiated, unlike autosave). And if the patch is *deferred* at all — it is here, by two frames, so the reorder animation does not fast-forward — a refetch that lands inside that window must invalidate it, or the pending write reapplies the pre-refetch value over the authoritative one and the newer-than guard happily waves it through; stamp each scheduled write with a generation the refetch bumps. +- **Never format `packages/web` with bare `prettier` — the web formatting contract lives in the eslint rule, not in `.prettierrc`.** Root `.prettierrc` sets only `singleQuote`, while `packages/web/.eslintrc.json` configures `prettier/prettier` with `trailingComma: "all"`, `printWidth: 80`, `tabWidth: 2`. The repo pins prettier **2.8.4**, whose default `trailingComma` is `es5` — so `npx prettier --write` on a web file silently **strips the trailing commas out of every multi-line function call it touches**, including lines you never edited, turning a 15-line change into a 130-line diff that reviewers have to read past. Format with `npx turbo run lint --filter=web --force -- --fix` instead; that is also what `npm run lint-dev` runs. If you already ran bare prettier, `git checkout` the file and redo the edit rather than trying to hand-restore the commas. - **`packages/web`'s lint script only globs `src/**`, so nothing under `packages/web/test/` is ever linted** — not by CI's `lint` job, not by `npm run lint-dev`. Running `npx eslint 'test/**/*.{ts,tsx}'` from `packages/web` today reports 21 errors nobody has seen, so a new web test needs a manual eslint pass or it ships with errors. Most common trap: `testing-library/render-result-naming-convention` fires on any local helper whose name merely *starts with* `render` even when testing-library is not involved — renaming `render` to `renderTabText` does not silence it, only a name that doesn't begin with `render` does. - **`AllowOnlyLoggedInUserOnlyGuard` calls its hooks after two early returns, and the linter only lets it.** `react-hooks/rules-of-hooks` does not flag member-expression calls, so `platformHooks.useCurrentPlatform()` / `flagsHooks.useFlags()` sail past it — but add a bare `useSomething()` there and the rule fires, correctly: `isLoggedIn()` can change between renders, so those calls really are conditional. Anything new that needs to run once a session is authenticated belongs in a null-rendering component placed inside the returned `` subtree, which mounts only after the guard passes. That is why automatic trial activation is `` and not a hook. - **The layering is lint-enforced, not just a convention.** `packages/web/.eslintrc.json` has an `import/no-restricted-paths` zone making the codebase unidirectional: `src/app` may import `src/features`, and both may import `src/lib`/`hooks`/`components`/`types`/`utils` — never the reverse (the one exception is `app/query-client.ts`). So a hook that a public route needs belongs in `src/lib`, but anything rendering a feature's components has to live in that feature; you cannot keep the pair in one `lib` file. It fails as an `import/no-restricted-paths` **error**, not a warning, so it blocks lint. diff --git a/brain/knowledge/platform-editions-ee/ee-platform-plans-billing.md b/brain/knowledge/platform-editions-ee/ee-platform-plans-billing.md index ef4b2b6727d9..d1978936c62d 100644 --- a/brain/knowledge/platform-editions-ee/ee-platform-plans-billing.md +++ b/brain/knowledge/platform-editions-ee/ee-platform-plans-billing.md @@ -28,6 +28,7 @@ Billing and entitlements are powered by [Autumn](https://useautumn.com). Each pl - Initial plan by edition: CE/EE → `OPEN_SOURCE_PLAN`, Cloud → `AUTUMN_FREE_PLAN`. CE and `TESTING` environments skip enrollment/sync entirely. - **Running billing locally means pointing a local backend at a *console*, not standing up Autumn.** Three gates must all pass or the provider is the empty no-op: `AP_EDITION` must be `cloud` or `ee` (`app.ts` sets `autumnBillingProvider` only there; Community returns `{ total: 0, byProject: [] }`), `AP_ENVIRONMENT` must **not** be `testing` (`triggerLazyBillingProviderSync`/`enrollBillingProviderOnCreate` early-return on it), and Redis must be up (billing reads/writes go through Redis caches). Enrollment is lazy — the first read of a billing/usage route fires `ensureEnrolled` → `enrollFree({ ownerEmail })` against `AUTUMN_CONSOLE_URL`. That prop **defaults to the production console** (`https://console.activepieces.com`), so an unconfigured local box enrolls a *real* customer keyed by the owner email; point it at the testing console instead. A freshly enrolled free customer has no events, so all usage aggregations read `0` until real `flow_run`/`ai`/`chat` credit events are metered for it. To merely eyeball the usage page with real data, `serve --filter=web -- --mode=cloud` against the cloud backend is far less setup than local billing. - **Hand-editing `platform_plan` flags to unlock a feature locally does not stick, and the revert looks like the feature breaking itself.** The lazy entitlement projection above rewrites the plan columns from Autumn on any plan read, so a flag you set by hand survives only until the next sync. Because the throttle is 15 minutes and the web app leaves TanStack Query's `refetchOnWindowFocus` at its default `true`, the trigger in practice is *returning to the tab after a break*: alt-tab quickly and the flag survives, come back later and it is gone — which reads as an intermittent bug in whatever feature the flag gated, not as billing. `triggerLazyBillingProviderSync` is fire-and-forget, so nothing in the request that caused it says so. The clean fix for local work is to null **both** `autumnCustomerId` and `autumnApiKey` on the row: `loadAutumnCreds` returns null only when both are nil, `refreshEntitlements` then returns before its `update()`, and hand-set flags stay put. Null exactly one and you hit the worst case — an `error`-level `Autumn credentials incomplete for an enrolled platform` and every billing call silently no-oping. +- **On `AP_EDITION=ce` the `platform_plan` row is ignored entirely, so there is nothing to hand-edit.** `platform.service.ts`'s `getPlan` returns the `OPEN_SOURCE_PLAN` literal for Community before it ever reads the DB — the row still exists (`createInitialBilling` writes it) and still shows the value you set, which is what makes SQL look like the fix. Unlocking a flag locally on CE means editing that literal in `packages/core/shared/src/lib/ee/billing/index.ts`; for a limit, `null` is unlimited on both sides (`isNil` short-circuits the frontend guard and `assertMaximumNumberOfProjectsReachedByEdition`), while `0` means *not available on this plan* and additionally hides the feature's UI. Two traps: the edit needs `packages/core/shared/dist/` patched too or rebuilt, because **the API resolves `@activepieces/shared` through node_modules to `main: ./dist/src/index.js` while the web app resolves it through the tsconfig path to `src/`** — edit only `src` and the browser flips while the API keeps enforcing the old value, which reads as a frontend/backend disagreement; and `src` is tracked, so revert it before committing. - The console base URL defaults to the production console and is overridable by an internal system prop (trailing slashes stripped) so our testing instance can point at the testing console. Deliberately absent from the self-hosting env-var reference: a self-hoster has no reason to change it, and the default must always be the one that works with zero setup. The Autumn SDK's own base URL is **not** configurable — nothing passes `serverURL` — so the console override cannot redirect entitlement reads. - Credit metering for managed AI happens post-run in centralized worker execution (decision 000016), so in-flight spend is invisible to the gate. - **A first-time chatter's plan grant must finish before the credit gate runs — `await` it, never fire-and-forget.** `computeCreditState` blocks only when `enforced && exhausted`, and free carries the `BILLING_ENFORCED` customer flag, so a free platform whose allowance is spent *is* blocked. `chatPlanGrant.grant` is what attaches the plan that gives that user credits, and `activateLicense` ends with `refreshEntitlements`, so awaiting it lets the gate 30 lines later read the new balance; backgrounding it bounces the user's very first message with `QUOTA_EXCEEDED` and only works on retry. Wrap the await in `tryCatch` — the grant's claim/plan-lookup calls sit outside its internal `tryCatch` and would otherwise fail the chat request. This ordering was documented in a comment that got deleted during the license-key → Autumn swap; don't re-optimize it away. diff --git a/bun.lock b/bun.lock index 96399c7cd14d..a89cc6830146 100644 --- a/bun.lock +++ b/bun.lock @@ -96,7 +96,7 @@ }, "packages/core/ai-providers": { "name": "@activepieces/ai-providers", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -161,7 +161,7 @@ }, "packages/core/shared": { "name": "@activepieces/shared", - "version": "0.147.0", + "version": "0.149.0", "dependencies": { "@activepieces/core-execution": "workspace:*", "@activepieces/core-formula": "workspace:*", @@ -186,7 +186,7 @@ }, "packages/core/utils": { "name": "@activepieces/core-utils", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "deepmerge-ts": "7.1.0", "ipaddr.js": "2.3.0", @@ -10839,7 +10839,7 @@ }, "packages/server/utils": { "name": "@activepieces/server-utils", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@activepieces/ai-providers": "workspace:*", "@activepieces/core-formula": "workspace:*", @@ -10865,6 +10865,7 @@ "request-filtering-agent": "3.2.0", "systeminformation": "5.31.7", "tslib": "2.6.2", + "zod": "4.3.6", }, "devDependencies": { "@types/node": "24.11.0", diff --git a/docs/install/reference/environment-variables.mdx b/docs/install/reference/environment-variables.mdx index 9965bfa6da3c..576abd8ac3a2 100644 --- a/docs/install/reference/environment-variables.mdx +++ b/docs/install/reference/environment-variables.mdx @@ -154,6 +154,7 @@ run timeouts, and the network egress posture for user code. Read | `AP_PROJECT_RATE_LIMITER_ENABLED` | Enforce per-project rate limits to prevent excessive usage. | `false` | | `AP_NETWORK_MODE` | Egress posture for user code. `STRICT` installs the engine's in-process SSRF guard, blocking outbound connections to private, loopback, link-local, and cloud-metadata IPs across every Node egress path (`axios`, `fetch`, `undici`, raw `http`/`net`). This is best-effort, in-process protection — enforce the real boundary in infrastructure (see [Network Security](/install/architecture/network-security)). `UNRESTRICTED` disables the guard. | `UNRESTRICTED` | | `AP_SSRF_ALLOW_LIST` | Comma-separated IPs or CIDR ranges that bypass `AP_NETWORK_MODE=STRICT`, e.g. `10.0.0.5,10.10.0.0/24`. Only applies when `AP_NETWORK_MODE=STRICT`. | `None` | +| `AP_MODEL_CATALOG_URL` | Source for AI model metadata (context window, pricing, capabilities), shown beside each model when picking one. Fetched once and cached for 24 hours. An install with no egress to the default CDN simply shows models without that metadata; point this at a self-hosted mirror of the same JSON to restore it. | `https://cdn.activepieces.com/ai/model-catalog.json` | --- @@ -201,16 +202,29 @@ S3-compatible bucket. ### Sign-up protection -Controls on who may create an account. Both default to the safe behaviour with -no configuration: disposable addresses are refused, and no challenge is served -until you supply Turnstile keys. +Controls on who may create an account. Both are off with no configuration, so a +self-hosted instance needs neither a Cloudflare nor a ZeroBounce account. | Variable | Description | Default | |---|---|---| -| `AP_ALLOW_DISPOSABLE_EMAILS` | Accept addresses from throwaway email providers. | `false` | +| `AP_ZEROBOUNCE_API_KEY` | ZeroBounce API key. Set it to check each new address against ZeroBounce before an account is created. | `None` | | `AP_TURNSTILE_SITE_KEY` | Cloudflare Turnstile site key. Public; served to the sign-in page. | `None` | | `AP_TURNSTILE_SECRET_KEY` | Cloudflare Turnstile secret key, used to verify a solved challenge. | `None` | +With `AP_ZEROBOUNCE_API_KEY` set, a sign-up is refused when ZeroBounce reports +the address as disposable, toxic, a spam trap, an abuse address or globally +suppressed. Role addresses such as `info@` and `sales@` are accepted, and so is +an address whose mailbox ZeroBounce cannot confirm. A member who already holds an +accepted invitation is never refused. One credit is spent per address, and only +for an address that has no account yet. If ZeroBounce cannot be reached, or +answers that the key is invalid or out of credits, the sign-up is allowed through +and the reason is logged — a validation outage never blocks sign-up. + +Both refusals are silent: the emailed-code request answers exactly as a served +one does and the sign-in page moves to the code step, while password sign-up +returns the same response an unverified sign-up returns. Neither reveals that the +address was refused; the server log is the only record. + The challenge is only served when **both** Turnstile variables are set. With either missing, the sign-in page renders no widget and the server verifies nothing, so a self-hosted instance needs no Cloudflare account. diff --git a/package.json b/package.json index 51e358ffc1af..674dea7f9136 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "i18n:extract": "i18next --config packages/web/i18next-parser.config.js", "bump-translated-pieces": "npx ts-node --project tools/tsconfig.tools.json tools/scripts/pieces/bump-translated-pieces.ts", "bump-all-pieces-patch-version": "npx ts-node --project tools/tsconfig.tools.json tools/scripts/pieces/bump-all-pieces-patch-version.ts", - "security:sla": "npx ts-node --project tools/tsconfig.tools.json tools/scripts/security/sla-report.ts" + "security:sla": "npx ts-node --project tools/tsconfig.tools.json tools/scripts/security/sla-report.ts", + "sync-model-catalog": "npx tsx tools/scripts/sync-model-catalog.ts" }, "private": true, "dependencies": { diff --git a/packages/core/ai-providers/package.json b/packages/core/ai-providers/package.json index 0d62b7e42aff..f1da5a1a32b9 100644 --- a/packages/core/ai-providers/package.json +++ b/packages/core/ai-providers/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/ai-providers", - "version": "0.2.0", + "version": "0.3.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", 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 edbfe7af46a0..3c9b475341ca 100644 --- a/packages/core/ai-providers/src/lib/create-language-model.ts +++ b/packages/core/ai-providers/src/lib/create-language-model.ts @@ -1,4 +1,4 @@ -import { AIProviderName, spreadIfDefined } from '@activepieces/core-utils' +import { AIProviderName, observedProviderFetch, ProviderOutcomeReporter, spreadIfDefined } from '@activepieces/core-utils' import { AzureProviderConfig, BaseAIProviderAuthConfig, BedrockProviderAuthConfig, BedrockProviderConfig, OPENAI_COMPATIBLE_VENDOR_BASE_URLS, OpenAICompatibleProviderConfig } from '@activepieces/core-piece-types' import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock' import { createAnthropic } from '@ai-sdk/anthropic' @@ -12,29 +12,30 @@ import { LanguageModel } from 'ai' const MISTRAL_BASE_URL = 'https://api.mistral.ai/v1' export function createLanguageModel({ provider, auth, config, modelId, options = {} }: CreateLanguageModelParams): LanguageModel { + const observed = spreadIfDefined('fetch', observedProviderFetch(options.onOutcome)) switch (provider) { case AIProviderName.OPENAI: { const { apiKey } = auth as BaseAIProviderAuthConfig - const client = createOpenAI({ apiKey }) + const client = createOpenAI({ apiKey, ...observed }) return options.openaiResponsesModel ? client.responses(modelId) : client.chat(modelId) } case AIProviderName.ANTHROPIC: { const { apiKey } = auth as BaseAIProviderAuthConfig - return createAnthropic({ apiKey })(modelId) + return createAnthropic({ apiKey, ...observed })(modelId) } case AIProviderName.GOOGLE: { const { apiKey } = auth as BaseAIProviderAuthConfig - return createGoogleGenerativeAI({ apiKey })(modelId) + return createGoogleGenerativeAI({ apiKey, ...observed })(modelId) } case AIProviderName.AZURE: { const { apiKey } = auth as BaseAIProviderAuthConfig const { resourceName, apiVersion } = config as AzureProviderConfig - return createAzure({ resourceName, apiKey, apiVersion }).chat(modelId) + return createAzure({ resourceName, apiKey, apiVersion, ...observed }).chat(modelId) } case AIProviderName.BEDROCK: { const { accessKeyId, secretAccessKey } = auth as BedrockProviderAuthConfig const { region } = config as BedrockProviderConfig - return createAmazonBedrock({ region, accessKeyId, secretAccessKey })(modelId) + return createAmazonBedrock({ region, accessKeyId, secretAccessKey, ...observed })(modelId) } case AIProviderName.CUSTOM: { const { apiKey } = auth as BaseAIProviderAuthConfig @@ -43,6 +44,7 @@ export function createLanguageModel({ provider, auth, config, modelId, options = name: 'openai-compatible', baseURL: baseUrl, headers: buildOpenAICompatibleHeaders({ apiKeyHeader, apiKey, defaultHeaders, extraHeaders: options.extraHeaders }), + ...observed, }).chatModel(modelId) } case AIProviderName.MISTRAL: { @@ -50,7 +52,7 @@ export function createLanguageModel({ provider, auth, config, modelId, options = if (options.mistralViaOpenRouter) { return createOpenRouterChatModel({ apiKey, modelId, options }) } - return createOpenAICompatible({ name: 'mistral', baseURL: MISTRAL_BASE_URL, apiKey }).chatModel(modelId) + return createOpenAICompatible({ name: 'mistral', baseURL: MISTRAL_BASE_URL, apiKey, ...observed }).chatModel(modelId) } case AIProviderName.XAI: case AIProviderName.DEEPSEEK: @@ -63,6 +65,7 @@ export function createLanguageModel({ provider, auth, config, modelId, options = name: provider, baseURL: OPENAI_COMPATIBLE_VENDOR_BASE_URLS[provider], apiKey, + ...observed, }).chatModel(modelId) } case AIProviderName.OPENROUTER: @@ -87,6 +90,7 @@ function createOpenRouterChatModel({ apiKey, modelId, options }: { return createOpenRouter({ apiKey, ...spreadIfDefined('headers', options.extraHeaders), + ...spreadIfDefined('fetch', observedProviderFetch(options.onOutcome)), }).chat(modelId, options.openRouterSettings) as LanguageModel } @@ -104,6 +108,7 @@ export function buildOpenAICompatibleHeaders({ apiKeyHeader, apiKey, defaultHead } export type LanguageModelOptions = { + onOutcome?: ProviderOutcomeReporter openaiResponsesModel?: boolean openRouterSettings?: OpenRouterChatSettings mistralViaOpenRouter?: boolean diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json index 2450cf7009d9..0176dd7946c0 100644 --- a/packages/core/shared/package.json +++ b/packages/core/shared/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/shared", - "version": "0.147.0", + "version": "0.149.0", "type": "commonjs", "sideEffects": false, "main": "./dist/src/index.js", 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 935b20880ab9..a9756a504f4c 100644 --- a/packages/core/shared/src/lib/management/ai-providers/index.ts +++ b/packages/core/shared/src/lib/management/ai-providers/index.ts @@ -1,4 +1,4 @@ -import { AIProviderName, BaseModelSchema } from '@activepieces/core-utils' +import { AiProviderKeyStatus, AIProviderName, BaseModelSchema } from '@activepieces/core-utils' import { z } from 'zod' export enum AIProviderModelType { @@ -258,6 +258,9 @@ export const AIProviderWithoutSensitiveData = z.object({ modelIds: z.array(z.string()), projectScope: AiProviderProjectScope, projectIds: z.array(z.string()), + status: AiProviderKeyStatus, + statusReason: z.string().nullable(), + statusUpdated: z.string().nullable(), }) export type AIProviderWithoutSensitiveData = z.infer @@ -275,10 +278,23 @@ export const ProjectAIProvider = z.object({ }) export type ProjectAIProvider = z.infer +export const AIProviderModelMetadata = z.object({ + contextTokens: z.number().optional(), + maxOutputTokens: z.number().optional(), + releaseDate: z.string().optional(), + inputCostPerMillionTokens: z.number().optional(), + outputCostPerMillionTokens: z.number().optional(), + supportsToolCalling: z.boolean().optional(), + supportsReasoning: z.boolean().optional(), + supportsVision: z.boolean().optional(), +}) +export type AIProviderModelMetadata = z.infer + export const AIProviderModel = z.object({ id: z.string(), name: z.string(), type: z.nativeEnum(AIProviderModelType), + metadata: AIProviderModelMetadata.optional(), }) export type AIProviderModel = z.infer diff --git a/packages/core/shared/src/lib/management/project/project.ts b/packages/core/shared/src/lib/management/project/project.ts index e530596b807e..0abe27edd23e 100755 --- a/packages/core/shared/src/lib/management/project/project.ts +++ b/packages/core/shared/src/lib/management/project/project.ts @@ -71,6 +71,7 @@ const projectAnalytics = z.object({ activeUsers: z.number(), totalFlows: z.number(), activeFlows: z.number(), + lastFlowUpdated: Nullable(DateOrString), }) export type Project = z.infer diff --git a/packages/core/utils/package.json b/packages/core/utils/package.json index 99a0cdf80669..df9638d0b349 100644 --- a/packages/core/utils/package.json +++ b/packages/core/utils/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/core-utils", - "version": "0.5.0", + "version": "0.6.0", "type": "commonjs", "main": "./dist/src/index.js", "scripts": { diff --git a/packages/core/utils/src/index.ts b/packages/core/utils/src/index.ts index ad8d97647d4f..4e53485c3ad7 100644 --- a/packages/core/utils/src/index.ts +++ b/packages/core/utils/src/index.ts @@ -17,3 +17,4 @@ export * from './lib/project-role' export * from './lib/activepieces-error' export * from './lib/form-errors' export * from './lib/byte-lru-cache' +export * from './lib/ai-provider-health' diff --git a/packages/core/utils/src/lib/ai-provider-health.ts b/packages/core/utils/src/lib/ai-provider-health.ts new file mode 100644 index 000000000000..36493d862de4 --- /dev/null +++ b/packages/core/utils/src/lib/ai-provider-health.ts @@ -0,0 +1,152 @@ +import { z } from 'zod' +import { formatPieceError } from './friendly-piece-error' + +export function toProviderOutcomeSignal(error: unknown): ProviderOutcomeSignal { + const formatted = formatPieceError(error) + return { + ...(formatted.status === undefined ? {} : { statusCode: formatted.status }), + ...(formatted.responseBody === undefined ? {} : { body: stringifyBody(formatted.responseBody) }), + message: formatted.apiMessage ?? formatted.message, + } +} + +function stringifyBody(body: unknown): string { + return typeof body === 'string' ? body : JSON.stringify(body) +} + +export function observedProviderFetch(onOutcome: ProviderOutcomeReporter | undefined): typeof globalThis.fetch | undefined { + if (isNil(onOutcome)) { + return undefined + } + return async (input, init) => { + let response: Response + try { + response = await fetch(input, init) + } + catch (error) { + report({ observed: Promise.resolve(toProviderOutcomeSignal(error)), onOutcome }) + throw error + } + report({ observed: observeResponse(response), onOutcome }) + return response + } +} + +function report({ observed, onOutcome }: { observed: Promise, onOutcome: ProviderOutcomeReporter }): void { + void observed.then(onOutcome).catch(() => undefined) +} + +async function observeResponse(response: Response): Promise { + if (response.ok) { + return { statusCode: response.status } + } + const body = await readEnoughToClassify(response) + return { statusCode: response.status, ...(isNil(body) ? {} : { body }) } +} + +async function readEnoughToClassify(response: Response): Promise { + try { + const stream = response.clone().body + if (isNil(stream)) { + return undefined + } + const reader = stream.getReader() + const decoder = new TextDecoder() + let text = '' + try { + while (text.length < MAX_OBSERVED_BODY_LENGTH) { + const { done, value } = await reader.read() + if (done) { + break + } + text += decoder.decode(value, { stream: true }) + } + } + finally { + void reader.cancel().catch(() => undefined) + } + return text.slice(0, MAX_OBSERVED_BODY_LENGTH) + } + catch { + return undefined + } +} + +export function isProviderCreditError(text: string): boolean { + return CREDIT_ERROR_PATTERNS.some((pattern) => pattern.test(text)) +} + + +export function isTransientProviderError(text: string): boolean { + return TRANSIENT_ERROR_PATTERN.test(text) +} + +export function classifyProviderOutcome({ statusCode, body, message }: ProviderOutcomeSignal): AiProviderKeyStatus | NoStatusChange { + if (!isNil(statusCode)) { + return classifyByStatus({ statusCode, haystack: `${body ?? ''} ${message ?? ''}` }) + } + const text = message ?? '' + if (isProviderCreditError(text)) { + return 'out_of_credits' + } + if (isTransientProviderError(text)) { + return 'no_change' + } + return text.length === 0 ? 'no_change' : 'unreachable' +} + +function classifyByStatus({ statusCode, haystack }: { statusCode: number, haystack: string }): AiProviderKeyStatus | NoStatusChange { + if (statusCode >= 200 && statusCode < 300) { + return 'active' + } + if (statusCode === 401 || statusCode === 403) { + return 'rejected' + } + if (statusCode === 402) { + return 'out_of_credits' + } + if (statusCode === 429 && RATE_LIMIT_BODY_PATTERN.test(haystack)) { + return 'no_change' + } + if (BILLING_BODY_PATTERN.test(haystack)) { + return 'out_of_credits' + } + if (statusCode === 429) { + return 'no_change' + } + if (statusCode === 404) { + return MODEL_NOT_FOUND_PATTERN.test(haystack) ? 'no_change' : 'unreachable' + } + if (statusCode === 408 || statusCode >= 500) { + return 'unreachable' + } + return 'no_change' +} + +function isNil(value: T | null | undefined): value is null | undefined { + return value === null || value === undefined +} + +const MAX_OBSERVED_BODY_LENGTH = 2000 +const CREDIT_ERROR_PATTERNS = [/credits/i, /\b402\b/, /payment.required/i] + +const TRANSIENT_ERROR_PATTERN = /\b(429|5\d\d)\b|rate.?limit|timeout|timed out|temporarily|try again|econnreset|etimedout|socket hang up|service unavailable/i + +const BILLING_BODY_PATTERN = /insufficient_quota|credit[_ ]balance|billing_hard_limit_reached|billing|\bcredits?\b|out of funds|payment required/i + +const RATE_LIMIT_BODY_PATTERN = /per minute|per day|per_minute|per_day|requests? per|tokens? per|rate.?limit|resource_exhausted|\brpm\b|\btpm\b/i + +const MODEL_NOT_FOUND_PATTERN = /model|deployment|engine/i + +export const AiProviderKeyStatus = z.enum(['active', 'out_of_credits', 'rejected', 'unreachable']) +export type AiProviderKeyStatus = z.infer + +export type NoStatusChange = 'no_change' + +export type ProviderOutcomeSignal = { + statusCode?: number + body?: string + message?: string +} + +export type ProviderOutcomeReporter = (signal: ProviderOutcomeSignal) => void | Promise diff --git a/packages/core/utils/test/ai-provider-health.test.ts b/packages/core/utils/test/ai-provider-health.test.ts new file mode 100644 index 000000000000..71acf9f22f68 --- /dev/null +++ b/packages/core/utils/test/ai-provider-health.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, vi } from 'vitest' +import { classifyProviderOutcome, observedProviderFetch, ProviderOutcomeSignal } from '../src/lib/ai-provider-health' + +describe('classifyProviderOutcome', () => { + it('reads a 2xx as a working key', () => { + expect(classifyProviderOutcome({ statusCode: 200 })).toBe('active') + expect(classifyProviderOutcome({ statusCode: 204 })).toBe('active') + }) + + it('separates a rejected secret from a provider outage', () => { + expect(classifyProviderOutcome({ statusCode: 401 })).toBe('rejected') + expect(classifyProviderOutcome({ statusCode: 403 })).toBe('rejected') + expect(classifyProviderOutcome({ statusCode: 500 })).toBe('unreachable') + expect(classifyProviderOutcome({ statusCode: 503 })).toBe('unreachable') + expect(classifyProviderOutcome({ statusCode: 408 })).toBe('unreachable') + }) + + it('reads an explicit 402 as the provider billing', () => { + expect(classifyProviderOutcome({ statusCode: 402, body: 'Insufficient credits' })).toBe('out_of_credits') + }) + + // The case the message-regex on main gets wrong: OpenAI bills through 429 and the message + // carries neither "credits" nor "402", so today it is treated as transient and retried. + it('reads OpenAI insufficient_quota as out of credits, not as a rate limit', () => { + const outcome = classifyProviderOutcome({ + statusCode: 429, + body: '{"error":{"message":"You exceeded your current quota, please check your plan and billing details.","type":"insufficient_quota","code":"insufficient_quota"}}', + }) + expect(outcome).toBe('out_of_credits') + }) + + it('reads Anthropic credit_balance_too_low as out of credits', () => { + const outcome = classifyProviderOutcome({ + statusCode: 400, + body: '{"error":{"type":"invalid_request_error","message":"Your credit balance is too low to access the Anthropic API."}}', + }) + expect(outcome).toBe('out_of_credits') + }) + + it('leaves the status alone for a plain rate limit', () => { + const outcome = classifyProviderOutcome({ + statusCode: 429, + body: '{"error":{"message":"Rate limit reached for gpt-4o","type":"requests","code":"rate_limit_exceeded"}}', + }) + expect(outcome).toBe('no_change') + }) + + it('reads a Gemini per-minute quota as load, not as billing', () => { + const outcome = classifyProviderOutcome({ + statusCode: 429, + body: '{"error":{"code":429,"message":"Quota exceeded for quota metric \'Generate Content API requests per minute\' and limit \'GenerateRequestsPerMinutePerProjectPerModel\'","status":"RESOURCE_EXHAUSTED"}}', + }) + expect(outcome).toBe('no_change') + }) + + it('leaves the status alone when only one model is missing', () => { + const outcome = classifyProviderOutcome({ + statusCode: 404, + body: '{"error":{"message":"The model `gpt-5-turbo` does not exist","code":"model_not_found"}}', + }) + expect(outcome).toBe('no_change') + }) + + it('leaves the status alone for a bad request, which is the caller\'s fault', () => { + expect(classifyProviderOutcome({ statusCode: 400, body: 'invalid temperature' })).toBe('no_change') + expect(classifyProviderOutcome({ statusCode: 422, body: 'unprocessable' })).toBe('no_change') + }) + + describe('without a status code, falling back to the message', () => { + it('still catches credit exhaustion', () => { + expect(classifyProviderOutcome({ message: 'You are out of credits' })).toBe('out_of_credits') + expect(classifyProviderOutcome({ message: 'HTTP 402 payment required' })).toBe('out_of_credits') + }) + + it('holds the status for a transient failure', () => { + expect(classifyProviderOutcome({ message: '429 rate limit exceeded' })).toBe('no_change') + expect(classifyProviderOutcome({ message: 'socket hang up' })).toBe('no_change') + expect(classifyProviderOutcome({ message: 'ETIMEDOUT' })).toBe('no_change') + }) + + it('treats an unrecognised failure as unreachable', () => { + expect(classifyProviderOutcome({ message: 'getaddrinfo ENOTFOUND api.openai.com' })).toBe('unreachable') + }) + + it('holds the status when there is nothing to go on', () => { + expect(classifyProviderOutcome({})).toBe('no_change') + expect(classifyProviderOutcome({ message: '' })).toBe('no_change') + }) + }) +}) + +describe('observedProviderFetch', () => { + const withFetch = async (impl: () => Promise): Promise => { + const signals: ProviderOutcomeSignal[] = [] + const original = globalThis.fetch + globalThis.fetch = vi.fn(impl) as unknown as typeof globalThis.fetch + try { + const observed = observedProviderFetch((signal) => signals.push(signal)) + expect(observed).toBeDefined() + await observed?.('https://api.openai.com/v1/models') + await new Promise((resolve) => setImmediate(resolve)) + } + finally { + globalThis.fetch = original + } + return signals + } + + it('returns the provider response even when the body cannot be read', async () => { + const response = new Response('nope', { status: 401 }) + vi.spyOn(response, 'clone').mockImplementation(() => { + throw new Error('body already disturbed') + }) + const signals = await withFetch(async () => response) + expect(signals).toMatchObject([{ statusCode: 401 }]) + }) + + it('never reads the body of a streaming success', async () => { + const response = new Response('hello', { status: 200 }) + const clone = vi.spyOn(response, 'clone') + const signals = await withFetch(async () => response) + expect(clone).not.toHaveBeenCalled() + expect(signals).toMatchObject([{ statusCode: 200 }]) + expect(await response.text()).toBe('hello') + }) + + it('caps how much of a failure body it keeps', async () => { + const signals = await withFetch(async () => new Response('x'.repeat(5000), { status: 429 })) + expect(signals[0].statusCode).toBe(429) + expect(signals[0].body).toHaveLength(2000) + }) + + it('reports a transport failure and rethrows it untouched', async () => { + const boom = new Error('socket hang up') + const signals: ProviderOutcomeSignal[] = [] + const original = globalThis.fetch + globalThis.fetch = vi.fn(async () => { + throw boom + }) as unknown as typeof globalThis.fetch + try { + const observed = observedProviderFetch((signal) => signals.push(signal)) + await expect(observed?.('https://api.openai.com/v1/models')).rejects.toBe(boom) + await new Promise((resolve) => setImmediate(resolve)) + } + finally { + globalThis.fetch = original + } + expect(signals[0].message).toContain('socket hang up') + }) + + it('stops pulling a huge failure body once it has enough to classify', async () => { + const chunk = 'x'.repeat(500) + let pulled = 0 + const stream = new ReadableStream({ + pull(controller) { + pulled += 1 + if (pulled > 200) { + controller.close() + return + } + controller.enqueue(new TextEncoder().encode(chunk)) + }, + }) + + const signals: ProviderOutcomeSignal[] = [] + const original = globalThis.fetch + globalThis.fetch = vi.fn(async () => new Response(stream, { status: 500 })) as unknown as typeof globalThis.fetch + try { + const observed = observedProviderFetch((signal) => signals.push(signal)) + await observed?.('https://api.openai.com/v1/models') + await new Promise((resolve) => setTimeout(resolve, 50)) + } + finally { + globalThis.fetch = original + } + + expect(signals[0].body).toHaveLength(2000) + expect(pulled).toBeLessThan(10) + }) + + it('keeps a slow reporter off the call path', async () => { + const signals: ProviderOutcomeSignal[] = [] + const original = globalThis.fetch + globalThis.fetch = vi.fn(async () => new Response('{}', { status: 200 })) as unknown as typeof globalThis.fetch + try { + const observed = observedProviderFetch(async (signal) => { + await new Promise((resolve) => setTimeout(resolve, 50)) + signals.push(signal) + }) + await observed?.('https://api.openai.com/v1/models') + expect(signals).toEqual([]) + await new Promise((resolve) => setTimeout(resolve, 90)) + } + finally { + globalThis.fetch = original + } + expect(signals).toMatchObject([{ statusCode: 200 }]) + }) +}) 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 20ff25da4fe1..0fb46d7b2932 100644 --- a/packages/server/api/src/app/ai/ai-provider-controller.ts +++ b/packages/server/api/src/app/ai/ai-provider-controller.ts @@ -1,4 +1,4 @@ -import { AIProviderName } from '@activepieces/core-utils' +import { AiProviderKeyStatus, AIProviderName } from '@activepieces/core-utils' import { AIProviderModel, CreateAIProviderRequest, PrincipalType, spreadIfDefined, UpdateAIProviderRequest } from '@activepieces/shared' import { FastifyPluginAsyncZod } from 'fastify-type-provider-zod' import { StatusCodes } from 'http-status-codes' @@ -49,6 +49,13 @@ export const aiProviderController: FastifyPluginAsyncZod = async (app) => { const platformId = request.principal.platform.id return aiProviderService(app.log).create(platformId, request.body) }) + app.post('/:id/recheck', RecheckAIProvider, async (request) => { + const status = await aiProviderService(app.log).recheck({ + platformId: request.principal.platform.id, + providerId: request.params.id, + }) + return { status } + }) app.post('/:id', UpdateAIProvider, async (request) => { const platformId = request.principal.platform.id return aiProviderService(app.log).update(platformId, request.params.id, request.body) @@ -132,6 +139,22 @@ const CreateAIProvider = { }, } +const RecheckAIProvider = { + config: { + security: securityAccess.platformAdminOnly([PrincipalType.USER]), + }, + schema: { + params: z.object({ + id: z.string(), + }), + response: { + [StatusCodes.OK]: z.object({ + status: AiProviderKeyStatus, + }), + }, + }, +} + const UpdateAIProvider = { config: { security: securityAccess.platformAdminOnly([PrincipalType.USER]), 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 e054a681d30b..bcc64a43ea37 100644 --- a/packages/server/api/src/app/ai/ai-provider-entity.ts +++ b/packages/server/api/src/app/ai/ai-provider-entity.ts @@ -1,4 +1,4 @@ -import { AIProviderName, BaseModelSchema } from '@activepieces/core-utils' +import { AiProviderKeyStatus, AIProviderName, BaseModelSchema } from '@activepieces/core-utils' import { AIProviderConfig, AiProviderModelScope, AiProviderProjectScope, Platform } from '@activepieces/shared' import { EntitySchema } from 'typeorm' import { z } from 'zod' @@ -17,6 +17,10 @@ const AIProviderEncrypted = z.object({ modelIds: z.array(z.string()).default([]), projectScope: AiProviderProjectScope.default('all'), projectIds: z.array(z.string()).default([]), + status: AiProviderKeyStatus, + statusReason: z.string().nullable(), + statusUpdated: z.string().nullable(), + statusVersion: z.number(), }) type AIProviderEncrypted = z.infer @@ -54,6 +58,24 @@ export const AIProviderEntity = new EntitySchema({ nullable: false, default: false, }, + status: { + type: String, + nullable: false, + default: 'active', + }, + statusReason: { + type: String, + nullable: true, + }, + statusUpdated: { + type: 'timestamp with time zone', + nullable: true, + }, + statusVersion: { + type: Number, + nullable: false, + default: 0, + }, modelScope: { type: String, nullable: false, diff --git a/packages/server/api/src/app/ai/ai-provider-health.ts b/packages/server/api/src/app/ai/ai-provider-health.ts new file mode 100644 index 000000000000..f4fb10a44487 --- /dev/null +++ b/packages/server/api/src/app/ai/ai-provider-health.ts @@ -0,0 +1,57 @@ +import { AiProviderKeyStatus, classifyProviderOutcome, isNil, PlatformId, ProviderOutcomeSignal } from '@activepieces/core-utils' +import { FastifyBaseLogger } from 'fastify' +import { repoFactory } from '../core/db/repo-factory' +import { AIProviderEntity, AIProviderSchema } from './ai-provider-entity' + +const aiProviderRepo = repoFactory(AIProviderEntity) + +const MAX_REASON_LENGTH = 300 + +const REFRESH_UNCHANGED_AFTER_MINUTES = 15 + +export const aiProviderHealth = (log: FastifyBaseLogger) => ({ + async record({ platformId, providerId, signal, throttled = true, expectVersion }: RecordParams): Promise { + const status = classifyProviderOutcome(signal) + if (status === 'no_change') { + return null + } + const reason = status === 'active' ? null : buildReason(signal) + const refreshAfterMinutes = throttled ? REFRESH_UNCHANGED_AFTER_MINUTES : 0 + const rows = await aiProviderRepo().query( + `UPDATE "ai_provider" + SET "status" = $1, "statusReason" = $2, "statusUpdated" = now(), "statusVersion" = "statusVersion" + 1 + WHERE "id" = $3 AND "platformId" = $4 + AND ("status" <> $1 + OR "statusUpdated" IS NULL + OR "statusUpdated" <= now() - make_interval(mins => $5)) + AND ($6::integer IS NULL OR "statusVersion" = $6::integer) + RETURNING "status"`, + [status, reason, providerId, platformId, refreshAfterMinutes, expectVersion ?? null], + ) + + const applied = Array.isArray(rows) && rows.length > 0 + log.debug({ platform: { id: platformId }, aiProvider: { id: providerId, status }, applied }, '[aiProviderHealth#record] Key status observed') + return applied ? status : null + }, +}) + +function printable(text: string | undefined): string | undefined { + return isNil(text) ? undefined : text.replace(/[\u0000-\u001f\u007f]/g, ' ') +} + +function buildReason({ statusCode, body, message }: ProviderOutcomeSignal): string | null { + const detail = printable(message ?? body) + if (isNil(detail) || detail.trim().length === 0) { + return isNil(statusCode) ? null : `HTTP ${statusCode}` + } + const prefix = isNil(statusCode) ? '' : `HTTP ${statusCode}: ` + return `${prefix}${detail.trim()}`.slice(0, MAX_REASON_LENGTH) +} + +type RecordParams = { + platformId: PlatformId + providerId: string + signal: ProviderOutcomeSignal + throttled?: boolean + expectVersion?: number +} 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 a2c25fe33e31..088ab5d77297 100644 --- a/packages/server/api/src/app/ai/ai-provider-service.ts +++ b/packages/server/api/src/app/ai/ai-provider-service.ts @@ -1,13 +1,17 @@ -import { ActivepiecesError, AIProviderName, apId, ErrorCode, isNil, PlatformId, spreadIfDefined, unique } from '@activepieces/core-utils' +import { ActivepiecesError, AiProviderKeyStatus, AIProviderName, apId, classifyProviderOutcome, ErrorCode, isNil, PlatformId, ProviderOutcomeSignal, spreadIfDefined, spreadIfNotUndefined, toProviderOutcomeSignal, tryCatch, unique } from '@activepieces/core-utils' +import { modelCatalog } from '@activepieces/server-utils' import { ActivePiecesProviderAuthConfig, AI_PROVIDER_ENTITY_TYPES, 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' +import { getAiProviderConfirmKey } from '../database/redis/keys' +import { distributedStore } from '../database/redis-connections' import { openRouterApi } from '../ee/platform/platform-plan/openrouter/openrouter-api' import { flagService } from '../flags/flag.service' import { encryptUtils } from '../helper/encryption' import { platformService } from '../platform/platform.service' import { AIProviderEntity, AIProviderSchema } from './ai-provider-entity' +import { aiProviderHealth } from './ai-provider-health' import { aiProviders } from './providers' const aiProviderRepo = repoFactory(AIProviderEntity) @@ -17,6 +21,11 @@ const modelsCache = new Map() const MANAGED_OPENROUTER_KEY_MONTHLY_LIMIT_USD = 500 const MANAGED_OPENROUTER_KEY_LIMIT_RESET = 'monthly' +// A passing check must not lock out the next real failure, so the claim is a floor between +// checks rather than a window that swallows them. A confirmed failure needs no floor: the row +// stops being active, and only an active key asks for confirmation. +const CONFIRM_MIN_INTERVAL_SECONDS = 10 + export const aiProviderService = (log: FastifyBaseLogger) => ({ async setup(): Promise { cron.schedule('0 0 * * *', () => { @@ -47,7 +56,7 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ 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 }) + const models = await fetchModels({ aiProvider, platformId, log }) return aiProvider.modelScope === 'selected' ? models.filter((model) => aiProvider.modelIds.includes(model.id)) : models @@ -55,7 +64,7 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ async listModelsForConfig({ platformId, configId }: { platformId: PlatformId, configId: string }): Promise { const aiProvider = await getRowByIdOrThrow({ platformId, configId }) - return fetchModels({ aiProvider, platformId }) + return fetchModels({ aiProvider, platformId, log }) }, async create(platformId: PlatformId, request: CreateAIProviderRequest): Promise { @@ -78,6 +87,9 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ modelIds: [], projectScope: 'all', projectIds: [], + status: 'active', + statusReason: null, + statusUpdated: new Date().toISOString(), }) return toConfigResponse(saved) }, @@ -106,6 +118,7 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ await assertDisplayNameIsFree({ platformId, provider: aiProvider.provider, displayName: request.displayName, exceptId: providerId }) const config = request.config ?? aiProvider.config + const revalidated = !isNil(request.auth) || !isNil(request.config) if (!isNil(request.auth)) { await this.validateProviderCredentials(aiProvider.provider, request.auth, config) } @@ -123,6 +136,7 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ ...spreadIfDefined('modelIds', request.modelIds), ...spreadIfDefined('projectScope', request.projectScope), ...spreadIfDefined('projectIds', request.projectIds), + ...(revalidated ? provedHealthy() : {}), displayName: request.displayName, } @@ -169,6 +183,39 @@ export const aiProviderService = (log: FastifyBaseLogger) => ({ id: providerId, }) }, + async recordKeyObservation({ platformId, providerId, signal }: { platformId: PlatformId, providerId: string, signal: ProviderOutcomeSignal }): Promise { + const status = classifyProviderOutcome(signal) + if (status === 'no_change') { + return + } + const aiProvider = await aiProviderRepo().findOneBy({ id: providerId, platformId }) + if (isNil(aiProvider)) { + return + } + const demotesHealthyKey = status !== 'active' && aiProvider.status === 'active' + if (!demotesHealthyKey || aiProvider.provider === AIProviderName.ACTIVEPIECES) { + await aiProviderHealth(log).record({ platformId, providerId, signal }) + return + } + await distributedStore.runOnceWithin( + getAiProviderConfirmKey(providerId), + CONFIRM_MIN_INTERVAL_SECONDS, + () => this.recheck({ platformId, providerId, expectVersion: aiProvider.statusVersion }), + ) + }, + + async recheck({ platformId, providerId, expectVersion }: { platformId: PlatformId, providerId: string, expectVersion?: number }): Promise { + const aiProvider = await getRowByIdOrThrow({ platformId, configId: providerId }) + if (aiProvider.provider === AIProviderName.ACTIVEPIECES) { + return aiProvider.status + } + const auth = await decryptRowAuth({ aiProvider, platformId }) + const { error } = await tryCatch(() => aiProviders[aiProvider.provider].validateConnection(auth, aiProvider.config, log)) + const signal = isNil(error) ? { statusCode: 200 } : toProviderOutcomeSignal(error) + const recorded = await aiProviderHealth(log).record({ platformId, providerId, signal, throttled: false, ...spreadIfNotUndefined('expectVersion', expectVersion) }) + return recorded ?? aiProvider.status + }, + async validateProviderCredentials(provider: AIProviderName, auth: AIProviderAuthConfig, config: AIProviderConfig): Promise { const providerStrategy = aiProviders[provider] try { @@ -237,6 +284,10 @@ function rankRows(rows: AIProviderSchema[]): AIProviderSchema[] { }) } +function provedHealthy(): { status: AiProviderKeyStatus, statusReason: null, statusUpdated: () => string, statusVersion: () => string } { + return { status: 'active', statusReason: null, statusUpdated: () => 'now()', statusVersion: () => '"statusVersion" + 1' } +} + function toConfigResponse(row: AIProviderSchema): AIProviderWithoutSensitiveData { return { id: row.id, @@ -248,6 +299,9 @@ function toConfigResponse(row: AIProviderSchema): AIProviderWithoutSensitiveData modelIds: row.modelIds, projectScope: row.projectScope, projectIds: row.projectIds, + status: row.status, + statusReason: row.statusReason, + statusUpdated: row.statusUpdated, } } @@ -371,16 +425,22 @@ async function getRowByIdOrThrow({ platformId, configId }: { platformId: Platfor return aiProvider } -async function fetchModels({ aiProvider, platformId }: { aiProvider: AIProviderSchema, platformId: PlatformId }): Promise { +async function fetchModels({ aiProvider, platformId, log }: { aiProvider: AIProviderSchema, platformId: PlatformId, log: FastifyBaseLogger }): 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) + const { data, error } = await tryCatch(() => aiProviders[provider].listModels(auth, config)) + if (!isNil(error) || isNil(data)) { + await aiProviderService(log).recordKeyObservation({ platformId, providerId: aiProvider.id, signal: toProviderOutcomeSignal(error) }) + throw error + } + const catalog = await modelCatalog.load() modelsCache.set(cacheKey, data.map(model => ({ id: model.id, name: model.name, type: model.type, + ...spreadIfDefined('metadata', catalog.lookup({ provider, modelId: model.id })), }))) } return modelsCache.get(cacheKey)! diff --git a/packages/server/api/src/app/authentication/authentication.service.ts b/packages/server/api/src/app/authentication/authentication.service.ts index 4ec6c5181b1d..6cf2060e8b34 100644 --- a/packages/server/api/src/app/authentication/authentication.service.ts +++ b/packages/server/api/src/app/authentication/authentication.service.ts @@ -9,14 +9,22 @@ import { platformService } from '../platform/platform.service' import { userService } from '../user/user-service' import { userInvitationsService } from '../user-invitations/user-invitation.service' import { authenticationUtils } from './authentication-utils' -import { disposableEmail } from './lib/disposable-email' +import { zerobounce } from './lib/zerobounce' import { otpService } from './otp/otp-service' import { userIdentityService } from './user-identity/user-identity-service' export const authenticationService = (log: FastifyBaseLogger) => ({ async signUp(params: SignUpParams): Promise { if (params.provider === UserIdentityProvider.EMAIL) { - await disposableEmail.assertMaySignUp({ email: params.email, log }) + const maySignUp = await zerobounce.maySignUp({ email: params.email, log }) + if (!maySignUp) { + throw new ActivepiecesError({ + code: ErrorCode.EMAIL_IS_NOT_VERIFIED, + params: { + email: params.email.toLowerCase().trim(), + }, + }) + } } const platformId = params.platformId diff --git a/packages/server/api/src/app/authentication/lib/disposable-email.ts b/packages/server/api/src/app/authentication/lib/disposable-email.ts deleted file mode 100644 index 213c4040575f..000000000000 --- a/packages/server/api/src/app/authentication/lib/disposable-email.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { ActivepiecesError, ErrorCode } from '@activepieces/core-utils' -import disposableDomains from 'disposable-email-domains' -import wildcardDomains from 'disposable-email-domains/wildcard.json' -import { FastifyBaseLogger } from 'fastify' -import { system } from '../../helper/system/system' -import { AppSystemProp } from '../../helper/system/system-props' -import { userInvitationsService } from '../../user-invitations/user-invitation.service' - -const exactDomains = new Set(disposableDomains) -const suffixDomains: string[] = wildcardDomains - -function domainOf(email: string): string { - const at = email.lastIndexOf('@') - return at < 0 ? '' : email.slice(at + 1).trim().toLowerCase().replace(/\.$/, '') -} - -function isDisposable(email: string): boolean { - const domain = domainOf(email) - if (domain.length === 0) { - return false - } - if (exactDomains.has(domain)) { - return true - } - return suffixDomains.some((suffix) => domain === suffix || domain.endsWith(`.${suffix}`)) -} - -async function assertMaySignUp({ email, log }: AssertMaySignUpParams): Promise { - if (system.getBoolean(AppSystemProp.ALLOW_DISPOSABLE_EMAILS)) { - return - } - if (!isDisposable(email)) { - return - } - const invited = await userInvitationsService(log).hasAnyAcceptedInvitationsForEmail({ email }) - if (invited) { - return - } - throw new ActivepiecesError({ - code: ErrorCode.DOMAIN_NOT_ALLOWED, - params: { - domain: domainOf(email), - }, - }) -} - -export const disposableEmail = { - isDisposable, - assertMaySignUp, -} - -type AssertMaySignUpParams = { - email: string - log: FastifyBaseLogger -} diff --git a/packages/server/api/src/app/authentication/lib/zerobounce.ts b/packages/server/api/src/app/authentication/lib/zerobounce.ts new file mode 100644 index 000000000000..099a9d833d2d --- /dev/null +++ b/packages/server/api/src/app/authentication/lib/zerobounce.ts @@ -0,0 +1,148 @@ +import { isNil, tryCatch } from '@activepieces/core-utils' +import { safeHttp } from '@activepieces/server-utils' +import { FastifyBaseLogger } from 'fastify' +import { distributedStore } from '../../database/redis-connections' +import { system } from '../../helper/system/system' +import { AppSystemProp } from '../../helper/system/system-props' +import { userInvitationsService } from '../../user-invitations/user-invitation.service' + +const VALIDATE_URL = 'https://api.zerobounce.net/v2/validate' +const VALIDATE_TIMEOUT_SECONDS = 5 +const REQUEST_TIMEOUT_MS = 7_000 + +const DISPOSABLE_DOMAIN_CACHE_KEY = 'zerobounce:disposable-domains:v1' +const DISPOSABLE_DOMAIN_CACHE_SIZE = 500 + +const REFUSED_STATUSES = new Set(['spamtrap', 'abuse']) +const REFUSED_DO_NOT_MAIL_SUB_STATUSES = new Set(['disposable', 'toxic', 'possible_trap', 'global_suppression']) + +function apiKey(): string | undefined { + const raw = system.get(AppSystemProp.ZEROBOUNCE_API_KEY)?.trim() + return isNil(raw) || raw.length === 0 ? undefined : raw +} + +function domainOf(email: string): string { + const at = email.lastIndexOf('@') + return at < 0 ? '' : email.slice(at + 1).trim().toLowerCase().replace(/\.$/, '') +} + +function reasonOf(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function refusedBy(verdict: ValidateResponse): boolean { + const status = verdict.status?.toLowerCase() ?? '' + if (REFUSED_STATUSES.has(status)) { + return true + } + return status === 'do_not_mail' && REFUSED_DO_NOT_MAIL_SUB_STATUSES.has(verdict.sub_status?.toLowerCase() ?? '') +} + +function isDisposableVerdict(verdict: ValidateResponse): boolean { + return verdict.status?.toLowerCase() === 'do_not_mail' && verdict.sub_status?.toLowerCase() === 'disposable' +} + +async function cachedDisposableDomains({ log }: CachedDisposableDomainsParams): Promise { + const { data: cached, error } = await tryCatch(() => distributedStore.get(DISPOSABLE_DOMAIN_CACHE_KEY)) + if (!isNil(error)) { + log.warn({ error: reasonOf(error) }, '[zerobounce#cachedDisposableDomains] the cache could not be read, asking zerobounce') + return [] + } + return Array.isArray(cached) ? cached : [] +} + +async function rememberDisposableDomain({ domain, log }: RememberDisposableDomainParams): Promise { + if (domain.length === 0) { + return + } + const known = await cachedDisposableDomains({ log }) + if (known.includes(domain)) { + return + } + const next = [...known, domain].slice(-DISPOSABLE_DOMAIN_CACHE_SIZE) + const { error } = await tryCatch(() => distributedStore.put(DISPOSABLE_DOMAIN_CACHE_KEY, next)) + if (!isNil(error)) { + log.warn({ error: reasonOf(error) }, '[zerobounce#rememberDisposableDomain] the verdict could not be cached, the next attempt will spend a credit') + } +} + +async function refuses({ email, log }: RefusesParams): Promise { + const key = apiKey() + if (isNil(key)) { + return false + } + const domain = domainOf(email) + const known = domain.length > 0 && (await cachedDisposableDomains({ log })).includes(domain) + if (known) { + log.info({ zerobounce: { domain, source: 'cache' } }, '[zerobounce#refuses] address refused') + return true + } + const { data: response, error } = await tryCatch(() => safeHttp.axios.get(VALIDATE_URL, { + params: { + api_key: key, + email, + timeout: VALIDATE_TIMEOUT_SECONDS, + }, + timeout: REQUEST_TIMEOUT_MS, + })) + if (!isNil(error) || isNil(response)) { + log.warn({ error: reasonOf(error) }, '[zerobounce#refuses] the address could not be validated, letting it through') + return false + } + if (!isNil(response.data.error)) { + log.error({ error: response.data.error }, '[zerobounce#refuses] zerobounce answered with an error, letting the address through: check the api key and the credit balance') + return false + } + if (isDisposableVerdict(response.data)) { + await rememberDisposableDomain({ domain, log }) + } + const refused = refusedBy(response.data) + if (refused) { + log.info({ + zerobounce: { + domain, + status: response.data.status, + subStatus: response.data.sub_status, + source: 'zerobounce', + }, + }, '[zerobounce#refuses] address refused') + } + return refused +} + +async function maySignUp({ email, log }: MaySignUpParams): Promise { + const refused = await refuses({ email, log }) + if (!refused) { + return true + } + return userInvitationsService(log).hasAnyAcceptedInvitationsForEmail({ email }) +} + +export const zerobounce = { + maySignUp, +} + +type ValidateResponse = { + status?: string + sub_status?: string + error?: string +} + +type CachedDisposableDomainsParams = { + log: FastifyBaseLogger +} + +type RememberDisposableDomainParams = { + domain: string + log: FastifyBaseLogger +} + +type RefusesParams = { + email: string + log: FastifyBaseLogger +} + +type MaySignUpParams = { + email: string + log: FastifyBaseLogger +} diff --git a/packages/server/api/src/app/authentication/passwordless-auth.service.ts b/packages/server/api/src/app/authentication/passwordless-auth.service.ts index 473efa340c5c..e51d085543dc 100644 --- a/packages/server/api/src/app/authentication/passwordless-auth.service.ts +++ b/packages/server/api/src/app/authentication/passwordless-auth.service.ts @@ -12,9 +12,9 @@ import { userService } from '../user/user-service' import { userInvitationsService } from '../user-invitations/user-invitation.service' import { authenticationUtils } from './authentication-utils' import { authenticationService } from './authentication.service' -import { disposableEmail } from './lib/disposable-email' import { signupNames } from './lib/signup-names' import { turnstile } from './lib/turnstile' +import { zerobounce } from './lib/zerobounce' import { otpService } from './otp/otp-service' import { userIdentityService } from './user-identity/user-identity-service' @@ -23,7 +23,10 @@ export const passwordlessAuthService = (log: FastifyBaseLogger) => ({ await turnstile.assertSolved({ token: captchaToken, remoteIp, log }) const existingIdentity = await userIdentityService(log).getIdentityByEmail(email) if (isNil(existingIdentity)) { - await disposableEmail.assertMaySignUp({ email, log }) + const maySignUp = await zerobounce.maySignUp({ email, log }) + if (!maySignUp) { + return + } } if (!isNil(platformId)) { await assertPlatformAuthIsOpenTo({ email, platformId, log }) diff --git a/packages/server/api/src/app/database/migration/postgres/1837000000000-AddAiProviderStatus.ts b/packages/server/api/src/app/database/migration/postgres/1837000000000-AddAiProviderStatus.ts new file mode 100644 index 000000000000..e3a016f44d24 --- /dev/null +++ b/packages/server/api/src/app/database/migration/postgres/1837000000000-AddAiProviderStatus.ts @@ -0,0 +1,32 @@ +import { QueryRunner } from 'typeorm' +import { Migration } from '../../migration' + +export class AddAiProviderStatus1837000000000 implements Migration { + name = 'AddAiProviderStatus1837000000000' + breaking = false + release = '0.88.4' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "ai_provider" + ADD COLUMN IF NOT EXISTS "status" character varying DEFAULT 'active', + ADD COLUMN IF NOT EXISTS "statusReason" character varying, + ADD COLUMN IF NOT EXISTS "statusUpdated" TIMESTAMP WITH TIME ZONE, + ADD COLUMN IF NOT EXISTS "statusVersion" integer NOT NULL DEFAULT 0 + `) + await queryRunner.query(` + ALTER TABLE "ai_provider" + ALTER COLUMN "status" SET NOT NULL + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "ai_provider" + DROP COLUMN "status", + DROP COLUMN "statusReason", + DROP COLUMN "statusUpdated", + DROP COLUMN "statusVersion" + `) + } +} diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts index b5005e91d13c..874ea39529b7 100644 --- a/packages/server/api/src/app/database/postgres-connection.ts +++ b/packages/server/api/src/app/database/postgres-connection.ts @@ -428,6 +428,7 @@ import { ClearRoleFromCompanyPersonalization1833000000000 } from './migration/po import { AddAutoCreatePersonalProjectsToPlatform1834000000000 } from './migration/postgres/1834000000000-AddAutoCreatePersonalProjectsToPlatform' import { WidenMcpOAuthState1835000000000 } from './migration/postgres/1835000000000-WidenMcpOAuthState' import { DropTeamsBotInstallation1836000000000 } from './migration/postgres/1836000000000-DropTeamsBotInstallation' +import { AddAiProviderStatus1837000000000 } from './migration/postgres/1837000000000-AddAiProviderStatus' const getSslConfig = (): boolean | TlsOptions => { const useSsl = system.get(AppSystemProp.POSTGRES_USE_SSL) @@ -871,6 +872,7 @@ export const getMigrations = (): (new () => Migration)[] => { AddAutoCreatePersonalProjectsToPlatform1834000000000, WidenMcpOAuthState1835000000000, DropTeamsBotInstallation1836000000000, + AddAiProviderStatus1837000000000, ] return migrations } diff --git a/packages/server/api/src/app/database/redis/keys.ts b/packages/server/api/src/app/database/redis/keys.ts index 223064fde606..f72d5d7960bb 100644 --- a/packages/server/api/src/app/database/redis/keys.ts +++ b/packages/server/api/src/app/database/redis/keys.ts @@ -1,5 +1,6 @@ import { PlatformId, ProjectId } from '@activepieces/core-utils' +export const getAiProviderConfirmKey = (providerId: string): string => `ai_provider:confirm-downgrade:${providerId}` export const getPlatformPlanNameKey = (platformId: PlatformId): string => `platform_plan:plan:${platformId}` export const getCreditsBalanceKey = (platformId: PlatformId): string => `platform_plan:credits:${platformId}` export const getAppSumoAiCreditsBalanceKey = (platformId: PlatformId): string => `platform_plan:appsumo-ai-credits:${platformId}` 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 f296a3b2e260..0f1b0847a546 100644 --- a/packages/server/api/src/app/ee/agent/agent-helpers.ts +++ b/packages/server/api/src/app/ee/agent/agent-helpers.ts @@ -1,4 +1,4 @@ -import { ActivepiecesError, AIProviderName, apId, ErrorCode, isNil, spreadIfDefined, tryCatch, unique } from '@activepieces/core-utils' +import { ActivepiecesError, AIProviderName, apId, ErrorCode, isNil, ProviderOutcomeReporter, spreadIfDefined, tryCatch, unique } from '@activepieces/core-utils' import { agentAiUtils } from '@activepieces/server-utils' import { ACTIVEPIECES_CHAT_TIERS, AgentConversation, AgentConversationStatus, AI_PROVIDER_ENTITY_TYPES, aiProviderUtils, DEFAULT_CHAT_TIER_ID, GetAgentMemoryResponse, GetProviderConfigResponse, Project, ProjectType, UserMemory } from '@activepieces/shared' import { SharedV3ProviderOptions } from '@ai-sdk/provider' @@ -187,6 +187,15 @@ function resolveModelIdForAnalytics({ provider, selectedModel }: { provider: AIP return aiProviderUtils.isCuratedChatModelId({ modelId: selectedModel }) ? selectedModel : null } +function reportKeyOutcome({ platformId, providerId, log }: { platformId: string, providerId: string, log: FastifyBaseLogger }): ProviderOutcomeReporter { + return async (signal) => { + const { error } = await tryCatch(() => aiProviderService(log).recordKeyObservation({ platformId, providerId, signal })) + if (!isNil(error)) { + log.warn({ error, aiProvider: { id: providerId } }, '[agentHelpers#reportKeyOutcome] Could not record key status') + } + } +} + 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 }) @@ -196,6 +205,7 @@ async function resolveTierModel({ platformId, tierId, provider, providerConfigId auth: providerConfig.auth, config: providerConfig.config, modelId, + onOutcome: reportKeyOutcome({ platformId, providerId: providerConfig.configId, log }), }), modelId, provider: providerConfig.provider, @@ -216,6 +226,7 @@ async function resolveEmbeddingModel({ platformId, provider, providerConfigId, s provider: providerConfig.provider, auth: providerConfig.auth, config: providerConfig.config, + onOutcome: reportKeyOutcome({ platformId, providerId: providerConfig.configId, log }), }) } diff --git a/packages/server/api/src/app/ee/projects/platform-project-service.ts b/packages/server/api/src/app/ee/projects/platform-project-service.ts index ae7db8eb9a4e..d8d66b9b895f 100644 --- a/packages/server/api/src/app/ee/projects/platform-project-service.ts +++ b/packages/server/api/src/app/ee/projects/platform-project-service.ts @@ -275,11 +275,12 @@ async function enrichProjects( const projectIds = projects.map(p => p.id) - const [totalUsersMap, activeUsersMap, totalFlowsMap, activeFlowsMap, plansMap] = await Promise.all([ + const [totalUsersMap, activeUsersMap, totalFlowsMap, activeFlowsMap, lastFlowUpdatedMap, plansMap] = await Promise.all([ projectMemberService(log).countTotalUsersByProjects(projectIds), projectMemberService(log).countActiveUsersByProjects(projectIds), flowService(log).countFlowsByProjects(projectIds), flowService(log).countActiveFlowsByProjects(projectIds), + flowService(log).getLastFlowUpdatedByProjects(projectIds), projectLimitsService(log).getOrCreateDefaultPlansForProjects(projectIds), ]) @@ -292,6 +293,7 @@ async function enrichProjects( totalFlows: totalFlowsMap.get(project.id) ?? 0, totalUsers: totalUsersMap.get(project.id) ?? 0, activeUsers: activeUsersMap.get(project.id) ?? 0, + lastFlowUpdated: lastFlowUpdatedMap.get(project.id) ?? null, }, } }) diff --git a/packages/server/api/src/app/flows/flow/flow.service.ts b/packages/server/api/src/app/flows/flow/flow.service.ts index 9cde186ab2a3..a5649da2fae6 100644 --- a/packages/server/api/src/app/flows/flow/flow.service.ts +++ b/packages/server/api/src/app/flows/flow/flow.service.ts @@ -687,6 +687,21 @@ export const flowService = (log: FastifyBaseLogger) => ({ return new Map(result.map(r => [r.projectId, parseInt(r.count)])) }, + + async getLastFlowUpdatedByProjects(projectIds: ProjectId[]): Promise> { + if (projectIds.length === 0) return new Map() + + const result = await flowRepo() + .createQueryBuilder('flow') + .select('flow.projectId', 'projectId') + .addSelect('MAX(flow.updated)', 'lastUpdated') + .where('flow.projectId IN (:...projectIds)', { projectIds }) + .andWhere('flow.operationStatus != :deleting', { deleting: FlowOperationStatus.DELETING }) + .groupBy('flow.projectId') + .getRawMany() + + return new Map(result.map(r => [r.projectId, new Date(r.lastUpdated).toISOString()])) + }, }) diff --git a/packages/server/api/src/app/helper/system-validator.ts b/packages/server/api/src/app/helper/system-validator.ts index 56d1f7891665..8852175fd24a 100644 --- a/packages/server/api/src/app/helper/system-validator.ts +++ b/packages/server/api/src/app/helper/system-validator.ts @@ -50,7 +50,6 @@ const systemPropValidators: { [key in SystemProp]: (value: string) => true | string } = { // AppSystemProp - [AppSystemProp.ALLOW_DISPOSABLE_EMAILS]: booleanValidator, [AppSystemProp.ALLOW_OPEN_SIGN_UP]: booleanValidator, [AppSystemProp.EXECUTION_MODE]: enumValidator(Object.values(ExecutionMode)), [AppSystemProp.SKIP_PROJECT_LIMITS_CHECK]: booleanValidator, @@ -155,6 +154,7 @@ const systemPropValidators: { [AppSystemProp.TOOL_SEARCH_ENABLED]: booleanValidator, [AppSystemProp.TRIGGER_DEFAULT_POLL_INTERVAL]: numberValidator, [AppSystemProp.WEBHOOK_TIMEOUT_SECONDS]: numberValidator, + [AppSystemProp.ZEROBOUNCE_API_KEY]: stringValidator, [AppSystemProp.LOAD_TRANSLATIONS_FOR_DEV_PIECES]: booleanValidator, [AppSystemProp.APPSUMO_TOKEN]: stringValidator, [AppSystemProp.APOLLO_API_KEY]: stringValidator, diff --git a/packages/server/api/src/app/helper/system/system-props.ts b/packages/server/api/src/app/helper/system/system-props.ts index e315970b5870..3478742f4cf3 100644 --- a/packages/server/api/src/app/helper/system/system-props.ts +++ b/packages/server/api/src/app/helper/system/system-props.ts @@ -4,7 +4,6 @@ import { environmentMigrations } from '@activepieces/server-utils' export type SystemProp = AppSystemProp export enum AppSystemProp { - ALLOW_DISPOSABLE_EMAILS = 'ALLOW_DISPOSABLE_EMAILS', ALLOW_OPEN_SIGN_UP = 'ALLOW_OPEN_SIGN_UP', ALLOWED_EMBED_ORIGINS = 'ALLOWED_EMBED_ORIGINS', API_KEY = 'API_KEY', @@ -129,6 +128,7 @@ export enum AppSystemProp { TRIGGER_TIMEOUT_SECONDS = 'TRIGGER_TIMEOUT_SECONDS', USE_CDN_FOR_BUNDLES = 'USE_CDN_FOR_BUNDLES', WEBHOOK_TIMEOUT_SECONDS = 'WEBHOOK_TIMEOUT_SECONDS', + ZEROBOUNCE_API_KEY = 'ZEROBOUNCE_API_KEY', OPENROUTER_PROVISION_KEY = 'OPENROUTER_PROVISION_KEY', OPENAI_API_KEY = 'OPENAI_API_KEY', EVENT_DESTINATION_TIMEOUT_SECONDS = 'EVENT_DESTINATION_TIMEOUT_SECONDS', diff --git a/packages/server/api/src/app/helper/system/system.ts b/packages/server/api/src/app/helper/system/system.ts index fbd3a01c6b93..f042586df1da 100644 --- a/packages/server/api/src/app/helper/system/system.ts +++ b/packages/server/api/src/app/helper/system/system.ts @@ -34,7 +34,6 @@ const systemPropDefaultValues: Partial> = { [AppSystemProp.WEBHOOK_TIMEOUT_SECONDS]: '30', [AppSystemProp.LOAD_TRANSLATIONS_FOR_DEV_PIECES]: 'false', [AppSystemProp.LOG_LEVEL]: 'info', - [AppSystemProp.ALLOW_DISPOSABLE_EMAILS]: 'false', [AppSystemProp.LOG_PRETTY]: 'false', [AppSystemProp.S3_USE_SIGNED_URLS]: 'false', [AppSystemProp.MAX_FILE_SIZE_MB]: '25', diff --git a/packages/server/api/test/integration/ce/ai-provider/key-status.test.ts b/packages/server/api/test/integration/ce/ai-provider/key-status.test.ts new file mode 100644 index 000000000000..2a8430c2a32a --- /dev/null +++ b/packages/server/api/test/integration/ce/ai-provider/key-status.test.ts @@ -0,0 +1,431 @@ +import { AIProviderName, apId } from '@activepieces/core-utils' +import { DefaultProjectRole } from '@activepieces/shared' +import { FastifyInstance } from 'fastify' +import { StatusCodes } from 'http-status-codes' +import { vi } from 'vitest' +import { aiProviderHealth } from '../../../../src/app/ai/ai-provider-health' +import { aiProviderService } from '../../../../src/app/ai/ai-provider-service' +import { db } from '../../../helpers/db' +import { mockAndSaveAIProvider } from '../../../helpers/mocks' +import { createMemberContext, createTestContext, TestContext } from '../../../helpers/test-context' +import { setupTestEnvironment, teardownTestEnvironment } from '../../../helpers/test-setup' + +const { mockSendRequest } = vi.hoisted(() => ({ mockSendRequest: vi.fn() })) + +vi.mock('@activepieces/pieces-common', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + httpClient: { ...original.httpClient, sendRequest: mockSendRequest }, + } +}) + +let app: FastifyInstance | null = null +let ctx: TestContext + +beforeAll(async () => { + app = await setupTestEnvironment({ fresh: true }) +}) + +afterAll(async () => { + await teardownTestEnvironment() +}) + +beforeEach(async () => { + ctx = await createTestContext(app!) + mockSendRequest.mockReset() +}) + +// Each azure config gets its own resourceName so the model cache never serves one key's answer +// for another — the cache is keyed on the whole configuration. +async function azureKey(resourceName: string) { + return mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.AZURE, + displayName: `Azure ${resourceName}`, + config: { resourceName }, + }) +} + +function httpFailure(status: number, body: unknown) { + return Object.assign(new Error(`Request failed with status code ${status}`), { + response: { status, body }, + }) +} + +async function statusOf(providerId: string) { + const row = await db.findOneByOrFail<{ status: string, statusReason: string | null, statusUpdated: string | null, statusVersion: number }>('ai_provider', { id: providerId }) + return row +} + +describe('AI provider key status', () => { + it('is active the moment it is created, because creating it proved the credentials', async () => { + mockSendRequest.mockResolvedValue({ body: { data: [] } }) + + const response = await ctx.post('/v1/ai-providers', { + provider: AIProviderName.AZURE, + displayName: 'Fresh azure key', + config: { resourceName: 'fresh' }, + auth: { apiKey: 'valid-key' }, + }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + expect(response?.json().status).toBe('active') + const stored = await statusOf(response?.json().id) + expect(stored.status).toBe('active') + expect(stored.statusUpdated).not.toBeNull() + }) + + // The reported bug: a second key used to read untested while the first flipped to active, because + // nothing recorded at creation and the list was refetched before any later call could. + it('does not leave a second key waiting on a later call, even when it shares the first key credentials', async () => { + mockSendRequest.mockResolvedValue({ body: { data: [] } }) + const sharedAuth = { apiKey: 'one-key-two-configs' } + + const first = await ctx.post('/v1/ai-providers', { + provider: AIProviderName.AZURE, + displayName: 'First', + config: { resourceName: 'shared' }, + auth: sharedAuth, + }) + const second = await ctx.post('/v1/ai-providers', { + provider: AIProviderName.AZURE, + displayName: 'Second', + config: { resourceName: 'shared' }, + auth: sharedAuth, + }) + + // The second key's model listing is a cache hit on the first key's entry, so it reports + // nothing — which is exactly why creation has to record instead. + await ctx.get(`/v1/ai-providers/configs/${second.json().id}/models`) + + expect((await statusOf(first.json().id)).status).toBe('active') + expect((await statusOf(second.json().id)).status).toBe('active') + }) + + it('leaves the status alone when a replacement key is rejected and discarded', async () => { + const key = await azureKey('replaced') + await db.update('ai_provider', key.id, { status: 'active' }) + + mockSendRequest.mockRejectedValue(httpFailure(401, { error: { message: 'Access denied due to invalid subscription key' } })) + const response = await ctx.post(`/v1/ai-providers/${key.id}`, { + displayName: 'replaced', + auth: { apiKey: 'revoked' }, + }) + + expect(response?.statusCode).not.toBe(StatusCodes.OK) + expect((await statusOf(key.id)).status).toBe('active') + }) + + it('does not let a status write during validation hand the old version back to a confirmation', async () => { + const key = await azureKey('replaced-mid-check') + const health = aiProviderHealth(app!.log) + const before = await statusOf(key.id) + + // The race: a status write lands while the replacement is still validating, so the version + // the replacement read is already out of date by the time it writes. + mockSendRequest.mockImplementationOnce(async () => { + await health.record({ platformId: ctx.platform.id, providerId: key.id, signal: { statusCode: 200 }, throttled: false }) + return { body: { data: [] } } + }) + mockSendRequest.mockResolvedValue({ body: { data: [] } }) + + const response = await ctx.post(`/v1/ai-providers/${key.id}`, { + displayName: 'replaced-mid-check', + auth: { apiKey: 'fresh' }, + }) + expect(response?.statusCode).toBe(StatusCodes.OK) + + const interimVersion = before.statusVersion + 1 + expect((await statusOf(key.id)).statusVersion).toBe(interimVersion + 1) + + const staleConfirmation = await health.record({ + platformId: ctx.platform.id, + providerId: key.id, + signal: { statusCode: 401, body: 'invalid api key' }, + throttled: false, + expectVersion: interimVersion, + }) + + expect(staleConfirmation).toBeNull() + expect((await statusOf(key.id)).status).toBe('active') + }) + + it('records a replacement key that works', async () => { + const key = await azureKey('accepted') + await db.update('ai_provider', key.id, { status: 'rejected', statusReason: 'HTTP 401' }) + + mockSendRequest.mockResolvedValue({ body: { data: [{ id: 'gpt-4o', model: 'gpt-4o', status: 'succeeded' }] } }) + const response = await ctx.post(`/v1/ai-providers/${key.id}`, { + displayName: 'accepted', + auth: { apiKey: 'working' }, + }) + + expect(response?.statusCode).toBe(StatusCodes.OK) + const recorded = await statusOf(key.id) + expect(recorded.status).toBe('active') + expect(recorded.statusReason).toBeNull() + }) + + it('turns a rejected secret into rejected, then back to active once an admin rechecks it', async () => { + const key = await azureKey('rotated') + + mockSendRequest.mockRejectedValue(httpFailure(401, { error: { message: 'Access denied due to invalid subscription key' } })) + await ctx.get(`/v1/ai-providers/configs/${key.id}/models`) + + const rejected = await statusOf(key.id) + expect(rejected.status).toBe('rejected') + expect(rejected.statusReason).toContain('401') + expect(rejected.statusUpdated).not.toBeNull() + + mockSendRequest.mockResolvedValueOnce({ body: { data: [{ id: 'gpt-4o', model: 'gpt-4o', status: 'succeeded' }] } }) + await ctx.post(`/v1/ai-providers/${key.id}/recheck`, {}) + + const recovered = await statusOf(key.id) + expect(recovered.status).toBe('active') + expect(recovered.statusReason).toBeNull() + }) + + it('does not let a model listing that never spent the key clear a real failure', async () => { + const key = await azureKey('listing-proves-nothing') + await db.update('ai_provider', key.id, { status: 'out_of_credits', statusReason: 'HTTP 429: insufficient_quota' }) + + mockSendRequest.mockResolvedValue({ body: { data: [{ id: 'gpt-4o', model: 'gpt-4o', status: 'succeeded' }] } }) + await ctx.get(`/v1/ai-providers/configs/${key.id}/models`) + + const unchanged = await statusOf(key.id) + expect(unchanged.status).toBe('out_of_credits') + expect(unchanged.statusReason).toContain('429') + }) + + it('reads the provider billing as out of credits, not as an outage', async () => { + const key = await azureKey('unpaid') + + mockSendRequest.mockRejectedValue(httpFailure(429, { + error: { code: 'insufficient_quota', message: 'You exceeded your current quota, please check your plan and billing details.' }, + })) + await ctx.get(`/v1/ai-providers/configs/${key.id}/models`) + + expect((await statusOf(key.id)).status).toBe('out_of_credits') + }) + + it('leaves the status alone for a plain rate limit, because a busy key is not a sick key', async () => { + const key = await azureKey('busy') + + mockSendRequest.mockRejectedValueOnce(httpFailure(429, { + error: { code: 'rate_limit_exceeded', message: 'Requests to the ChatCompletions Operation have exceeded the rate limit' }, + })) + await ctx.get(`/v1/ai-providers/configs/${key.id}/models`) + + expect((await statusOf(key.id)).status).toBe('active') + }) + + it('reads a provider outage as unreachable', async () => { + const key = await azureKey('down') + + mockSendRequest.mockRejectedValue(httpFailure(503, { error: { message: 'Service Unavailable' } })) + await ctx.get(`/v1/ai-providers/configs/${key.id}/models`) + + expect((await statusOf(key.id)).status).toBe('unreachable') + }) + + it('does not refresh an unchanged status inside the throttle window', async () => { + const key = await azureKey('steady') + + mockSendRequest.mockRejectedValue(httpFailure(503, { error: { message: 'Service Unavailable' } })) + await ctx.get(`/v1/ai-providers/configs/${key.id}/models`) + const first = await statusOf(key.id) + expect(first.status).toBe('unreachable') + + mockSendRequest.mockRejectedValue(httpFailure(503, { error: { message: 'Service Unavailable' } })) + await ctx.get(`/v1/ai-providers/configs/${key.id}/models`) + const second = await statusOf(key.id) + + expect(second.status).toBe('unreachable') + expect(String(second.statusUpdated)).toBe(String(first.statusUpdated)) + }) + + it('writes an unchanged status once, unless the caller skips the throttle', async () => { + const key = await azureKey('throttled') + const health = aiProviderHealth(app!.log) + const signal = { statusCode: 503, body: 'Service Unavailable' } + + const firstWrite = await health.record({ platformId: ctx.platform.id, providerId: key.id, signal }) + const throttledAgain = await health.record({ platformId: ctx.platform.id, providerId: key.id, signal }) + const forced = await health.record({ platformId: ctx.platform.id, providerId: key.id, signal, throttled: false }) + + expect(firstWrite).toBe('unreachable') + expect(throttledAgain).toBeNull() + expect(forced).toBe('unreachable') + }) + + it('asks the provider before demoting a healthy key, and keeps it active when the answer is fine', async () => { + const key = await azureKey('one-off-failure') + mockSendRequest.mockResolvedValue({ body: { data: [] } }) + + await aiProviderService(app!.log).recordKeyObservation({ + platformId: ctx.platform.id, + providerId: key.id, + signal: { statusCode: 401, body: 'invalid api key' }, + }) + + expect((await statusOf(key.id)).status).toBe('active') + }) + + it('demotes the key when the provider confirms the failure', async () => { + const key = await azureKey('really-broken') + mockSendRequest.mockRejectedValue(httpFailure(401, { error: { message: 'Access denied due to invalid subscription key' } })) + + await aiProviderService(app!.log).recordKeyObservation({ + platformId: ctx.platform.id, + providerId: key.id, + signal: { statusCode: 401, body: 'invalid api key' }, + }) + + expect((await statusOf(key.id)).status).toBe('rejected') + }) + + it('drops a confirmation whose answer arrived after the key had already recovered', async () => { + const key = await azureKey('recovered-mid-check') + const health = aiProviderHealth(app!.log) + const before = await statusOf(key.id) + + await health.record({ platformId: ctx.platform.id, providerId: key.id, signal: { statusCode: 200 } }) + const late = await health.record({ + platformId: ctx.platform.id, + providerId: key.id, + signal: { statusCode: 401, body: 'invalid api key' }, + throttled: false, + expectVersion: before.statusVersion, + }) + + expect(late).toBeNull() + expect((await statusOf(key.id)).status).toBe('active') + }) + + it('applies a confirmation when nothing moved while it ran', async () => { + const key = await azureKey('unchanged-during-check') + const health = aiProviderHealth(app!.log) + await health.record({ platformId: ctx.platform.id, providerId: key.id, signal: { statusCode: 200 } }) + const seen = await statusOf(key.id) + + const applied = await health.record({ + platformId: ctx.platform.id, + providerId: key.id, + signal: { statusCode: 401, body: 'invalid api key' }, + throttled: false, + expectVersion: seen.statusVersion, + }) + + expect(applied).toBe('rejected') + expect((await statusOf(key.id)).status).toBe('rejected') + }) + + it('counts every status write, so a stale version can never match', async () => { + const key = await azureKey('versioned') + const health = aiProviderHealth(app!.log) + const start = await statusOf(key.id) + + await health.record({ platformId: ctx.platform.id, providerId: key.id, signal: { statusCode: 200 }, throttled: false }) + await health.record({ platformId: ctx.platform.id, providerId: key.id, signal: { statusCode: 200 }, throttled: false }) + + expect((await statusOf(key.id)).statusVersion).toBe(start.statusVersion + 2) + }) + + it('takes a reported recovery at once, without asking the provider', async () => { + const key = await azureKey('recovering') + await db.update('ai_provider', key.id, { status: 'rejected', statusReason: 'HTTP 401: old failure' }) + mockSendRequest.mockRejectedValue(httpFailure(500, { error: { message: 'never called' } })) + + await aiProviderService(app!.log).recordKeyObservation({ + platformId: ctx.platform.id, + providerId: key.id, + signal: { statusCode: 200 }, + }) + + const row = await statusOf(key.id) + expect(row.status).toBe('active') + expect(row.statusReason).toBeNull() + }) + + it('lets an admin recheck cut through the recent-success grace', async () => { + const key = await azureKey('recheck-through-grace') + const health = aiProviderHealth(app!.log) + + await health.record({ platformId: ctx.platform.id, providerId: key.id, signal: { statusCode: 200 } }) + const forced = await health.record({ + platformId: ctx.platform.id, + providerId: key.id, + signal: { statusCode: 401, body: 'invalid api key' }, + throttled: false, + }) + + expect(forced).toBe('rejected') + }) + + it('lets the next call correct a status a late observation got wrong', async () => { + const key = await azureKey('raced') + const health = aiProviderHealth(app!.log) + + const late = await health.record({ + platformId: ctx.platform.id, + providerId: key.id, + signal: { statusCode: 401, body: 'invalid api key' }, + }) + const afterRetry = await health.record({ + platformId: ctx.platform.id, + providerId: key.id, + signal: { statusCode: 200 }, + }) + + expect(late).toBe('rejected') + expect(afterRetry).toBe('active') + const row = await statusOf(key.id) + expect(row.status).toBe('active') + expect(row.statusReason).toBeNull() + }) + + describe('POST /:id/recheck', () => { + it('lets an admin ask now rather than wait for traffic', async () => { + const key = await azureKey('recheck-me') + await db.update('ai_provider', key.id, { status: 'rejected', statusReason: 'HTTP 401: old failure' }) + + mockSendRequest.mockResolvedValue({ body: { data: [{ id: 'gpt-4o', model: 'gpt-4o', status: 'succeeded' }] } }) + const response = await ctx.post(`/v1/ai-providers/${key.id}/recheck`, {}) + + expect(response?.statusCode).toBe(StatusCodes.OK) + expect(response?.json().status).toBe('active') + expect((await statusOf(key.id)).status).toBe('active') + }) + + it('will not claim the managed key is healthy, because it checks nothing', async () => { + const managed = await mockAndSaveAIProvider({ + platformId: ctx.platform.id, + provider: AIProviderName.ACTIVEPIECES, + displayName: 'Activepieces', + }) + await db.update('ai_provider', managed.id, { status: 'out_of_credits', statusReason: 'HTTP 402: no credits left' }) + + const response = await ctx.post(`/v1/ai-providers/${managed.id}/recheck`, {}) + + expect(response?.statusCode).toBe(StatusCodes.OK) + expect(response?.json().status).toBe('out_of_credits') + expect((await statusOf(managed.id)).status).toBe('out_of_credits') + }) + + it('forbids a non-admin member', async () => { + const key = await azureKey('guarded') + const memberCtx = await createMemberContext(app!, ctx, { projectRole: DefaultProjectRole.VIEWER }) + + const response = await memberCtx.post(`/v1/ai-providers/${key.id}/recheck`, {}) + + expect(response?.statusCode).toBe(StatusCodes.FORBIDDEN) + }) + + it('404s for a key on another platform', async () => { + const response = await ctx.post(`/v1/ai-providers/${apId()}/recheck`, {}) + + expect(response?.statusCode).toBe(StatusCodes.NOT_FOUND) + }) + }) +}) diff --git a/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts b/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts index 84ca86674783..8cf5f27998f9 100644 --- a/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts +++ b/packages/server/api/test/integration/ce/authentication/passwordless-authn.test.ts @@ -1,4 +1,5 @@ import { apId } from '@activepieces/core-utils' +import { safeHttp } from '@activepieces/server-utils' import { OtpState, OtpType, PlatformRole, UserIdentityProvider, UserStatus } from '@activepieces/shared' import { FastifyInstance } from 'fastify' import { StatusCodes } from 'http-status-codes' @@ -37,6 +38,18 @@ async function verifyCode({ email, code }: { email: string, code: string }) { }) } +async function withZerobounceVerdict({ status, subStatus, run }: WithZerobounceVerdictParams): Promise { + const answer = vi.spyOn(safeHttp.axios, 'get').mockResolvedValue({ data: { status, sub_status: subStatus } }) + process.env.AP_ZEROBOUNCE_API_KEY = 'test-api-key' + try { + return await run() + } + finally { + delete process.env.AP_ZEROBOUNCE_API_KEY + answer.mockRestore() + } +} + function wrongCodeFor(code: string): string { const shifted = (Number.parseInt(code, 10) + 1) % 1000000 return shifted.toString().padStart(6, '0') @@ -108,21 +121,34 @@ describe('Passwordless Authentication API', () => { expect(identity?.lastName).toBe('') }) - it('refuses a throwaway address and creates nothing', async () => { - const response = await app?.inject({ - method: 'POST', - url: '/api/v1/authentication/otp/request', - body: { email: 'someone@mailinator.com' }, + it('answers a refused address exactly like a served one, but sends and creates nothing', async () => { + const response = await withZerobounceVerdict({ + status: 'do_not_mail', + subStatus: 'disposable', + run: async () => app?.inject({ + method: 'POST', + url: '/api/v1/authentication/otp/request', + body: { email: 'someone@mailinator.com' }, + }), }) - expect(response?.statusCode).not.toBe(StatusCodes.NO_CONTENT) - expect(response?.json()?.code).toBe('DOMAIN_NOT_ALLOWED') - const identity = await databaseConnection().getRepository('user_identity') - .findOneBy({ email: 'someone@mailinator.com' }) - expect(identity).toBeNull() + expect(response?.statusCode).toBe(StatusCodes.NO_CONTENT) + expect(response?.body).toBe('') + expect(await storedIdentity('someone@mailinator.com')).toBeNull() + expect(await storedOtpRow('someone@mailinator.com')).toBeNull() + }) + + it('issues a code without asking zerobounce when no api key is set', async () => { + const answer = vi.spyOn(safeHttp.axios, 'get') + + const statusCode = await requestCode('someone@mailinator.com') + + expect(statusCode).toBe(StatusCodes.NO_CONTENT) + expect(answer).not.toHaveBeenCalled() + answer.mockRestore() }) - it('lets an invited member through even on a throwaway domain', async () => { + it('lets an invited member through even on a refused domain', async () => { const invited = 'guest@mailinator.com' await databaseConnection().getRepository('user_invitation').save({ id: apId(), @@ -133,13 +159,19 @@ describe('Passwordless Authentication API', () => { platformRole: PlatformRole.MEMBER, }) - const response = await app?.inject({ - method: 'POST', - url: '/api/v1/authentication/otp/request', - body: { email: invited }, + const response = await withZerobounceVerdict({ + status: 'do_not_mail', + subStatus: 'disposable', + run: async () => app?.inject({ + method: 'POST', + url: '/api/v1/authentication/otp/request', + body: { email: invited }, + }), }) expect(response?.statusCode).toBe(StatusCodes.NO_CONTENT) + expect(await storedIdentity(invited)).not.toBeNull() + expect(await storedOtpRow(invited)).not.toBeNull() }) it('issues a code with no captcha token when no challenge is configured', async () => { @@ -444,3 +476,9 @@ describe('Passwordless Authentication API', () => { }) }) }) + +type WithZerobounceVerdictParams = { + status: string + subStatus: string + run: () => Promise +} diff --git a/packages/server/api/test/integration/cloud/authn/cloud-authn.test.ts b/packages/server/api/test/integration/cloud/authn/cloud-authn.test.ts index 2b2f9aaacebb..bc5e319edd77 100644 --- a/packages/server/api/test/integration/cloud/authn/cloud-authn.test.ts +++ b/packages/server/api/test/integration/cloud/authn/cloud-authn.test.ts @@ -1,4 +1,5 @@ import { ProjectRole } from '@activepieces/core-utils' +import { safeHttp } from '@activepieces/server-utils' import { ApEdition, DefaultProjectRole, InvitationStatus, InvitationType, OtpType, PlatformRole, ProjectType, UserStatus } from '@activepieces/shared' import { faker } from '@faker-js/faker' import dayjs from 'dayjs' @@ -61,6 +62,38 @@ beforeEach(async () => { }) describe('Authentication API', () => { describe('Sign up Endpoint', () => { + it('answers a zerobounce-refused address exactly like an unverified sign-up, and creates nothing', async () => { + await mockAndSaveBasicSetup({ + platform: { id: CLOUD_PLATFORM_ID, emailAuthEnabled: true }, + plan: { ssoEnabled: false }, + }) + const mockSignUpRequest = createMockSignUpRequest() + const email = mockSignUpRequest.email.toLocaleLowerCase().trim() + const answer = vi.spyOn(safeHttp.axios, 'get') + .mockResolvedValue({ data: { status: 'do_not_mail', sub_status: 'disposable' } }) + process.env.AP_ZEROBOUNCE_API_KEY = 'test-api-key' + + try { + const response = await app?.inject({ + method: 'POST', + url: '/api/v1/authentication/sign-up', + body: mockSignUpRequest, + }) + + expect(response?.statusCode).toBe(StatusCodes.FORBIDDEN) + expect(response?.json()).toEqual({ + code: 'EMAIL_IS_NOT_VERIFIED', + params: { email }, + }) + const identity = await databaseConnection().getRepository('user_identity').findOneBy({ email }) + expect(identity).toBeNull() + } + finally { + delete process.env.AP_ZEROBOUNCE_API_KEY + answer.mockRestore() + } + }) + it('Create new user for the cloud user and then ask to verify email if email is not verified', async () => { const edition = system.getEdition() await mockAndSaveBasicSetup({ diff --git a/packages/server/api/test/integration/cloud/platform/platform.test.ts b/packages/server/api/test/integration/cloud/platform/platform.test.ts index 8f0b0039092c..9ae8b51e6a0f 100644 --- a/packages/server/api/test/integration/cloud/platform/platform.test.ts +++ b/packages/server/api/test/integration/cloud/platform/platform.test.ts @@ -613,6 +613,7 @@ describe('Platform API', () => { expect(responseBody.logoIconUrl).toBe(mockPlatform.logoIconUrl) expect(responseBody.fullLogoUrl).toBe(mockPlatform.fullLogoUrl) expect(responseBody.favIconUrl).toBe(mockPlatform.favIconUrl) + expect(responseBody.autoCreatePersonalProjects).toBe(mockPlatform.autoCreatePersonalProjects) }) diff --git a/packages/server/api/test/unit/app/authentication/disposable-email.test.ts b/packages/server/api/test/unit/app/authentication/disposable-email.test.ts deleted file mode 100644 index 19f70fe26291..000000000000 --- a/packages/server/api/test/unit/app/authentication/disposable-email.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { disposableEmail } from '../../../../src/app/authentication/lib/disposable-email' - -describe('disposableEmail', () => { - describe('isDisposable', () => { - it.each([ - 'someone@mailinator.com', - 'someone@guerrillamail.com', - 'someone@10minutemail.com', - ])('rejects the throwaway provider in %s', (email) => { - expect(disposableEmail.isDisposable(email)).toBe(true) - }) - - it.each([ - 'ahmad@activepieces.com', - 'someone@gmail.com', - 'someone@outlook.com', - 'someone@googlemail.com', - ])('accepts the real provider in %s', (email) => { - expect(disposableEmail.isDisposable(email)).toBe(false) - }) - - it('matches a subdomain of a wildcard provider', () => { - const wildcardHit = disposableEmail.isDisposable('someone@mail.mailinator.com') - const unrelated = disposableEmail.isDisposable('someone@mailinator.com.activepieces.com') - - expect(wildcardHit).toBe(true) - expect(unrelated).toBe(false) - }) - - it('ignores case and surrounding whitespace in the domain', () => { - expect(disposableEmail.isDisposable('Someone@MAILINATOR.com ')).toBe(true) - }) - - it('treats an address with no domain as acceptable, leaving that to schema validation', () => { - expect(disposableEmail.isDisposable('not-an-email')).toBe(false) - }) - }) -}) diff --git a/packages/server/api/test/unit/app/authentication/zerobounce.test.ts b/packages/server/api/test/unit/app/authentication/zerobounce.test.ts new file mode 100644 index 000000000000..2c12f7f2cd80 --- /dev/null +++ b/packages/server/api/test/unit/app/authentication/zerobounce.test.ts @@ -0,0 +1,232 @@ +import { safeHttp } from '@activepieces/server-utils' +import { FastifyBaseLogger } from 'fastify' +import { zerobounce } from '../../../../src/app/authentication/lib/zerobounce' + +const mockStoreGet = vi.fn() +const mockStorePut = vi.fn() +const mockHasAcceptedInvitation = vi.fn() + +vi.mock('../../../../src/app/database/redis-connections', () => ({ + distributedStore: { + get: (...args: unknown[]) => mockStoreGet(...args), + put: (...args: unknown[]) => mockStorePut(...args), + }, +})) + +vi.mock('../../../../src/app/user-invitations/user-invitation.service', () => ({ + userInvitationsService: () => ({ + hasAnyAcceptedInvitationsForEmail: (...args: unknown[]) => mockHasAcceptedInvitation(...args), + }), +})) + +const log = { warn: vi.fn(), info: vi.fn(), error: vi.fn() } as unknown as FastifyBaseLogger + +function configure(apiKey?: string): void { + if (apiKey === undefined) { + delete process.env.AP_ZEROBOUNCE_API_KEY + } + else { + process.env.AP_ZEROBOUNCE_API_KEY = apiKey + } +} + +function answers(verdict: { status: string, sub_status?: string, error?: string }) { + return vi.spyOn(safeHttp.axios, 'get').mockResolvedValue({ data: verdict }) +} + +async function maySignUp(email = 'someone@example.com'): Promise { + return zerobounce.maySignUp({ email, log }) +} + +beforeEach(() => { + vi.restoreAllMocks() + mockStoreGet.mockReset().mockResolvedValue(null) + mockStorePut.mockReset().mockResolvedValue(undefined) + mockHasAcceptedInvitation.mockReset().mockResolvedValue(false) + configure('api-key') +}) + +afterAll(() => { + configure() +}) + +describe('zerobounce', () => { + describe('maySignUp', () => { + it('asks nothing and refuses nothing when no api key is set, so a self-hosted instance needs no account', async () => { + configure() + const get = vi.spyOn(safeHttp.axios, 'get') + + expect(await maySignUp('someone@mailinator.com')).toBe(true) + expect(get).not.toHaveBeenCalled() + expect(mockStoreGet).not.toHaveBeenCalled() + }) + + it('treats a blank api key as unset, so an empty env line cannot spend credits', async () => { + configure(' ') + const get = vi.spyOn(safeHttp.axios, 'get') + + await maySignUp('someone@mailinator.com') + + expect(get).not.toHaveBeenCalled() + }) + + it.each([ + { status: 'do_not_mail', sub_status: 'disposable' }, + { status: 'do_not_mail', sub_status: 'toxic' }, + { status: 'do_not_mail', sub_status: 'possible_trap' }, + { status: 'do_not_mail', sub_status: 'global_suppression' }, + { status: 'spamtrap', sub_status: '' }, + { status: 'abuse', sub_status: '' }, + ])('refuses $status/$sub_status', async (verdict) => { + answers(verdict) + expect(await maySignUp()).toBe(false) + }) + + it.each([ + { status: 'valid', sub_status: '' }, + { status: 'catch-all', sub_status: '' }, + { status: 'unknown', sub_status: 'greylisted' }, + { status: 'invalid', sub_status: 'mailbox_not_found' }, + { status: 'invalid', sub_status: 'possible_typo' }, + { status: 'do_not_mail', sub_status: 'role_based' }, + { status: 'do_not_mail', sub_status: 'role_based_catch_all' }, + { status: 'do_not_mail', sub_status: 'mx_forward' }, + ])('lets $status/$sub_status through', async (verdict) => { + answers(verdict) + expect(await maySignUp()).toBe(true) + }) + + it('matches the verdict regardless of the case zerobounce answers in', async () => { + answers({ status: 'DO_NOT_MAIL', sub_status: 'Disposable' }) + expect(await maySignUp()).toBe(false) + }) + + it('lets a refused address through when it holds an accepted invitation', async () => { + answers({ status: 'do_not_mail', sub_status: 'disposable' }) + mockHasAcceptedInvitation.mockResolvedValue(true) + + expect(await maySignUp('guest@mailinator.com')).toBe(true) + }) + + it('lets the address through when zerobounce is unreachable, rather than taking sign-up down', async () => { + vi.spyOn(safeHttp.axios, 'get').mockRejectedValue(new Error('ETIMEDOUT')) + + expect(await maySignUp('someone@mailinator.com')).toBe(true) + }) + + it('lets the address through when the key is rejected or the credits are gone, which answers 200 with an error body', async () => { + answers({ status: '', error: 'Invalid API Key or your account ran out of credits' }) + + expect(await maySignUp('someone@mailinator.com')).toBe(true) + }) + + it('lets the address through when the answer carries no verdict at all', async () => { + vi.spyOn(safeHttp.axios, 'get').mockResolvedValue({ data: {} }) + + expect(await maySignUp('someone@mailinator.com')).toBe(true) + }) + }) + + describe('disposable-domain cache', () => { + it('appends a disposable domain to the list under a versioned key', async () => { + answers({ status: 'do_not_mail', sub_status: 'disposable' }) + mockStoreGet.mockResolvedValue(['already-known.com']) + + await maySignUp('first@mailinator.com') + + expect(mockStorePut).toHaveBeenCalledWith( + 'zerobounce:disposable-domains:v1', + ['already-known.com', 'mailinator.com'], + ) + }) + + it('drops the oldest entry once the list is full, keeping it at 500', async () => { + const full = Array.from({ length: 500 }, (_unused, index) => `domain-${index}.com`) + answers({ status: 'do_not_mail', sub_status: 'disposable' }) + mockStoreGet.mockResolvedValue(full) + + await maySignUp('first@mailinator.com') + + const stored = mockStorePut.mock.calls[0][1] as string[] + expect(stored).toHaveLength(500) + expect(stored).not.toContain('domain-0.com') + expect(stored[0]).toBe('domain-1.com') + expect(stored[499]).toBe('mailinator.com') + }) + + it('does not rewrite the list for a domain it already holds', async () => { + answers({ status: 'do_not_mail', sub_status: 'disposable' }) + mockStoreGet.mockResolvedValue(['mailinator.com']) + + expect(await maySignUp('someone@mailinator.com')).toBe(false) + expect(mockStorePut).not.toHaveBeenCalled() + }) + + it('refuses a known disposable domain without spending a credit', async () => { + const get = vi.spyOn(safeHttp.axios, 'get') + mockStoreGet.mockResolvedValue(['mailinator.com']) + + expect(await maySignUp('anyone@mailinator.com')).toBe(false) + expect(get).not.toHaveBeenCalled() + }) + + it('still honours the invitation carve-out on a cached refusal', async () => { + mockStoreGet.mockResolvedValue(['mailinator.com']) + mockHasAcceptedInvitation.mockResolvedValue(true) + + expect(await maySignUp('guest@mailinator.com')).toBe(true) + }) + + it('asks zerobounce when the stored value is not a list', async () => { + mockStoreGet.mockResolvedValue('not-a-list') + const get = answers({ status: 'valid', sub_status: '' }) + + expect(await maySignUp('someone@gmail.com')).toBe(true) + expect(get).toHaveBeenCalled() + }) + + it.each([ + { status: 'do_not_mail', sub_status: 'toxic' }, + { status: 'do_not_mail', sub_status: 'global_suppression' }, + { status: 'spamtrap', sub_status: '' }, + { status: 'abuse', sub_status: '' }, + ])('never caches the address-level verdict $status/$sub_status by domain', async (verdict) => { + answers(verdict) + + await maySignUp('one-bad-mailbox@gmail.com') + + expect(mockStorePut).not.toHaveBeenCalled() + }) + + it('does not cache an allow verdict, so an address-level refusal is never skipped', async () => { + answers({ status: 'valid', sub_status: '' }) + + await maySignUp('someone@gmail.com') + + expect(mockStorePut).not.toHaveBeenCalled() + }) + + it('asks zerobounce when the cache read fails, rather than refusing or crashing', async () => { + mockStoreGet.mockRejectedValue(new Error('ECONNREFUSED')) + answers({ status: 'valid', sub_status: '' }) + + expect(await maySignUp('someone@gmail.com')).toBe(true) + }) + + it('still refuses when the verdict cannot be cached', async () => { + answers({ status: 'do_not_mail', sub_status: 'disposable' }) + mockStorePut.mockRejectedValue(new Error('ECONNREFUSED')) + + expect(await maySignUp('someone@mailinator.com')).toBe(false) + }) + + it('does not touch the cache for an address with no domain', async () => { + answers({ status: 'valid', sub_status: '' }) + + await maySignUp('not-an-email') + + expect(mockStoreGet).not.toHaveBeenCalled() + expect(mockStorePut).not.toHaveBeenCalled() + }) + }) +}) diff --git a/packages/server/api/test/unit/app/ee/agent/chat-usage-tracker.test.ts b/packages/server/api/test/unit/app/ee/agent/chat-usage-tracker.test.ts index 0d9f23ffbe97..3114b1c930f5 100644 --- a/packages/server/api/test/unit/app/ee/agent/chat-usage-tracker.test.ts +++ b/packages/server/api/test/unit/app/ee/agent/chat-usage-tracker.test.ts @@ -31,6 +31,8 @@ vi.mock('../../../../../src/app/ee/agent/agent-helpers', () => ({ resolveChatProviderName: vi.fn().mockResolvedValue(AIProviderName.ACTIVEPIECES), resolveModelIdForAnalytics: vi.fn().mockReturnValue('model-x'), resolveTier: vi.fn().mockReturnValue({ id: 'tier-1', creditWeight: 5 }), + providerScopeFor: ({ projectId }: { projectId: string | null }) => + projectId === null ? { type: 'platform' } : { type: 'project', projectId }, }, })) diff --git a/packages/server/api/vitest.config.ts b/packages/server/api/vitest.config.ts index e916664a8e1e..363c02bfaaa0 100644 --- a/packages/server/api/vitest.config.ts +++ b/packages/server/api/vitest.config.ts @@ -10,7 +10,10 @@ export default defineConfig({ globals: true, environment: 'node', testTimeout: 60000, - hookTimeout: 60000, + // Every integration file boots a server in beforeAll — seconds locally, but the suites boot in + // parallel forks and a loaded CI runner has pushed that past a minute. The timeout is here to + // catch a hang, and two minutes still catches one. + hookTimeout: 120000, pool: 'forks', setupFiles: [path.resolve(__dirname, 'vitest.setup.ts')], include: [path.resolve(__dirname, 'test/**/*.test.ts')], diff --git a/packages/server/utils/package.json b/packages/server/utils/package.json index 3e01a805c587..6501f95f8019 100644 --- a/packages/server/utils/package.json +++ b/packages/server/utils/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/server-utils", - "version": "0.2.0", + "version": "0.3.0", "type": "commonjs", "main": "./dist/src/index.js", "typings": "./dist/src/index.d.ts", @@ -31,6 +31,7 @@ "request-filtering-agent": "3.2.0", "systeminformation": "5.31.7", "tslib": "2.6.2", + "zod": "4.3.6", "@activepieces/core-utils": "workspace:*", "@activepieces/core-formula": "workspace:*" }, diff --git a/packages/server/utils/src/agent-ai-utils.ts b/packages/server/utils/src/agent-ai-utils.ts index ffb66205bb32..faf6787df8c3 100644 --- a/packages/server/utils/src/agent-ai-utils.ts +++ b/packages/server/utils/src/agent-ai-utils.ts @@ -1,4 +1,4 @@ -import { AIProviderName, isNil, spreadIfDefined } from '@activepieces/core-utils'; +import { AIProviderName, isNil, observedProviderFetch, ProviderOutcomeReporter, spreadIfDefined } from '@activepieces/core-utils'; import { createLanguageModel } from '@activepieces/ai-providers'; import { AI_PROVIDER_CAPABILITIES, BaseAIProviderAuthConfig, agentPersistenceUtils, agentToolClassification, CloudflareGatewayProviderConfig, PersistedAgentPart, PersistedAgentPartType, PersistedToolCallStatus, splitCloudflareGatewayModelId } from '@activepieces/shared'; import { createAnthropic } from '@ai-sdk/anthropic' @@ -56,13 +56,14 @@ function openRouterModelSettings(provider: AIProviderName, webSearchEnabled: boo return { plugins: [{ id: 'web', max_results: MAX_WEB_SEARCH_RESULTS }] } } -function createChatModel({ provider, auth, config, modelId, metadata, webSearchEnabled = false }: { +function createChatModel({ provider, auth, config, modelId, metadata, webSearchEnabled = false, onOutcome }: { provider: AIProviderName auth: Record config: Record modelId: string metadata?: ChatModelMetadata webSearchEnabled?: boolean + onOutcome?: ProviderOutcomeReporter }): LanguageModel { if (provider === AIProviderName.CLOUDFLARE_GATEWAY) { const { apiKey } = auth as BaseAIProviderAuthConfig @@ -72,6 +73,7 @@ function createChatModel({ provider, auth, config, modelId, metadata, webSearchE name: 'cloudflare', baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gatewayId}/compat`, headers: { 'cf-aig-authorization': `Bearer ${apiKey}` }, + ...spreadIfDefined('fetch', observedProviderFetch(onOutcome)), }).chatModel(actualModelId) } return createLanguageModel({ @@ -83,6 +85,7 @@ function createChatModel({ provider, auth, config, modelId, metadata, webSearchE openRouterSettings: openRouterModelSettings(provider, webSearchEnabled), mistralViaOpenRouter: true, ...spreadIfDefined('extraHeaders', managedProviderMetadataHeaders({ provider, metadata })), + ...spreadIfDefined('onOutcome', onOutcome), }, }) } @@ -101,32 +104,34 @@ function toStorageEmbedding(embedding: number[]): number[] { return magnitude === 0 ? truncated : truncated.map((value) => value / magnitude) } -function createEmbeddingModel({ provider, auth, config }: { +function createEmbeddingModel({ provider, auth, config, onOutcome }: { provider: AIProviderName auth: Record config: Record + onOutcome?: ProviderOutcomeReporter }): { model: EmbeddingModel, providerOptions: SharedV3ProviderOptions } { const embeddingModelId = AI_PROVIDER_CAPABILITIES[provider].defaultEmbeddingModel if (isNil(embeddingModelId)) { throw new Error(`Provider ${provider} does not support knowledge base search`) } const apiKey = readStringField(auth, 'apiKey') + const fetch = observedProviderFetch(onOutcome) switch (provider) { case AIProviderName.OPENAI: - return { model: createOpenAI({ apiKey }).embeddingModel(embeddingModelId), providerOptions: OPENAI_EMBEDDING_PROVIDER_OPTIONS } + return { model: createOpenAI({ apiKey, ...spreadIfDefined('fetch', fetch) }).embeddingModel(embeddingModelId), providerOptions: OPENAI_EMBEDDING_PROVIDER_OPTIONS } case AIProviderName.GOOGLE: - return { model: createGoogleGenerativeAI({ apiKey }).textEmbeddingModel(embeddingModelId), providerOptions: {} } + return { model: createGoogleGenerativeAI({ apiKey, ...spreadIfDefined('fetch', fetch) }).textEmbeddingModel(embeddingModelId), providerOptions: {} } case AIProviderName.AZURE: { const resourceName = readStringField(config, 'resourceName') const apiVersion = readStringField(config, 'apiVersion') return { - model: createAzure({ resourceName, apiKey, ...spreadIfDefined('apiVersion', apiVersion || undefined) }).embeddingModel(embeddingModelId), + model: createAzure({ resourceName, apiKey, ...spreadIfDefined('apiVersion', apiVersion || undefined), ...spreadIfDefined('fetch', fetch) }).embeddingModel(embeddingModelId), providerOptions: OPENAI_EMBEDDING_PROVIDER_OPTIONS, } } case AIProviderName.ACTIVEPIECES: case AIProviderName.OPENROUTER: - return { model: createOpenRouter({ apiKey }).textEmbeddingModel(embeddingModelId), providerOptions: OPENROUTER_EMBEDDING_PROVIDER_OPTIONS } + return { model: createOpenRouter({ apiKey, ...spreadIfDefined('fetch', fetch) }).textEmbeddingModel(embeddingModelId), providerOptions: OPENROUTER_EMBEDDING_PROVIDER_OPTIONS } default: throw new Error(`Provider ${provider} does not support knowledge base search`) } diff --git a/packages/server/utils/src/index.ts b/packages/server/utils/src/index.ts index 29b6d136e8ab..e455f3de31d0 100644 --- a/packages/server/utils/src/index.ts +++ b/packages/server/utils/src/index.ts @@ -21,6 +21,7 @@ export { loggerRedact } from './logger-redact' export type { RedactConfig } from './logger-redact' export { memoryLock } from './memory-lock' export type { ApLock } from './memory-lock' +export { modelCatalog } from './model-catalog' export { RedisType } from './redis-type' export { safeHttp } from './safe-http' export type { SsrfAgents } from './safe-http' diff --git a/packages/server/utils/src/model-catalog.ts b/packages/server/utils/src/model-catalog.ts new file mode 100644 index 000000000000..205ba54c421a --- /dev/null +++ b/packages/server/utils/src/model-catalog.ts @@ -0,0 +1,100 @@ +import { AIProviderName, isNil, tryCatch } from '@activepieces/core-utils' +import { AIProviderModelMetadata } from '@activepieces/shared' +import { z } from 'zod' +import { apLogger } from './ap-logger' +import { safeHttp } from './safe-http' + +const logger = apLogger.create() + +export const modelCatalog = { + async load(): Promise { + const catalog = await loadCatalog() + return { + lookup({ provider, modelId }: { provider: AIProviderName, modelId: string }): AIProviderModelMetadata | undefined { + const source = CATALOG_SOURCE_PROVIDER[provider] ?? provider + const models = catalog?.providers[source] + if (isNil(models)) { + return undefined + } + return models[normalizeModelId({ provider: source, modelId })] + }, + } + }, +} + +async function loadCatalog(): Promise { + if (!isNil(cached) && Date.now() - cached.fetchedAt < CATALOG_TTL_MS) { + return cached.value + } + if (!isNil(lastFailureAt) && Date.now() - lastFailureAt < FAILURE_BACKOFF_MS) { + return cached?.value + } + + const pending = inFlight ?? startFetch() + const { data, error } = await tryCatch(() => pending) + if (!isNil(error)) { + return cached?.value + } + return data ?? undefined +} + +function startFetch(): Promise { + inFlight = fetchCatalog() + .then((value) => { + cached = { value, fetchedAt: Date.now() } + lastFailureAt = undefined + return value + }) + .catch((error) => { + lastFailureAt = Date.now() + logger.warn({ error, catalog: { url: catalogUrl() } }, 'Failed to load the AI model catalog; models will be returned without metadata') + throw error + }) + .finally(() => { + inFlight = undefined + }) + return inFlight +} + +async function fetchCatalog(): Promise { + const response = await safeHttp.retryingAxios.get(catalogUrl(), { + timeout: REQUEST_TIMEOUT_MS, + }) + return PublishedCatalog.parse(response.data) +} + +function catalogUrl(): string { + return process.env['AP_MODEL_CATALOG_URL'] ?? DEFAULT_CATALOG_URL +} + +function normalizeModelId({ provider, modelId }: { provider: AIProviderName, modelId: string }): string { + if (provider !== AIProviderName.BEDROCK) { + return modelId + } + return modelId.replace(BEDROCK_INFERENCE_PROFILE_PREFIX, '') +} + +let cached: { value: PublishedCatalog, fetchedAt: number } | undefined +let inFlight: Promise | undefined +let lastFailureAt: number | undefined + +const DEFAULT_CATALOG_URL = 'https://cdn.activepieces.com/ai/model-catalog.json' +const CATALOG_TTL_MS = 24 * 60 * 60 * 1000 +const FAILURE_BACKOFF_MS = 5 * 60 * 1000 +const REQUEST_TIMEOUT_MS = 10_000 + +const BEDROCK_INFERENCE_PROFILE_PREFIX = /^(us|eu|apac|global)\./ + +const CATALOG_SOURCE_PROVIDER: Partial> = { + [AIProviderName.ACTIVEPIECES]: AIProviderName.OPENROUTER, +} + +const PublishedCatalog = z.object({ + providers: z.partialRecord(z.enum(AIProviderName), z.record(z.string(), AIProviderModelMetadata)), +}) + +type PublishedCatalog = z.infer + +type ModelCatalogReader = { + lookup(params: { provider: AIProviderName, modelId: string }): AIProviderModelMetadata | undefined +} diff --git a/packages/server/utils/test/model-catalog.test.ts b/packages/server/utils/test/model-catalog.test.ts new file mode 100644 index 000000000000..41ebc07470fa --- /dev/null +++ b/packages/server/utils/test/model-catalog.test.ts @@ -0,0 +1,110 @@ +import { AIProviderName } from '@activepieces/core-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const get = vi.fn() + +vi.mock('../src/safe-http', () => ({ + safeHttp: { + get retryingAxios() { + return { get } + }, + }, +})) + +const CATALOG = { + notice: 'Model data from models.dev', + generatedAt: '2026-08-27T00:00:00.000Z', + providers: { + [AIProviderName.OPENAI]: { + 'gpt-5.5': { contextTokens: 1_050_000, outputCostPerMillionTokens: 30 }, + }, + [AIProviderName.OPENROUTER]: { + 'anthropic/claude-sonnet-5': { contextTokens: 1_000_000, outputCostPerMillionTokens: 10 }, + }, + [AIProviderName.BEDROCK]: { + 'anthropic.claude-fable-5:0': { contextTokens: 1_000_000 }, + }, + }, +} + +async function freshCatalog(): Promise { + vi.resetModules() + const { modelCatalog } = await import('../src/model-catalog') + return modelCatalog +} + +async function lookup(provider: AIProviderName, modelId: string): Promise { + const modelCatalog = await freshCatalog() + const catalog = await modelCatalog.load() + return catalog.lookup({ provider, modelId }) +} + +describe('modelCatalog.lookup', () => { + beforeEach(() => { + get.mockReset() + get.mockResolvedValue({ data: CATALOG }) + }) + + afterEach(() => { + vi.useRealTimers() + delete process.env['AP_MODEL_CATALOG_URL'] + }) + + it('returns metadata for a native model id', async () => { + await expect(lookup(AIProviderName.OPENAI, 'gpt-5.5')) + .resolves.toEqual(CATALOG.providers[AIProviderName.OPENAI]['gpt-5.5']) + }) + + it('resolves activepieces against the openrouter block', async () => { + await expect(lookup(AIProviderName.ACTIVEPIECES, 'anthropic/claude-sonnet-5')) + .resolves.toEqual(CATALOG.providers[AIProviderName.OPENROUTER]['anthropic/claude-sonnet-5']) + }) + + it.each(['us', 'eu', 'apac', 'global'])('strips the bedrock %s inference-profile prefix', async (region) => { + await expect(lookup(AIProviderName.BEDROCK, `${region}.anthropic.claude-fable-5:0`)) + .resolves.toEqual(CATALOG.providers[AIProviderName.BEDROCK]['anthropic.claude-fable-5:0']) + }) + + it.each([ + [AIProviderName.AZURE, 'my-gpt5-deployment'], + [AIProviderName.CUSTOM, 'some-self-hosted-model'], + [AIProviderName.CLOUDFLARE_GATEWAY, 'openai/gpt-4'], + [AIProviderName.MISTRAL, 'gpt-5.5'], + ])('returns undefined rather than throwing for %s / %s', async (provider, modelId) => { + await expect(lookup(provider, modelId)).resolves.toBeUndefined() + }) + + it('fetches once for concurrent lookups', async () => { + const modelCatalog = await freshCatalog() + await Promise.all(Array.from({ length: 25 }, () => modelCatalog.load())) + expect(get).toHaveBeenCalledTimes(1) + }) + + it('reuses the cached catalog across later lookups', async () => { + const modelCatalog = await freshCatalog() + await modelCatalog.load() + await modelCatalog.load() + expect(get).toHaveBeenCalledTimes(1) + }) + + it('returns undefined and does not throw when the catalog is unreachable', async () => { + get.mockRejectedValue(new Error('ENOTFOUND cdn.activepieces.com')) + await expect(lookup(AIProviderName.OPENAI, 'gpt-5.5')).resolves.toBeUndefined() + }) + + it('backs off after a failure instead of refetching on every lookup', async () => { + get.mockRejectedValue(new Error('ENOTFOUND cdn.activepieces.com')) + const modelCatalog = await freshCatalog() + await modelCatalog.load() + await modelCatalog.load() + await modelCatalog.load() + expect(get).toHaveBeenCalledTimes(1) + }) + + it('reads AP_MODEL_CATALOG_URL when set', async () => { + process.env['AP_MODEL_CATALOG_URL'] = 'https://mirror.internal/model-catalog.json' + const modelCatalog = await freshCatalog() + await modelCatalog.load() + expect(get).toHaveBeenCalledWith('https://mirror.internal/model-catalog.json', expect.anything()) + }) +}) diff --git a/packages/web/public/locales/en/translation.json b/packages/web/public/locales/en/translation.json index 84401072a36f..556669d9bf1f 100644 --- a/packages/web/public/locales/en/translation.json +++ b/packages/web/public/locales/en/translation.json @@ -2516,6 +2516,10 @@ "Resets in {days, plural, =1 {# day} other {# days}}": "Resets in {days, plural, =1 {# day} other {# days}}", "Show all projects": "Show all projects", "Sort pinned projects": "Sort pinned projects", + "Key rejected": "Key rejected", + "Unreachable": "Unreachable", + "Recheck": "Recheck", + "Last checked {when}": "Last checked {when}", "Flow pieces upgraded": "Flow pieces upgraded", "kept at {version}": "kept at {version}", "Flow pieces reverted": "Flow pieces reverted", diff --git a/packages/web/src/app/components/primary-rail/index.tsx b/packages/web/src/app/components/primary-rail/index.tsx index 417b8bd7f16c..24cdb2e73b6e 100644 --- a/packages/web/src/app/components/primary-rail/index.tsx +++ b/packages/web/src/app/components/primary-rail/index.tsx @@ -25,6 +25,7 @@ import { SquarePen, UserCogIcon, } from 'lucide-react'; +import { motion, useReducedMotion } from 'motion/react'; import { ComponentType, useState } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; @@ -66,10 +67,7 @@ import { authenticationSession } from '@/lib/authentication-session'; import { cn } from '@/lib/utils'; import AccountSettingsDialog from '../account-settings'; -import { - getAccessHistory, - recordAccess, -} from '../global-search/access-history'; +import { recordAccess } from '../global-search/access-history'; import { useGlobalSearch } from '../global-search/global-search-context'; import { HelpAndFeedback } from '../help-and-feedback'; @@ -104,51 +102,58 @@ export function PrimaryRail() {
- {platform.plan.chatEnabled && ( +
+ {platform.plan.chatEnabled && ( + pathname.startsWith('/chat')} + onClick={() => + window.dispatchEvent(new Event(chatUtils.newChatEvent)) + } + /> + )} + {showAgents && ( + pathname.startsWith('/agents')} + /> + )} pathname.startsWith('/chat')} + to="/templates" + icon={Compass} + label={t('Explore')} + isActive={({ pathname }) => pathname.startsWith('/templates')} onClick={() => - window.dispatchEvent(new Event(chatUtils.newChatEvent)) + templatesTelemetryApi.sendEvent({ + eventType: TemplateTelemetryEventType.EXPLORE_VIEW, + userId: currentUser?.id, + }) } /> - )} - {showAgents && ( pathname.startsWith('/agents')} + to="/impact" + icon={ChartLine} + label={t('Impact')} + isActive={({ pathname }) => pathname.startsWith('/impact')} /> - )} - pathname.startsWith('/templates')} - onClick={() => - templatesTelemetryApi.sendEvent({ - eventType: TemplateTelemetryEventType.EXPLORE_VIEW, - userId: currentUser?.id, - }) - } - /> - pathname.startsWith('/impact')} - /> +
@@ -303,7 +308,13 @@ function RailPlatformAdminButton({ collapsed }: { collapsed: boolean }) { } return ( -
+
+
{!collapsed && ( -
+
{t('Projects')} @@ -441,15 +454,22 @@ function RailPinnedProjects({ collapsed }: { collapsed: boolean }) {
)} - {ordered.map((project) => ( - - ))} +
+ {ordered.map((project) => ( + + ))} +
); } @@ -465,6 +485,7 @@ function ProjectRow({ active: boolean; onOpen: (params: { projectId: string; name: string }) => void; }) { + const prefersReducedMotion = useReducedMotion(); const name = getProjectName(project); const isTeam = project.type === ProjectType.TEAM; const palette = @@ -488,7 +509,9 @@ function ProjectRow({ ); const row = ( - + ); if (!collapsed) { @@ -580,27 +603,15 @@ function readStoredSort(stored: string | null): PinnedSort { return PINNED_SORTS.find((sort) => sort === stored) ?? 'added'; } -function lastUsedByProject(): Record { - return getAccessHistory().reduce>((acc, item) => { - const projectId = /^\/projects\/([^/]+)/.exec(item.href)?.[1]; - if (isNil(projectId)) { - return acc; - } - const seen = acc[projectId]; - if (!isNil(seen) && seen >= item.accessedAt) { - return acc; - } - return { ...acc, [projectId]: item.accessedAt }; - }, {}); +function lastFlowUpdatedAt(project: ProjectWithLimits): number { + const lastFlowUpdated = project.analytics.lastFlowUpdated; + if (isNil(lastFlowUpdated)) { + return 0; + } + return new Date(lastFlowUpdated).getTime(); } -function compareProjects({ - sort, - lastUsed, -}: { - sort: PinnedSort; - lastUsed: Record; -}) { +function compareProjects({ sort }: { sort: PinnedSort }) { return (a: ProjectWithLimits, b: ProjectWithLimits): number => { if (sort === 'alphabetical') { return getProjectName(a).localeCompare(getProjectName(b)); @@ -608,10 +619,10 @@ function compareProjects({ if (sort === 'added') { return new Date(b.created).getTime() - new Date(a.created).getTime(); } - const usedA = lastUsed[a.id] ?? 0; - const usedB = lastUsed[b.id] ?? 0; - if (usedA !== usedB) { - return usedB - usedA; + const flowA = lastFlowUpdatedAt(a); + const flowB = lastFlowUpdatedAt(b); + if (flowA !== flowB) { + return flowB - flowA; } return new Date(b.updated).getTime() - new Date(a.updated).getTime(); }; @@ -619,14 +630,12 @@ function compareProjects({ function orderProjects({ projects, - lastUsed, sort, }: { projects: ProjectWithLimits[]; - lastUsed: Record; sort: PinnedSort; }): ProjectWithLimits[] { - const compare = compareProjects({ sort, lastUsed }); + const compare = compareProjects({ sort }); const personal = projects.filter( (project) => project.type !== ProjectType.TEAM, ); 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 index 85d6beba8fec..6772224c51c0 100644 --- 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 @@ -11,7 +11,7 @@ import { } from '@activepieces/shared'; import { useQuery } from '@tanstack/react-query'; import { t } from 'i18next'; -import { ChevronLeft, KeyRound, Trash2 } from 'lucide-react'; +import { Activity, ChevronLeft, KeyRound, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { z } from 'zod'; @@ -28,9 +28,11 @@ import { } from '@/components/ui/select'; import { AiProviderInfo } from '@/features/agents'; import { aiProviderApi, aiProviderKeys } from '@/features/platform-admin'; +import { formatUtils } from '@/lib/format-utils'; import { SectionHeader } from '../components/section-header'; +import { KeyStatusBadge } from './key-status'; import { ManualModelList } from './manual-model-list'; import { ModelSelectionPanel } from './model-selection-panel'; import { ProjectSelectionPanel } from './project-selection-panel'; @@ -45,6 +47,8 @@ export function ConfigDetail({ onSave, onDelete, onReplaceCredentials, + isRechecking, + onRecheck, onBack, }: { config: AIProviderWithoutSensitiveData; @@ -54,6 +58,8 @@ export function ConfigDetail({ onSave: (request: UpdateAIProviderRequest) => void; onDelete: () => void; onReplaceCredentials: () => void; + isRechecking: boolean; + onRecheck: () => void; onBack: () => void; }) { const [draft, setDraft] = useState(draftOf(config)); @@ -78,6 +84,15 @@ export function ConfigDetail({ })), ]; const dirty = JSON.stringify(draft) !== JSON.stringify(draftOf(config)); + const statusDetail = [ + config.statusReason, + config.statusUpdated && + t('Last checked {when}', { + when: formatUtils.formatDateTime(new Date(config.statusUpdated)), + }), + ] + .filter(Boolean) + .join(' · '); const nameMissing = draft.name.trim().length === 0; const enabledModelCount = !manualModels && draft.modelScope === 'all' @@ -145,6 +160,7 @@ export function ConfigDetail({
{info.name} +
@@ -191,6 +207,31 @@ export function ConfigDetail({ {t('Replace')}
+
+
+
+ +
+
+

+ {t('Status')} +

+ {statusDetail && ( +

+ {statusDetail} +

+ )} +
+
+ +
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 27219d53f367..79af658b97af 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 @@ -2,26 +2,13 @@ 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 { Bot, ChevronRight, MessageSquare, Plus, Trash2 } 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'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; import { Select, SelectContent, @@ -48,6 +35,7 @@ import { SectionHeader } from '../components/section-header'; import { ConfigDetail } from './config-detail'; import { ConnectProviderDialog } from './connect-provider-dialog'; +import { KeyStatusBadge } from './key-status'; import { ProjectSwatch } from './project-selection-panel'; import { ProviderLogo } from './provider-logo'; @@ -82,6 +70,10 @@ export function ProvidersTab() { aiProviderMutations.useDeleteAiProvider({ onSuccess: () => refetch(), }); + const { mutate: recheckProvider, isPending: isRechecking } = + aiProviderMutations.useRecheckAiProvider({ + onSuccess: () => refetch(), + }); const { mutate: updateProvider, isPending: isSaving } = aiProviderMutations.useUpdateAiProvider({ onSuccess: () => refetch(), @@ -163,6 +155,8 @@ export function ProvidersTab() { closeConfig(); }} onReplaceCredentials={() => openReplaceCredentials(activeConfig)} + isRechecking={isRechecking} + onRecheck={() => recheckProvider(activeConfig.id)} onBack={closeConfig} /> +
@@ -310,7 +309,7 @@ function ProviderGroup({ {t('Keys')}

-
+
{configs.map((config) => (
@@ -384,6 +384,7 @@ function ConfigRow({ {t('Chat')} )} +
@@ -412,27 +413,25 @@ function ConfigRow({ )} {allowWrite && ( -
event.stopPropagation()}> - - - - - - - - {t('Edit')} - - event.stopPropagation()} + > + + + + + {t('Delete')} + + void; }) { return ( -
+
@@ -615,7 +619,12 @@ function AvailableProviderCard({ onConnect: () => void; }) { return ( -
+

{info.name}

@@ -636,6 +645,9 @@ function AvailableProviderCard({ ); } +const CARD_SHADOW = + 'shadow-[2px_0px_4px_-2px_rgba(0,0,0,0.05),0px_2px_4px_-2px_rgba(0,0,0,0.05)]'; + function providerInfoOf({ provider, }: { diff --git a/packages/web/src/app/routes/platform/setup/ai/providers-tab/key-status.tsx b/packages/web/src/app/routes/platform/setup/ai/providers-tab/key-status.tsx new file mode 100644 index 000000000000..4f421881f37d --- /dev/null +++ b/packages/web/src/app/routes/platform/setup/ai/providers-tab/key-status.tsx @@ -0,0 +1,43 @@ +import { isNil } from '@activepieces/core-utils'; +import { AiProviderKeyStatus } from '@activepieces/shared'; +import { t } from 'i18next'; +import { Check, CloudOff, CreditCard, LucideIcon, X } from 'lucide-react'; + +import { StatusIconWithText } from '@/components/custom/status-icon-with-text'; + +export function KeyStatusBadge({ status }: { status: AiProviderKeyStatus }) { + const badge = badgeOf({ status }); + if (isNil(badge)) { + return null; + } + return ( + + ); +} + +function badgeOf({ status }: { status: AiProviderKeyStatus }): { + icon: LucideIcon; + text: string; + variant: 'success' | 'warning' | 'error' | 'secondary'; +} | null { + switch (status) { + case 'active': + return { icon: Check, text: t('Active'), variant: 'success' }; + case 'out_of_credits': + return { + icon: CreditCard, + text: t('Out of credits'), + variant: 'warning', + }; + case 'rejected': + return { icon: X, text: t('Key rejected'), variant: 'error' }; + case 'unreachable': + return { icon: CloudOff, text: t('Unreachable'), variant: 'secondary' }; + default: + return null; + } +} diff --git a/packages/web/src/components/custom/status-icon-with-text.tsx b/packages/web/src/components/custom/status-icon-with-text.tsx index 74380bdea17a..f61f6751e6b2 100644 --- a/packages/web/src/components/custom/status-icon-with-text.tsx +++ b/packages/web/src/components/custom/status-icon-with-text.tsx @@ -7,6 +7,7 @@ const variantBadgeMap: Record< React.ComponentProps['variant'] > = { success: 'success', + warning: 'warning', error: 'destructive', default: 'accent', secondary: 'secondary', @@ -26,7 +27,7 @@ const StatusIconWithText = React.memo( StatusIconWithText.displayName = 'StatusIconWithText'; export { StatusIconWithText }; -type StatusVariant = 'success' | 'error' | 'default' | 'secondary'; +type StatusVariant = 'success' | 'warning' | 'error' | 'default' | 'secondary'; interface StatusIconWithTextProps { icon: React.ElementType; diff --git a/packages/web/src/components/ui/badge.tsx b/packages/web/src/components/ui/badge.tsx index 69f710f8815a..9bddd74fb069 100644 --- a/packages/web/src/components/ui/badge.tsx +++ b/packages/web/src/components/ui/badge.tsx @@ -16,6 +16,8 @@ const badgeVariants = cva( 'bg-destructive-50 text-destructive-700 border-destructive-600 dark:bg-destructive-950 dark:text-destructive-300 dark:border-destructive-400', success: 'bg-success-50 text-success-700 border-success-600 dark:bg-success-950 dark:text-success-300 dark:border-success-400', + warning: + 'bg-warning-50 text-warning-700 border-warning-600 dark:bg-warning-950 dark:text-warning-300 dark:border-warning-400', info: 'bg-blue-50 text-blue-700 border-blue-600 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-400', accent: 'bg-accent text-accent-foreground border-border', outline: diff --git a/packages/web/src/features/flows/api/flows-api.tsx b/packages/web/src/features/flows/api/flows-api.tsx index 95cfdf0a3eb1..37bf95d19558 100644 --- a/packages/web/src/features/flows/api/flows-api.tsx +++ b/packages/web/src/features/flows/api/flows-api.tsx @@ -15,6 +15,7 @@ import { import { toast } from 'sonner'; import { UNSAVED_CHANGES_TOAST } from '@/components/ui/sonner'; +import { projectCollectionUtils } from '@/features/projects/stores/project-collection'; import { api } from '@/lib/api'; export const flowsApi = { @@ -22,7 +23,13 @@ export const flowsApi = { return api.get>('/v1/flows', request); }, create(request: CreateFlowRequest) { - return api.post('/v1/flows', request); + return api.post('/v1/flows', request).then((flow) => { + projectCollectionUtils.markFlowActivity({ + projectId: flow.projectId, + lastFlowUpdated: flow.updated, + }); + return flow; + }); }, update( flowId: string, @@ -40,6 +47,13 @@ export const flowsApi = { }); } throw error; + }) + .then((flow) => { + projectCollectionUtils.markFlowActivity({ + projectId: flow.projectId, + lastFlowUpdated: flow.updated, + }); + return flow; }); }, getTemplate(flowId: string, request: GetFlowTemplateRequestQuery) { @@ -63,7 +77,9 @@ export const flowsApi = { ); }, delete(flowId: string) { - return api.delete(`/v1/flows/${flowId}`); + return api.delete(`/v1/flows/${flowId}`).then(() => { + return projectCollectionUtils.refetchProjects(); + }); }, count(query: CountFlowsRequest) { return api.get('/v1/flows/count', query); 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 158e378c4f32..cabf32fd5463 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 @@ -1,4 +1,5 @@ import { + AiProviderKeyStatus, AIProviderModel, AIProviderWithoutSensitiveData, CreateAIProviderRequest, @@ -38,6 +39,12 @@ export const aiProviderApi = { request, ); }, + recheck(providerId: string) { + return api.post<{ status: AiProviderKeyStatus }>( + `/v1/ai-providers/${providerId}/recheck`, + {}, + ); + }, update(providerId: string, request: UpdateAIProviderRequest): Promise { return api.post(`/v1/ai-providers/${providerId}`, 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 aad0ab79672a..e9252154f655 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 @@ -46,6 +46,12 @@ export const aiProviderQueries = { }; export const aiProviderMutations = { + useRecheckAiProvider: ({ onSuccess }: { onSuccess: () => void }) => { + return useMutation({ + mutationFn: (providerId: string) => aiProviderApi.recheck(providerId), + onSuccess, + }); + }, useDeleteAiProvider: ({ onSuccess }: { onSuccess: () => void }) => { return useMutation({ mutationFn: (providerId: string) => aiProviderApi.delete(providerId), diff --git a/packages/web/src/features/projects/stores/project-collection.ts b/packages/web/src/features/projects/stores/project-collection.ts index 0a70e5e9147b..10c045fa2bd1 100644 --- a/packages/web/src/features/projects/stores/project-collection.ts +++ b/packages/web/src/features/projects/stores/project-collection.ts @@ -102,6 +102,8 @@ export const projectCollection = createCollection( }), ); +let authoritativeProjectsGeneration = 0; + export const projectCollectionUtils = { useCreateProject: ( onSuccess: (project: ProjectWithLimits) => void, @@ -153,7 +155,43 @@ export const projectCollectionUtils = { delete: (projectIds: string[]) => { projectCollection.delete(projectIds); }, - refetchProjects: () => projectCollection.utils.refetch(), + refetchProjects: () => { + authoritativeProjectsGeneration += 1; + return projectCollection.utils.refetch(); + }, + markFlowActivity: ({ + projectId, + lastFlowUpdated, + }: { + projectId: string; + lastFlowUpdated: string; + }) => { + const generation = authoritativeProjectsGeneration; + const write = () => { + if (generation !== authoritativeProjectsGeneration) { + return; + } + const project = projectCollection.get(projectId); + if (isNil(project)) { + return; + } + const current = project.analytics.lastFlowUpdated; + const isNewer = + isNil(current) || + new Date(lastFlowUpdated).getTime() > new Date(current).getTime(); + if (!isNewer) { + return; + } + projectCollection.utils.writeUpdate({ + ...project, + analytics: { + ...project.analytics, + lastFlowUpdated, + }, + }); + }; + requestAnimationFrame(() => requestAnimationFrame(write)); + }, setCurrentProject: (projectId: string, pathName?: string) => { authenticationSession.switchToProject(projectId); if (pathName) { diff --git a/packages/web/src/features/projects/stores/projects-collection.test.ts b/packages/web/src/features/projects/stores/projects-collection.test.ts index 1276e80e5f98..392bcd0fe11e 100644 --- a/packages/web/src/features/projects/stores/projects-collection.test.ts +++ b/packages/web/src/features/projects/stores/projects-collection.test.ts @@ -71,6 +71,7 @@ function makeProject( activeUsers: 0, totalFlows: 0, activeFlows: 0, + lastFlowUpdated: null, }, }; } diff --git a/tools/scripts/model-catalog.NOTICE.md b/tools/scripts/model-catalog.NOTICE.md new file mode 100644 index 000000000000..cf5511068381 --- /dev/null +++ b/tools/scripts/model-catalog.NOTICE.md @@ -0,0 +1,28 @@ +`model-catalog.generated.json` is generated by `npm run sync-model-catalog` from +https://models.dev/api.json — an open-source database of AI models. + +Source: https://github.com/anomalyco/models.dev + +--- + +MIT License + +Copyright (c) 2025 models.dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tools/scripts/sync-model-catalog.ts b/tools/scripts/sync-model-catalog.ts new file mode 100644 index 000000000000..4bb7073a7a06 --- /dev/null +++ b/tools/scripts/sync-model-catalog.ts @@ -0,0 +1,228 @@ +import { mkdirSync, writeFileSync } from 'fs' +import { dirname, join } from 'path' +import { AIProviderName } from '../../packages/core/utils/src/lib/permission' +import { tryCatch } from '../../packages/core/utils/src/lib/try-catch' + +const MODELS_DEV_API_URL = 'https://models.dev/api.json' + +const PUBLISHED_CATALOG_URL = process.env['AP_MODEL_CATALOG_URL'] ?? 'https://cdn.activepieces.com/ai/model-catalog.json' + +const NOTICE = 'Model data from models.dev, MIT licensed — https://github.com/anomalyco/models.dev' + +const OUTPUT_PATH = join(__dirname, '../../dist/model-catalog.json') + +const MODELS_DEV_PROVIDER: Partial> = { + [AIProviderName.OPENAI]: 'openai', + [AIProviderName.ANTHROPIC]: 'anthropic', + [AIProviderName.GOOGLE]: 'google', + [AIProviderName.AZURE]: 'azure', + [AIProviderName.BEDROCK]: 'amazon-bedrock', + [AIProviderName.MISTRAL]: 'mistral', + [AIProviderName.OPENROUTER]: 'openrouter', + [AIProviderName.XAI]: 'xai', + [AIProviderName.DEEPSEEK]: 'deepseek', + [AIProviderName.ZAI]: 'zai', + [AIProviderName.QWEN]: 'alibaba', + [AIProviderName.MINIMAX]: 'minimax', + [AIProviderName.MOONSHOT]: 'moonshotai', +} + +const ALIASED_AT_LOOKUP: AIProviderName[] = [AIProviderName.ACTIVEPIECES] + +const COST_PRECISION = 1_000 + +const NOT_FOUND = 404 + +const MIN_RETAINED_RATIO = 0.8 + +const MAX_TOLERATED_MODEL_LOSS = 2 + +async function main(): Promise { + const published = await fetchPublishedCatalog() + const upstream = await fetchUpstream() + const providers = buildCatalog(upstream) + + assertNotTruncated({ previous: published?.providers, catalog: providers }) + + const catalog: PublishedCatalog = { + notice: NOTICE, + generatedAt: new Date().toISOString(), + providers, + } + mkdirSync(dirname(OUTPUT_PATH), { recursive: true }) + writeFileSync(OUTPUT_PATH, `${JSON.stringify(catalog, null, 2)}\n`) + printCoverage(providers) +} + +async function fetchPublishedCatalog(): Promise { + const { data: response, error } = await tryCatch(() => fetch(PUBLISHED_CATALOG_URL)) + if (error !== null) { + throw new Error(`refusing to publish: cannot reach ${PUBLISHED_CATALOG_URL} to validate against — ${error instanceof Error ? error.message : String(error)}`) + } + if (response.status === NOT_FOUND) { + process.stdout.write(`nothing published at ${PUBLISHED_CATALOG_URL} yet; publishing the first catalog without a truncation guard\n`) + return undefined + } + if (!response.ok) { + throw new Error(`refusing to publish: ${PUBLISHED_CATALOG_URL} returned ${response.status} ${response.statusText}, so the current catalog is unknown`) + } + const { data: body, error: parseError } = await tryCatch(() => response.json()) + if (parseError !== null) { + throw new Error(`refusing to publish: ${PUBLISHED_CATALOG_URL} is not valid JSON, so the current catalog is unknown`) + } + if (!isPublishedCatalog(body)) { + throw new Error(`refusing to publish: ${PUBLISHED_CATALOG_URL} is not a catalog document — "providers" is missing or malformed, so the current catalog is unknown`) + } + return body +} + +function isPublishedCatalog(body: unknown): body is PublishedCatalog { + if (!isPlainObject(body) || !('providers' in body) || !isPlainObject(body.providers)) { + return false + } + return Object.values(body.providers).every(isPlainObject) +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +async function fetchUpstream(): Promise { + const response = await fetch(MODELS_DEV_API_URL) + if (!response.ok) { + throw new Error(`models.dev returned ${response.status} ${response.statusText}`) + } + const upstream: ModelsDevApi = await response.json() + return upstream +} + +function buildCatalog(upstream: ModelsDevApi): ModelCatalogFile { + return Object.fromEntries( + sorted(Object.entries(MODELS_DEV_PROVIDER)) + .map(([provider, upstreamId]) => [provider, buildProviderBlock(upstream[upstreamId])] as const) + .filter(([, models]) => Object.keys(models).length > 0), + ) +} + +function buildProviderBlock(upstreamProvider: ModelsDevProvider | undefined): Record { + if (!upstreamProvider) { + return {} + } + return Object.fromEntries( + sorted(Object.entries(upstreamProvider.models)) + .map(([modelId, model]) => [modelId, toMetadata(model)] as const), + ) +} + +function toMetadata(model: ModelsDevModel): ModelMetadata { + return { + contextTokens: model.limit?.context, + maxOutputTokens: model.limit?.output, + releaseDate: model.release_date, + inputCostPerMillionTokens: roundCost(model.cost?.input), + outputCostPerMillionTokens: roundCost(model.cost?.output), + supportsToolCalling: model.tool_call, + supportsReasoning: model.reasoning, + supportsVision: model.modalities?.input?.includes('image'), + } +} + +function assertNotTruncated({ previous, catalog }: { previous: ModelCatalogFile | undefined, catalog: ModelCatalogFile }): void { + if (!previous) { + return + } + + const shrunk = Object.entries(previous) + .map(([provider, models]) => ({ + provider, + before: Object.keys(models).length, + after: Object.keys(catalog[provider] ?? {}).length, + })) + .filter(({ before, after }) => hasShrunk({ before, after })) + if (shrunk.length > 0) { + const detail = shrunk.map(({ provider, before, after }) => `${provider} ${before} -> ${after}`).join(', ') + throw new Error(`refusing to write: provider(s) lost models upstream — ${detail}`) + } + + const before = countModels(previous) + const after = countModels(catalog) + if (hasShrunk({ before, after })) { + throw new Error(`refusing to write: model count fell from ${before} to ${after}, upstream payload looks partial`) + } +} + +function hasShrunk({ before, after }: { before: number, after: number }): boolean { + return before - after > MAX_TOLERATED_MODEL_LOSS && after < before * MIN_RETAINED_RATIO +} + +function printCoverage(catalog: ModelCatalogFile): void { + const rows = Object.entries(catalog).map(([provider, models]) => { + const entries = Object.values(models) + const withCost = entries.filter((model) => model.inputCostPerMillionTokens !== undefined).length + return ` ${provider.padEnd(16)} ${String(entries.length).padStart(4)} models ${String(withCost).padStart(4)} priced` + }) + const unsourced = Object.values(AIProviderName) + .filter((provider) => !(provider in catalog) && !ALIASED_AT_LOOKUP.includes(provider)) + process.stdout.write([ + `wrote ${OUTPUT_PATH}`, + ` ${countModels(catalog)} models across ${Object.keys(catalog).length} providers`, + ...rows, + ` aliased at lookup: ${ALIASED_AT_LOOKUP.join(', ')}`, + ` no upstream source: ${unsourced.join(', ')}`, + '', + ].join('\n')) +} + +function roundCost(cost: number | undefined): number | undefined { + if (cost === undefined) { + return undefined + } + return Math.round(cost * COST_PRECISION) / COST_PRECISION +} + +function countModels(catalog: ModelCatalogFile): number { + return Object.values(catalog).reduce((total, models) => total + Object.keys(models).length, 0) +} + +function sorted(entries: [string, T][]): [string, T][] { + return [...entries].sort(([a], [b]) => a.localeCompare(b)) +} + +main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`) + process.exit(1) +}) + +type ModelMetadata = { + contextTokens?: number + maxOutputTokens?: number + releaseDate?: string + inputCostPerMillionTokens?: number + outputCostPerMillionTokens?: number + supportsToolCalling?: boolean + supportsReasoning?: boolean + supportsVision?: boolean +} + +type ModelCatalogFile = Record> + +type PublishedCatalog = { + notice: string + generatedAt: string + providers: ModelCatalogFile +} + +type ModelsDevModel = { + release_date?: string + tool_call?: boolean + reasoning?: boolean + limit?: { context?: number, output?: number } + cost?: { input?: number, output?: number } + modalities?: { input?: string[] } +} + +type ModelsDevProvider = { + models: Record +} + +type ModelsDevApi = Record