Back to Activepieces

AI Providers

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

0.90.153.7 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.

  • secret: true on a credential field is not about masking, it is what stops a stored credential being prefilled back into the form. In PROVIDER_CREDENTIAL_FIELDS the flag feeds secretKeysOf, and the dialog's default-values builder blanks exactly those keys when reopening an existing key; the password-style Input is a side effect of the same flag, not its purpose. So dropping it to change how a field renders silently turns a stored secret into a prefilled value. Render differently instead: type (dictionary, textarea) is checked ahead of secret in CredentialFieldInput, so a field can keep the flag and still draw as something other than a masked input — that is how the Vertex service-account JSON gets a monospace textarea while staying blanked on edit. Both the initialiser and the required-field check filter only dictionary, so any other type is initialised and validated like a normal credential.

  • 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.

  • Adding a provider compiles fine and still breaks flows, because the two model factories fail differently. createLanguageModel in @activepieces/ai-providers ends its switch with const exhaustiveCheck: never = provider, so a missing provider is a compile error. The deliberately duplicated buildLanguageModel inside the AI piece (pieces/community/ai/.../common/ai-sdk.ts, kept on ai@6 because a v4 model cannot cross into the engine) ends with default: throw new Error(...) — so a provider wired only into the first one type-checks, ships, connects happily in the admin UI, and then throws Provider <name> is not supported the moment someone uses it in a flow step. That is the .claude/rules/self-hosting.md antipattern exactly. Wire both factories, and note buildNativeImageModel in that same file is a third switch, and its miss is worse than a throw: createAIModel does if (imageModel) return imageModel and otherwise falls through to buildLanguageModel, so a provider absent from that switch answers an image request with a language model rather than erroring. NO_IMAGE_GENERATION_PROVIDERS is what turns that into the clear does not support image models message, so a provider belongs there until images are actually wired — the entry is load bearing, not cosmetic.

  • Overriding fetch on a provider silently switches off key-health reporting. ...observed is a fetch (observedProviderFetch), so any later fetch in the same options object replaces it rather than adding to it — spread order decides, and nothing errors or logs. A path that needs its own fetch (the Responses header-stripping one, the Cloudflare gateway's Authorization-deleting one) must take observedProviderFetch(options.onOutcome) as a delegate and call it, not call globalThis.fetch. Resolve the delegate at call time rather than capturing it at model-construction time; late binding is both more correct and the only way a test can stub the global. This is the same family as the merge hazard above — the failure is always one provider, or one path within a provider, quietly not reporting.

  • A provider branch merges cleanly and still opts out of whatever the switch gained meanwhile. createLanguageModel applies cross-cutting behaviour by spreading it into every case — ...observed (observedProviderFetch, added by the key-health work) is the current one. Git treats a long-lived branch's new case as an added block, so it lands with none of the spreads its siblings picked up, and the merge reports no conflict: the new provider is simply the one that never reports health. Nothing fails, nothing logs. After merging main into any provider branch, diff your case against a neighbouring one and check the spreads match, rather than trusting a clean merge.

  • Only two Record<AIProviderName, …> maps are exhaustive, so the enum addition barely fights back. AI_PROVIDER_CAPABILITIES (piece-types) and aiProviders (ai/providers/index.ts) must gain an entry; PROVIDER_CREDENTIAL_FIELDS, PROVIDER_EMBEDDING_MODELS, ALLOWED_CHAT_MODELS_BY_PROVIDER, PROVIDER_MAX_CONTEXT_TOKENS, DEFAULT_EMBEDDING_MODELS and WEB_SEARCH_MODE_BY_PROVIDER are all Partial<Record<…>> and degrade silently to defaults. grep -rn 'Record<AIProviderName' | grep -v 'Partial<' is the whole compile-time safety net — every other consequence of a new provider is a runtime question you have to reason about yourself.

  • The repo runs three @ai-sdk generations at once, and the vendor package version is what pins you to one. @activepieces/ai-providers is on ai@7 (@ai-sdk/google 4.0.29, openai-compatible 3.0.18, provider-utils 5.0.16); the AI piece is on ai@6 (google 3.0.65, openai-compatible 2.0.42, provider-utils 4.0.24); older code sits on the ai@5 line (google 2.0.71, provider-utils 3.0.24). A vendor SDK therefore needs a different release per factory@ai-sdk/google-vertex is 5.0.36 for ai@7 and 4.0.113 for ai@6 — and taking the newest for both silently mixes LanguageModelV3/V4 and fails at the engine boundary. Match transitive deps rather than guessing: curl -s https://registry.npmjs.org/@ai-sdk/<pkg> and pick the version whose dependencies equal the target package's own pins. Faster still, ls node_modules/.bun/ | grep '<pkg>@' usually already lists every generation bun has resolved.

  • The OpenAI-compatible provider can speak the Responses API, and @ai-sdk/openai-compatible is not how you get there. That SDK exposes only chatModel (/chat/completions) and completionModel — no responses model in any version. The route is @ai-sdk/openai, which accepts a custom baseURL + headers and exposes .responses(); the Cloudflare gateway's openai branch already did this before the apiStyle: 'chat' | 'responses' config field existed. Header precedence is the subtlety: the SDK builds { Authorization: Bearer <apiKey>, ...options.headers }, so a caller header wins on Authorization (the Bedrock case, where the key is the bearer token) but a custom apiKeyHeader such as x-api-key leaves the SDK's default Authorization riding along. withUserAgentSuffix drops undefined values, but OpenAIProviderSettings.headers is typed Record<string, string>, so the way to strip it is the header-stripping fetch the Cloudflare branch uses — no cast needed, and worth reaching for straight away rather than writing the duplicate header up as an accepted limitation. Strip only when apiKeyHeader is not itself Authorization, compared case-insensitively: HTTP header names are, and an admin can type authorization. Confirmed shape, since it is easy to assert on the wrong key: the SDK lowercases everything, so a mis-set provider really does emit {authorization: 'Bearer <key>', 'x-api-key': '<key>'} — the credential twice.

  • AWS's OpenAI-compatible surface is two endpoints, and "Bedrock Mantle" is a real AWS product name, not a customer's nickname. bedrock-runtime.{region}.amazonaws.com/openai/v1 (AWS-recommended) and bedrock-mantle.{region}.api.aws/v1 both serve Chat Completions and Responses, both authenticate with a Bedrock API key as Authorization: Bearer, and both take an unmodified OpenAI SDK. Mantle alone adds server-side tools and web search, background=true async inference, and Projects/Workspaces; bedrock-runtime alone has Guardrails, cross-Region inference and intelligent prompt routing. So a ticket claiming "Mantle only supports responses" is half right — it serves chat/completions too, and the real loss is the Responses-only capabilities.

  • Vertex is a different door onto the same models, not a different vendor. The GOOGLE provider is the Gemini Developer API (generativelanguage.googleapis.com, static API key); VERTEX is Vertex AI ({region}-aiplatform.googleapis.com/v1beta1/projects/{project}/locations/{region}/publishers/google, service-account OAuth2 with ~1h tokens) — the same Gemini models, but billed to the customer's GCP account and inheriting IAM, VPC-SC, CMEK, data residency and audit logs, plus Model Garden's Claude/Llama/Mistral. That rotating token is why the CUSTOM provider can never reach Vertex: buildOpenAICompatibleHeaders injects one static header, which is why the workaround used to be a LiteLLM proxy. @ai-sdk/google-vertex does the JWT-to-token exchange itself, so the native provider needs no proxy. Vertex models are entered by hand (MANUAL_MODEL_PROVIDERS) on purpose: availability is project-, region- and Model-Garden-specific, and a publishers/google listing would miss exactly the third-party models that motivate choosing Vertex. But "same models" only holds for Gemini. Model Garden also serves Anthropic, Meta, Mistral and xAI, and those do not share Gemini's API surface — @ai-sdk/google-vertex ships separate /anthropic, /maas and /xai entry points for exactly that reason, so one createVertex(...) call does not cover Vertex. Because models are typed in by hand, a Claude id reaches the factory as an ordinary string and, unrouted, gets built with the Gemini client and fails at the endpoint. create-language-model.ts routes ids containing claude to createVertexAnthropic; vertexClientFor picks the client from the id shape, so all three are routed. Because the shapes are unambiguous the check is a lookup, not a tuned heuristic — and note the publisher prefix has to be tested before the claude one, or a MaaS path containing "claude" routes to the Anthropic client. Routing is cheap because the id shapes are distinct, not because a heuristic was tuned: gemini-2.5-pro is bare, Anthropic carries an @date (claude-3-5-sonnet@20241022), and MaaS is publisher-prefixed and suffixed (meta/llama-4-scout-17b-16e-instruct-maas). The publisher prefix is a stronger discriminator than the includes('claude') match currently shipped, and it looks like an argument for listing models rather than taking them by hand — but it is not, see below: the listing is scoped to a single publisher, so Model Garden ids stay hand-typed and the string matching stays with them. Listing was considered and rejected, and the reason is worth keeping so it does not get reopened, and the response shape does not need a GCP account to learn — every Google API publishes an unauthenticated discovery document: curl -s 'https://aiplatform.googleapis.com/$discovery/rest?version=v1beta1' returns ~5MB of JSON whose schemas are the authoritative request/response types. publishers.models.list returns { publisherModels: PublisherModel[], nextPageToken }, and PublisherModel is { name: 'publishers/google/models/<id>', versionId, versionState (STABLE|UNSTABLE), launchStage (GA|PUBLIC_PREVIEW|PRIVATE_PREVIEW|EXPERIMENTAL), openSourceCategory, supportedActions, predictSchemata, frameworks, parent }. There is no displayName on it — the one in google-vertexai's piece comes from @google/genai's Model type, which is a different shape, so copying that code gives every row a blank label with nothing failing. displayName exists only on the nested parent, which is the base model a tuned model derives from. The decisive fact is in that same doc: publishers.models.list takes parent matching ^publishers/[^/]+$one publisher per call. Listing everything therefore means hardcoding the set of publishers to enumerate, which is the same shape of hardcoding it was meant to remove, and Model Garden models would still need hand entry, leaving two mechanisms where there is now one. VERTEX stays in MANUAL_MODEL_PROVIDERS alongside CUSTOM and CLOUDFLARE_GATEWAY, which fits: Vertex model availability really is per-project. Reach for the discovery doc before a live probe on any *.googleapis.com integration; a curl against the real endpoint needs billing attached to the project even for a read (403 BILLING_DISABLED), so it is the slower path and it is not free to set up. The x-goog-user-project header is not optional when probing with a personal login: without it aiplatform answers 403 SERVICE_DISABLED naming consumer: projects/32555940559, which is Google's shared gcloud CLI project rather than yours, and the message talks about quota projects rather than the missing header. This is a user-credential quirk only — a service account carries its own project_id, so the provider itself never hits it. The multi-vendor story is text only. Vertex's image models are Google's own Imagen (imagen-3.0-*, imagen-4.0-*), not Model Garden's third parties, and createVertex(...) also exposes imageModel, video, speech, transcription and textEmbeddingModel — none of which we wire today. Imagen is wired (createVertex(...).imageModel(), with a VERTEX case in the piece's image switch, so VERTEX is no longer in NO_IMAGE_GENERATION_PROVIDERS); video, speech, transcription and embeddings are not. Embeddings still have no DEFAULT_EMBEDDING_MODELS entry, so that path paths fail with an explanatory message instead of reaching a switch that has no case for it.

  • Chat-tier resolution answers "which model" from three places, and only one of them is the key. resolveModelIdForProvider (ee/agent/agent-helpers.ts) now prefers the resolved key's own configured text models when its config carries a models array — that is the MANUAL_MODEL_PROVIDERS case (Vertex, Custom, Cloudflare Gateway), where the admin typed the exact ids the key exposes. An empty catalog is not a missing one, and collapsing the two is how the first cut of this went wrong: a helper returning string[] | undefined treated "lists zero text models" the same as "has no catalog", so a key configured with only image models fell through to the curated list and resolved to a Gemini id it never offered. An admin-listed catalog is the whole truth about a key, so an empty one now refuses the turn with a message rather than guessing. Otherwise it falls back to the static ALLOWED_CHAT_MODELS_BY_PROVIDER, and if the provider is missing from that too it returns the raw tier id with its vendor prefix stripped — which is how a Vertex key once resolved to claude-sonnet-4-6 and handed it to the Gemini client. That map is Partial<, so omitting a chat-capable provider compiles cleanly and fails only at the endpoint. The key's own modelScope/modelIds allow-list is applied last, to whichever candidate list was chosen, and a resolution with nothing left refuses the turn rather than returning a model the key forbids. GetProviderConfigResponse carries both fields for that, populated at all three construction sites (getChatProvider, getConfigOrThrow, enrichWithKeysIfNeeded); adding fields there is backward compatible because the AI piece reads the response as a typed shape rather than parsing it. I first recorded this as too big for a provider PR — "a wire contract the engine and the AI piece both consume" — having never measured it. It was about thirty lines across four files. Measure before recording something as out of scope; the estimate outlives the guess. The resolver is exported and has five call sites across four files, and only two of them are runs. agent-rpc-handlers resolves the chat turn's model and its fast model, and chat-personalization-service resolves both for research — those four must be given the resolved row's config, modelScope and modelIds, or the key's constraints are bypassed on exactly the paths a user takes. resolveTierModel inside agent-helpers is the fifth. agent-service and agent-draft-ai also call it but hold only a provider name and set a stored default on a draft, so they are correctly left thin. Adding a parameter here means grepping resolveModelIdForProvider|resolveFastModelId across packages/server, not editing the call site in front of you — threading only resolveTierModel looks complete, passes every test, and still leaves the real chat path unconstrained. One trap when touching this function: a tier id (fast, smart) is not a model id, so the preferred pick must be the resolved native id unless the selection is itself among the candidates — passing selectedModel straight through makes resolveFastModelId return the first curated model instead of the fast tier's.

  • 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 rule is not "after the last schema with a required field" — that phrasing reads as "append after BedrockProviderConfig" and is how a Vertex config got silently reduced to { region }, losing project and models with no error. What matters is the subset relation, not the count: BedrockProviderConfig is { region }, a strict subset of { project, region, models }, so it matches a Vertex object first and strips the rest. Order by specificity — any schema whose required keys are a subset of yours must sit after you. Check with a one-line parse before trusting the order: AIProviderConfig.parse(yourConfig) must return every field it was given. 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. The third copy moved rather than disappearing. .../setup/ai/universal-pieces/ is gone and the connect dialog now builds from PROVIDER_CREDENTIAL_FIELDS, but config-detail.tsx declares its own ManualProviderConfig = z.union([...]) that gates whether the manual-models panel sends config at all. A provider missing from it fails safeParse, its models are dropped from the payload, and they silently vanish on reload — which for a MANUAL_MODEL_PROVIDERS member means the only way to choose a model does not work. So it is still three unions per provider: the two shared copies plus this one, and it is the easiest to miss because it lives in a component and nothing references the provider by name. The rest of this sentence describes that deleted file and is kept only as history: 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.