Back to Activepieces

AI Providers

brain/knowledge/ai-intelligence/ai-providers.md

0.89.036.5 KB
Original Source

AI Providers

Lets platform admins configure one or more LLM backends for AI pieces in flows. Also auto-provisions an "Activepieces" provider (backed by OpenRouter) whose credit balance and auto-top-up are metered by Autumn billing. EE/Cloud only (not registered in CE).

Entities & services

  • AIProvider — platform-scoped: displayName, platformId (indexed with provider, NOT unique — a platform can hold multiple keys per provider since the 2026-08 providers redesign), provider (AIProviderName enum), auth (EncryptedObject, AES-256 at rest), config (JSON), enabledForChat, plus per-key scoping: modelScope (all|selected) + modelIds[], and projectScope (all|selected|except) + projectIds[] (GIN-indexed).
  • Backend under packages/server/api/src/app/ai/; shared schemas in core/shared/.../ai-providers/.
  • Supported providers (10): openai, anthropic, google, azure, openrouter, bedrock, mistral, cloudflare-gateway, custom (OpenAI-compatible, e.g. Ollama/LM Studio), activepieces (auto-provisioned via OpenRouter).

How it works

  • GET / list (auto-creates ACTIVEPIECES when aiCreditsEnabled); GET /:provider/config returns decrypted auth (engine-only); GET /:provider/models (cached); POST / create (validates creds first); POST /:id update; DELETE /:id.
  • For ACTIVEPIECES, update is blocked by an early return (only enabling enabledForChat is allowed); deletion is allowed and self-healing — listProviders recreates the managed row.
  • Engine integration: AI pieces call GET /v1/ai-providers/{provider}/config on every AI action execution (no per-run caching), authorized by the engine token.
  • For the managed ACTIVEPIECES provider that config route is also the credit gate: assertCreditsAndAppSumoNotExceeded (platform/billing-provider.ts) throws QUOTA_EXCEEDED when the Autumn credit or AppSumo balance is blocked — fires per AI call, but usage is only metered post-run, so in-flight spend is invisible to it. See decision 000016 (brain/decisions/000016-managed-ai-metering-moves-to-centralized-worker-execution.md).
  • Activepieces provisioning: getOrCreateActivePiecesProviderAuthConfig()enrichWithKeysIfNeeded() mints an OpenRouter key. No system job is scheduled — renewal/top-up is driven by Autumn (autoTopUps in autumn-billing.ts), not Stripe.

AI Credits (Autumn-metered)

  • Rate: 1000 credits = $1 USD; OpenRouter meters usage per API key; usage cached 180s.
  • New managed keys are minted with a hard spend guardrail of $500/month (MANAGED_OPENROUTER_KEY_MONTHLY_LIMIT_USD in ai-provider-service.ts, limit_reset: 'monthly') — a runaway-cost ceiling independent of the Autumn credit balance. Keys minted before the 2026-07 change carry $1000/monthly from a one-off OpenRouter backfill.
  • There is no monthly credit-reset job and no direct Stripe invoicing; the only "monthly" mechanism is the key's limit_reset.
  • Model lists are cached in memory, cleared daily at midnight via cron.

Provider visibility

  • 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 (MIT), generated by npm run sync-model-catalog and published by a weekly workflow to https://cdn.activepieces.com/ai/model-catalog.jsonnothing 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 generatedAtcurl -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: bedrockamazon-bedrock, qwenalibaba, moonshotmoonshotai, 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). 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), in which case resolveRowForScope serves that row after checking it is eligible for the caller's project.

  • Every resolver takes a required ProviderScope; there is no "no project" default (decision: 000027). getConfigOrThrow / getChatProvider / getChatProviderName / listModels all take scope: { type: 'project', projectId } | { type: 'platform' }. This is deliberate and load-bearing: the first cut made projectId optional and treated its absence as "every key is eligible", so each new call site that forgot to thread it silently bypassed project scoping — the agent piece/knowledge-base tool handlers, the chat model picker, and the configId model lookup each reopened the same hole in turn. Making the argument required turns an omission into a compile error, and { type: 'platform' } at a call site is a reviewable claim rather than an accident. Only three consumers are legitimately platform-wide: the tool-search embedder, chat memory extraction, and the managed-ACTIVEPIECES singleton.

  • The last fail-open scope lived in the helper that builds the scope, not in the resolvers. Chat resolves the project a turn runs in (selectRunProject) before it resolves a credential, and that project is nullable — a user who can no longer see any project gets null. The first cut turned null into { type: 'platform' }, which hands the run every key on the platform and is exactly the hole a required ProviderScope was meant to close. agentHelpers.runScopeOrThrow refuses the run instead; the analytics and billing paths, which want the provider's name and not a credential, take the nullable project id themselves (resolveChatProviderName) and report no provider for a conversation that has none. Rule of thumb: a nullable id feeding a scope constructor is the shape to look for, not a missing argument.

  • The managed ACTIVEPIECES row is a singleton the database enforces, not the code. create() rejecting it only covers the admin route; the row is also auto-provisioned from listVisibleRows on every list, so two concurrent GET /v1/ai-providers both missed the existsBy and inserted once the unique (platformId, provider) index was dropped. idx_ai_provider_platform_id_managed — unique on (platformId) WHERE provider = 'activepieces' — keeps it single, and the insert carries ON CONFLICT DO NOTHING so the losing racer is a no-op rather than a 500. Preferred over a distributed lock because the invariant holds even for a writer that never takes one.

  • Reads are split by trust level, and mixing them back together is how the scope bypass keeps returning. Runtime/project reads are GET /v1/ai-providers?projectId= (deduped to one entry per provider, {provider, name, enabledForChat} only) and GET /v1/ai-providers/:provider/models?projectId= — both securityAccess.project([USER, ENGINE], undefined, QUERY), so an ENGINE principal supplies its own project and a USER must name a project it belongs to, and both always apply the resolved key's modelScope allow-list. Admin reads are GET /v1/ai-providers/configs and GET /v1/ai-providers/configs/:id/modelsplatformAdminOnly, addressing an exact row and returning the unfiltered model list because that is what an admin picks the allow-list from. Never widen the project routes to accept a config id, and never hand projectIds/modelIds to a project caller: those are other projects' identifiers. That rule is pinned by a guard test asserting the exact key set of a project entry (and of each item in its keys array), so adding a field to the project-facing response is meant to fail ai-provider.test.ts until someone states the field is safe to expose.

  • Two keys may legitimately hold the same secret, and you could not detect it anyway. One API key scoped to two rows with different modelScope/projectScope allow-lists is a supported setup, not a mistake. Blocking it is also impractical: encryptUtils.encryptObject uses a fresh random IV per write, so the same credential stores as different ciphertext every time — deduping would need a separate HMAC column. Key names are the thing worth constraining, since a picker showing two rows called "Anthropic key" is unpickable.

  • mockAndSaveAIProvider uses save, not upsert — the old (platformId, provider) ON CONFLICT target died with the unique index; seeding the same provider twice now creates two keys, which is usually what a test wants.

  • ACTIVEPIECES auto-provision needs OPENROUTER_PROVISION_KEY env var set AND aiCreditsEnabled true.

  • Adding a provider is a leaf change, and the credential fields are the only part that is not. A new vendor touches six places: the AIProviderName enum (packages/core/utils/.../permission.ts), its auth/config schemas plus the two unions and ProviderConfigUnion in packages/core/shared/.../management/ai-providers/index.ts (all in the per-provider region, well above the generic request/response schemas at the bottom), a strategy file registered in ai/providers/index.ts, the model factory switch, name/logo/markdown in packages/web/src/features/agents/ai-providers.ts, and translation keys. None of that is the credential form: extra fields beyond apiKey (Azure's resourceName, Bedrock's region) are declared in one file — PROVIDER_CREDENTIAL_FIELDS in .../setup/ai/providers-tab/provider-credentials.ts, which falls back to DEFAULT_CREDENTIAL_FIELDS (a single apiKey) for any provider with no entry, so a plain API-key vendor needs no UI work at all. That file replaced the deleted universal-pieces/upsert-provider-config-form.tsx in the multi-key redesign, so a provider authored against an older branch loses its custom fields on merge silently — git resolves delete-vs-modify by taking the deletion, no conflict marker, and the provider just becomes unconfigurable in the admin UI. A vendor with no /models endpoint also belongs in MANUAL_MODEL_PROVIDERS in that same file (CUSTOM, CLOUDFLARE_GATEWAY) so the admin enters model ids by hand. Everything else about a provider is orthogonal to multi-key: that is a table-level change (drop UNIQUE (platformId, provider), add the four scope columns, keep a unique partial index for activepieces only), so a provider inherits multi-key with no provider-side code, and the admin providers-tab enumerates SUPPORTED_AI_PROVIDERS from packages/web/src/features/agents/ai-providers.ts rather than a catalog of its own, so a new vendor appears there on its own. When basing provider work off a branch that predates the redesign, re-check provider-credentials.ts after the merge.

  • The OpenAI-compatible vendors (xAI, DeepSeek, Z.ai, Qwen, MiniMax, Moonshot) share one strategy rather than a file each, via openAiCompatibleVendor({ name, provider }) in ai/providers/, with defaults in OPENAI_COMPATIBLE_VENDOR_BASE_URLS and an optional per-key baseUrl override because four of them run separate China and international endpoints. Their listModels GETs {baseUrl}/models, which the vendor docs mostly do not document — confirmed working against live keys for DeepSeek, Z.ai, MiniMax and Moonshot (Qwen still unverified), so don't redo that research. If a future vendor turns out to lack /models, the fallback is the manual-models path rather than a bespoke strategy. Unlike its siblings this factory uses safeHttp.axios, not httpClient from pieces-common: the base URL is admin-supplied, so it must go through the SSRF filter.

  • 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 <name>. 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/<slug>.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 entirelyzodResolver 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.)

  • 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.resolveChatProvidergetChatProvider calls it whenever the platform's managed ACTIVEPIECES row has no apiKey — and the save happens after the OpenRouter call, so a failure persists nothing and the next chat turn calls createKey again. There is also no distributed lock or cache, so concurrent turns for one platform each mint a live key and only the last is saved; the orphans keep spending provisioning quota. Seen in prod 2026-07-30: keys-modify-api-rpd-v2 429 (OpenRouter's key create/modify bucket, 10k/day on the provision key — a separate limit from inference), which killed every chat turn for the affected platform in getChatConfig before the first token, with no recovery until the bucket reset at 00:00 UTC.

  • openrouter-api.ts uses raw fetch — no timeout, no retry, no tryCatch, and it bypasses the repo's safeHttp rule for outbound HTTP in packages/server/api. A getKey 408 from OpenRouter escapes the admin increaseAiCredits path as an unhandled rejection.

  • Chat model tiers are Activepieces-only. ACTIVEPIECES_CHAT_TIERS (fast/smart/premium, shown as Fast/Expert/Heavy) hold OpenRouter-shaped Anthropic ids, so they only mean anything for the ACTIVEPIECES and OPENROUTER chat providers. Any provider that declares ALLOWED_CHAT_MODELS_BY_PROVIDER (openai, anthropic, google) picks a real model id from that list instead. Naively stripping the tier's vendor prefix for every provider is what once sent claude-haiku-4-5 to OpenAI and broke every message.

  • A short model list in the key's picker is the vendor's catalog, not a truncation. listModels returns whatever the provider's own /models endpoint gives and filters nothing except the key's modelScope allow-list. Anthropic ships roughly a dozen models, OpenAI ~80 (mostly embeddings/tts/whisper), while OpenRouter is an aggregator and returns 400+ from every vendor it proxies — so the counts differ by an order of magnitude by design. Anthropic pages at 20 by default, which is why its request pins ?limit=1000. If the list shows exactly three Claude models, that is the chat dropdown reading the curated ANTHROPIC_CHAT_MODELS, a different surface from the admin picker.

  • Read the chat model list through aiProviderUtils.getCuratedChatModels({ provider }). It is the one accessor the server resolver (agentHelpers.resolveModelIdForProvider) and the chat dropdown share, so the two cannot drift; it returns { id, label } or undefined — never an empty list, so callers may treat a result as non-empty. Labels come from the hardcoded CHAT_MODEL_LABELS (falling back to the id) rather than AIProviderModel.name, because the live listModels response cannot supply one for every provider: anthropic returns display_name and google displayName, but OpenAI's /v1/models returns ids only.

  • conversation.modelName carries either a tier id or a real model id — it is a free string with no discriminator. A legacy tier id resolves to the tier's equivalent model when the provider ships it, else the provider's first curated model, so old conversations keep working after a provider switch. Note premium maps to opus 4.8, which the native anthropic list does not carry, so a legacy premium on anthropic lands on Sonnet.

  • Chat-provider resolution is first enabledForChat row wins, not "prefer ACTIVEPIECES". All three branches of findAvailableChatProviderRow reduce to that: when the managed provider is visible the function returns chatProviders[0] whatever it is, so a platform with [openai, activepieces] both chat-enabled resolves to openai. The client mirror is aiProviderQueries.useChatProvider() (providers.find((p) => p.enabledForChat)) — always read the resolved chat provider through it rather than re-deriving the rule inline. enabledForChat on a deduped project entry must be an OR across that provider's keys, never the top-ranked key's flag — ranking (selected > except > all, newest first) and chat selection answer different questions, so reading rows[0].enabledForChat makes the client report "no provider configured" whenever the chat-enabled key is not the ranking winner, while the server (findAvailableChatProviderRow, which queries enabledForChat: true directly) happily serves the turn. Invisible with one key per provider. Both sides lean on an unordered findBy(): there is no ORDER BY, so "first" is not guaranteed stable when several providers are chat-enabled.

  • Listing providers is not a pure read: both listConfigs and listForProject go through listVisibleRows, which inserts the ACTIVEPIECES provider row when aiCreditsEnabled && !activepiecesExists. A GET /v1/ai-providers can therefore create a row. It also applies the hidden-provider filter (plan.embeddingEnabled hides the managed provider), which is why the client can trust its output without re-checking flags.

  • Managed-chat credit cost per turn is tier.creditWeight + billableToolCalls (fast 2 / smart 10 / premium 20, from ACTIVEPIECES_CHAT_TIERS), and BYOK collapses the weight to CHAT_BYOK_CREDIT_WEIGHT (1) regardless of tier — so never show tier weights to a BYOK platform. CHAT_BYOK_CREDIT_WEIGHT / CHAT_CREDITS_PER_TOOL_CALL live in @activepieces/shared so the billed number and the number shown in the model picker come from one place. Every credit-cost surface must render from ACTIVEPIECES_CHAT_TIERS, never from a local copy — the billing Credits FAQ (credits-info-dialog.tsx) hardcoded its own 2/10/20 table and drifted the moment the tiers were relabelled, so it still shows Fast/Smart/Premium against the real Fast/Expert/Heavy.

  • Azure model listing (azureProvider.listModels) is pinned to the retired data-plane api-version 2023-03-15-preview — newer versions 404 and break validateConnection; the configured apiVersion only affects inference via @ai-sdk/azure (GIT-1310).

  • @activepieces/ai-providers is ai@7, so only ai@7 code may call it — a piece must build its own language models. The shared createLanguageModel factory pins ai@7 / @ai-sdk/openai@4 / @openrouter@3, while packages/pieces/community/ai (and the engine that runs pieces) sit on ai@6 / @ai-sdk/openai@3 / @openrouter@2. #14446 pointed the piece at the factory anyway, which broke it two ways: tsc rejects the result (specificationVersion "v4" is not assignable to "v2", LanguageModelV4 vs the piece's LanguageModelV2 | V3), and at runtime the piece's own ai@6 generateText would refuse a v4 model. CI hid it because the pieces build only runs when a PR's diff touches packages/pieces/**, and a post-merge run on main diffs HEAD against origin/main — empty, so no piece is ever built there. Fixed by giving the piece back a local buildLanguageModel switch built on its own SDKs; the factory is server-side only until the pieces move to AI SDK 7. Cloudflare Gateway is the one provider the factory refuses (throw) because its routing is caller-specific; each caller builds that one itself. The standing consequence: a new provider must be added to BOTH switchescreateLanguageModel in packages/core/ai-providers and buildLanguageModel in packages/pieces/community/ai/src/lib/common/ai-sdk.ts — and the piece one is the easy half to forget, because nothing before step execution touches it. Miss it and the provider connects, validates, lists its models and saves without complaint, then every AI-piece step dies at run time on the switch's default: with Provider <name> is not supported. Since pieces cannot import @activepieces/shared, anything the piece-side case needs (base-url maps, config types) also has to be re-exported through packages/pieces/framework/src/index.ts. The git show --stat of the commits that added Mistral (#13088) and Bedrock (#12712) is the reliable checklist for what a provider actually touches — both include ai-sdk.ts.

  • Attribution headers are for ACTIVEPIECES only, and go through the factory's extraHeaders option rather than a local createOpenRouter call. The managed provider is OpenRouter under the hood on our own key, so the x-ap-* headers are what tag our account's events: x-ap-platform-id / x-ap-conversation-id / x-ap-run-id on the agent path, x-ap-project-id / x-ap-flow-id / x-ap-run-id on the piece path. BYOK OPENROUTER is a customer's own account and must not get them. Constructing the provider inline to attach headers is also what silently drops openRouterSettings (the web-search plugin), since the factory is the only place that still passes them. (CUSTOM separately receives the piece-path metadata headers — that is #11700's metadata forwarding for self-hosted OpenAI-compatible endpoints, older than either the rename or the Autumn work and unrelated to OpenRouter attribution. Its precedence is deliberate: admin-configured defaultHeaders override the x-ap-* metadata, and the api key is applied last.)

  • mistralViaOpenRouter does not mean "the managed provider"; it is read only inside the MISTRAL case, and that branch looks like dead legacy. ACTIVEPIECES routes through OpenRouter unconditionally and ignores the flag, so the only thing the agent path's mistralViaOpenRouter: true does is send a MISTRAL chat row to openrouter.ai — carrying that row's Mistral key, which cannot authenticate there. MISTRAL also has no ALLOWED_CHAT_MODELS_BY_PROVIDER entry, so getCuratedChatModels returns undefined for it and the resolver falls back to a tier's OpenRouter-shaped id. The fall-through arrived as a drive-by in #13489, not as a routing decision. Don't infer "this provider is AP-managed" from that case group.

  • AI Tool Configs are a sibling feature (same ai/ dir), distinct from AI Providers: they give the chat assistant external capabilities via /v1/ai-tools (platform-admin, EE/Cloud). AiToolCapability = WEB_SEARCH/WEB_SCRAPING/IMAGE_GENERATION; AiToolProvider = TAVILY/FIRECRAWL/APIFY/FAL. One config per capability (unique on platformId+capability); consumed by chat via getEnabledTools(). Because the config is per-platform, it can never serve a first-run flow on Cloud. A self-serve signup lands on a brand-new platform with no configs at all, so getEnabledTools() returns {} for exactly the users a new-signup feature is aimed at, and any capability read from it silently no-ops rather than failing loudly. A capability that has to work for someone who just signed up needs a cloud-wide AppSystemProp key instead, the way TURNSTILE_SECRET_KEY, FEATUREBASE_API_KEY and APPSUMO_TOKEN are sourced. Note there is no ENRICHMENT capability here, so anything needing people or company enrichment has nowhere to read a key from today.

  • /v1/ai-tools is registered only in the CLOUD and ENTERPRISE branches of app.ts, but the AI Center page that reads it is not edition-gated — so a Community admin opening the Capabilities tab fired useAiToolConfigs, got Fastify's Route not found, and the query's meta.showErrorDialog popped the global "Failed to load data" dialog. Shipped that way from #13911 until the tab was gated on ApFlagId.EDITION in the page. Two things make this class of bug hard to place: the dialog is opened from QueryCache.onError in query-client.ts, so it is page-independent, and React Query's 3 default retries mean it lands several seconds later on whatever page you navigated to next (the report was against /platform/setup/general). When a screenshot's edition is in doubt, read the sidebar: Billing & subscription and Usage carry a lock only when edition === COMMUNITY, every other lock there is plan-driven. Any new EE-only route needs its UI entry point gated the same way, enabled: on the query or hiding the surface.

Key files

Entry point: aiProviderModule, registered in packages/server/api/src/app/app.ts right after aiProviderService(app.log).setup().

  • packages/server/api/src/app/ai/ — backend module: provider controller, service, entity, module, plus the sibling ai-tool-config files
  • packages/server/api/src/app/ai/providers/ — per-vendor strategies keyed by AIProviderName
  • packages/server/api/src/app/platform/billing-provider.tsassertCreditsAndAppSumoNotExceeded credit gate
  • packages/server/api/src/app/ee/platform/platform-plan/openrouter/openrouter-api.ts — the OpenRouter provisioning client (createKey/updateKey/getKey/listKeys)
  • packages/core/ai-providers/src/lib/create-language-model.ts — the shared per-provider model factory (createLanguageModel, buildOpenAICompatibleHeaders) used by both the AI piece and the agent path
  • packages/core/shared/src/lib/management/ai-providers/index.ts — shared zod schemas, enums, request/response types
  • packages/core/shared/src/lib/management/ai-tools/index.ts — shared schemas for the AI Tool Configs sibling
  • packages/web/src/features/platform-admin/api/ + packages/web/src/features/platform-admin/hooks/ — frontend API clients and TanStack Query hooks (ai-provider-*, ai-tool-config-*)
  • packages/web/src/app/routes/platform/setup/ai/ — the AI Center: providers-tab/ (provider groups, connect dialog, per-config detail panel with model and project scope pickers) and capabilities-tab/
  • packages/web/src/app/routes/platform/setup/ai-capabilities/ — admin page, capability dialog, provider catalog for AI Tool Configs
  • packages/web/src/features/agents/ai-model/ — model selector used in agent step settings

Paths verified 2026-07-26.