diff --git a/.github/workflows/helm-chart-validation.yml b/.github/workflows/helm-chart-validation.yml index be57f287f812..b06fa73f5254 100644 --- a/.github/workflows/helm-chart-validation.yml +++ b/.github/workflows/helm-chart-validation.yml @@ -34,6 +34,30 @@ jobs: chart="charts/formbricks" render_dir="$(mktemp -d)" trap 'rm -rf "$render_dir"' EXIT + bundled_model_spec='{"name":"qwen","enabled":true,"repository":"vllm/vllm-openai","tag":"v0.14.0","modelURL":"Qwen/Qwen3-14B-AWQ","replicaCount":1,"requestCPU":4,"requestMemory":"24Gi","limitCPU":8,"limitMemory":"32Gi","requestGPU":1,"requestGPUType":"nvidia.com/gpu","pvcStorage":"100Gi","runtimeClassName":"","shmSize":"8Gi","vllmConfig":{"maxModelLen":65536,"dtype":"float16","tensorParallelSize":1,"maxNumSeqs":8,"gpuMemoryUtilization":0.9,"extraArgs":[]},"lmcacheConfig":{"enabled":false},"keda":{"enabled":false}}' + single_bundled_model_spec="[$bundled_model_spec]" + multi_bundled_model_spec="$(jq -cn --argjson model "$bundled_model_spec" \ + '[($model | .name = "small" | .vllmConfig.maxModelLen = 32768), ($model | .name = "large")]')" + disabled_bundled_model_spec="$(jq -cn --argjson model "$bundled_model_spec" \ + '[($model | .name = "disabled-small" | .enabled = false | .vllmConfig.maxModelLen = 8192), ($model | .name = "large")]')" + + expect_taxonomy_render_failure() { + local name="$1" + local pattern="$2" + shift 2 + if helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set taxonomy.enabled=true \ + --set taxonomy.llm.baseUrl=https://llm.example.com/v1 \ + --set-string taxonomy.llm.contextWindowTokens=65536 \ + "$@" \ + --show-only templates/taxonomy-deployment.yaml \ + > "$render_dir/$name.yaml" 2>&1; then + echo "Expected taxonomy render '$name' to fail" >&2 + exit 1 + fi + grep -q "$pattern" "$render_dir/$name.yaml" + } helm lint "$chart" --set formbricks.webappUrl=https://qa.example.com @@ -129,6 +153,18 @@ jobs: grep -A1 'name: EMBEDDING_BATCH_SIZE' "$render_dir/hub-embeddings-legacy.yaml" | grep -q 'value: "1"' grep -A1 'name: EMBEDDING_BATCH_MAX_WAIT_MS' "$render_dir/hub-embeddings-legacy.yaml" | grep -q 'value: "25"' + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set hub.embeddings.enabled=true \ + --show-only templates/hub-deployment.yaml > "$render_dir/hub-embeddings-legacy-api.yaml" + ! grep -q 'name: EMBEDDING_HTTP_DISABLE_KEEP_ALIVES' "$render_dir/hub-embeddings-legacy-api.yaml" + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set hub.embeddings.enabled=true \ + --show-only templates/hub-worker-deployment.yaml > "$render_dir/hub-embeddings-legacy-worker.yaml" + grep -A1 'name: EMBEDDING_HTTP_DISABLE_KEEP_ALIVES' "$render_dir/hub-embeddings-legacy-worker.yaml" | grep -q 'value: "false"' + helm template qa "$chart" \ --set formbricks.webappUrl=https://qa.example.com \ --set hub.embeddings.enabled=true \ @@ -137,6 +173,7 @@ jobs: --set-string hub.embeddings.background.batchSize=8 \ --set-string hub.embeddings.background.batchMaxWaitMs=25 \ --set-string hub.embeddings.background.batchMaxInFlight=3 \ + --set-string hub.embeddings.background.httpDisableKeepAlives=true \ --set hub.embeddings.background.persistence.storageClass=gp3 \ --set hub.embeddings.background.autoscaling.enabled=true \ --show-only templates/hub-deployment.yaml \ @@ -158,6 +195,33 @@ jobs: grep -q '^ maxReplicas: 6$' "$render_dir/hub-embeddings-background.yaml" grep -q 'stabilizationWindowSeconds: 900' "$render_dir/hub-embeddings-background.yaml" + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set hub.embeddings.enabled=true \ + --set hub.embeddings.background.enabled=true \ + --set-string hub.embeddings.background.httpDisableKeepAlives=true \ + --show-only templates/hub-deployment.yaml > "$render_dir/hub-embeddings-background-api.yaml" + ! grep -q 'name: EMBEDDING_HTTP_DISABLE_KEEP_ALIVES' "$render_dir/hub-embeddings-background-api.yaml" + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set hub.embeddings.enabled=true \ + --set hub.embeddings.background.enabled=true \ + --set-string hub.embeddings.background.httpDisableKeepAlives=true \ + --show-only templates/hub-worker-deployment.yaml > "$render_dir/hub-embeddings-background-worker.yaml" + grep -A1 'name: EMBEDDING_HTTP_DISABLE_KEEP_ALIVES' "$render_dir/hub-embeddings-background-worker.yaml" | grep -q 'value: "true"' + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set hub.embeddings.enabled=true \ + --set hub.embeddings.background.enabled=true \ + --set-string hub.embeddings.background.httpDisableKeepAlives=true \ + --set-string hub.env.EMBEDDING_HTTP_DISABLE_KEEP_ALIVES=true \ + --set-string hub.worker.env.EMBEDDING_HTTP_DISABLE_KEEP_ALIVES=false \ + --show-only templates/hub-worker-deployment.yaml > "$render_dir/hub-embeddings-background-worker-override.yaml" + test "$(grep -c 'name: EMBEDDING_HTTP_DISABLE_KEEP_ALIVES' "$render_dir/hub-embeddings-background-worker-override.yaml")" -eq 1 + grep -A1 'name: EMBEDDING_HTTP_DISABLE_KEEP_ALIVES' "$render_dir/hub-embeddings-background-worker-override.yaml" | grep -q 'value: "false"' + helm template qa "$chart" \ --set formbricks.webappUrl=https://qa.example.com \ --set hub.embeddings.enabled=true \ @@ -187,6 +251,7 @@ jobs: --set formbricks.webappUrl=https://qa.example.com \ --set hub.embeddings.enabled=true \ --set hub.embeddings.background.enabled=true \ + --set-string hub.embeddings.background.httpDisableKeepAlives=true \ --set hub.embeddingBackfill.enabled=true \ --set hub.embeddingBackfill.runId=eu-canary-001 \ --set hub.embeddingBackfill.taxonomy=true \ @@ -199,6 +264,219 @@ jobs: grep -q -- '- --taxonomy' "$render_dir/hub-embedding-backfill.yaml" grep -A1 -- '- --tenant-id' "$render_dir/hub-embedding-backfill.yaml" | grep -q -- '- "tenant-a"' grep -A1 -- '- --max-records' "$render_dir/hub-embedding-backfill.yaml" | grep -q -- '- "500"' + test "$(grep -c 'name: EMBEDDING_HTTP_DISABLE_KEEP_ALIVES' "$render_dir/hub-embedding-backfill.yaml")" -eq 1 + grep -A1 'name: EMBEDDING_HTTP_DISABLE_KEEP_ALIVES' "$render_dir/hub-embedding-backfill.yaml" | grep -q 'value: "true"' + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set taxonomy.enabled=true \ + --set taxonomy.llm.provider=openai-compatible \ + --set taxonomy.llm.model=operator-selected-model \ + --set taxonomy.llm.baseUrl=https://llm.example.com/v1 \ + --set-string taxonomy.llm.contextWindowTokens=65536 \ + --show-only templates/taxonomy-deployment.yaml > "$render_dir/taxonomy-openai-compatible.yaml" + grep -q 'terminationGracePeriodSeconds: 930' "$render_dir/taxonomy-openai-compatible.yaml" + grep -A4 'readinessProbe:' "$render_dir/taxonomy-openai-compatible.yaml" | grep -q 'path: /health' + grep -A1 'name: TAXONOMY_LLM_PROVIDER' "$render_dir/taxonomy-openai-compatible.yaml" | grep -q 'value: "openai-compatible"' + grep -A1 'name: TAXONOMY_LLM_MODEL' "$render_dir/taxonomy-openai-compatible.yaml" | grep -q 'value: "operator-selected-model"' + grep -A1 'name: TAXONOMY_LLM_STRUCTURED_OUTPUT_MODE' "$render_dir/taxonomy-openai-compatible.yaml" | grep -q 'value: "auto"' + grep -A1 'name: TAXONOMY_LLM_CONTEXT_WINDOW_TOKENS' "$render_dir/taxonomy-openai-compatible.yaml" | grep -q 'value: "65536"' + grep -A1 'name: HUB_HEARTBEAT_INTERVAL_SECONDS' "$render_dir/taxonomy-openai-compatible.yaml" | grep -q 'value: "30"' + grep -A1 'name: TAXONOMY_RUN_TIMEOUT_SECONDS' "$render_dir/taxonomy-openai-compatible.yaml" | grep -q 'value: "900"' + grep -A1 'name: TAXONOMY_MAX_RECORDS' "$render_dir/taxonomy-openai-compatible.yaml" | grep -q 'value: "10000"' + ! grep -q 'TAXONOMY_VERTEX_' "$render_dir/taxonomy-openai-compatible.yaml" + ! grep -q 'TAXONOMY_GOOGLE_CLOUD_CREDENTIALS_JSON' "$render_dir/taxonomy-openai-compatible.yaml" + ! grep -q 'name: AWS_REGION' "$render_dir/taxonomy-openai-compatible.yaml" + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set taxonomy.enabled=true \ + --set taxonomy.llm.baseUrl=https://llm.example.com/v1 \ + --set-string taxonomy.llm.contextWindowTokens=65536 \ + --show-only templates/hub-deployment.yaml > "$render_dir/taxonomy-hub-wiring.yaml" + grep -A1 'name: TAXONOMY_STUCK_RUN_TIMEOUT_SECONDS' "$render_dir/taxonomy-hub-wiring.yaml" | grep -q 'value: "1800"' + grep -A1 'name: TAXONOMY_REAPER_INTERVAL_SECONDS' "$render_dir/taxonomy-hub-wiring.yaml" | grep -q 'value: "60"' + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set llm.enabled=true \ + --set-json "llm.servingEngineSpec.modelSpec=$single_bundled_model_spec" \ + --set taxonomy.enabled=true \ + --set taxonomy.llm.structuredOutputMode=json-schema \ + --set-string taxonomy.llm.contextWindowTokens=65536 \ + --show-only templates/taxonomy-deployment.yaml > "$render_dir/taxonomy-vllm.yaml" + grep -A1 'name: TAXONOMY_LLM_STRUCTURED_OUTPUT_MODE' "$render_dir/taxonomy-vllm.yaml" | grep -q 'value: "json-schema"' + grep -q 'router-service:8000/v1' "$render_dir/taxonomy-vllm.yaml" + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set llm.enabled=true \ + --set-json "llm.servingEngineSpec.modelSpec=$multi_bundled_model_spec" \ + --set taxonomy.enabled=true \ + --set taxonomy.llm.bundledModelSpecName=large \ + --set-string taxonomy.llm.contextWindowTokens=65536 \ + --show-only templates/taxonomy-deployment.yaml > "$render_dir/taxonomy-vllm-selected-model.yaml" + grep -q 'router-service:8000/v1' "$render_dir/taxonomy-vllm-selected-model.yaml" + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set llm.enabled=true \ + --set-json "llm.servingEngineSpec.modelSpec=$disabled_bundled_model_spec" \ + --set taxonomy.enabled=true \ + --set-string taxonomy.llm.contextWindowTokens=65536 \ + --show-only templates/taxonomy-deployment.yaml > "$render_dir/taxonomy-vllm-disabled-model.yaml" + grep -q 'router-service:8000/v1' "$render_dir/taxonomy-vllm-disabled-model.yaml" + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set taxonomy.enabled=true \ + --set taxonomy.llm.provider=vertex-gemini \ + --set taxonomy.llm.model=gemini-2.5-flash \ + --set taxonomy.llm.vertex.project=example-project-id \ + --set taxonomy.llm.vertex.location=us-central1 \ + --set-string 'taxonomy.llm.vertex.credentialsJson={}' \ + --set-string taxonomy.llm.contextWindowTokens=1048576 \ + --show-only templates/taxonomy-deployment.yaml > "$render_dir/taxonomy-vertex.yaml" + grep -A1 'name: TAXONOMY_LLM_PROVIDER' "$render_dir/taxonomy-vertex.yaml" | grep -q 'value: "vertex-gemini"' + grep -A1 'name: TAXONOMY_VERTEX_THINKING_BUDGET' "$render_dir/taxonomy-vertex.yaml" | grep -q 'value: "0"' + ! grep -q 'name: TAXONOMY_LLM_BASE_URL' "$render_dir/taxonomy-vertex.yaml" + ! grep -q 'name: TAXONOMY_LLM_API_KEY' "$render_dir/taxonomy-vertex.yaml" + ! grep -q 'name: AWS_REGION' "$render_dir/taxonomy-vertex.yaml" + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set taxonomy.enabled=true \ + --set taxonomy.llm.provider=bedrock \ + --set taxonomy.llm.model=anthropic.claude-test \ + --set taxonomy.llm.bedrock.region=eu-central-1 \ + --set-json taxonomy.env=null \ + --set-string taxonomy.llm.contextWindowTokens=200000 \ + --show-only templates/taxonomy-deployment.yaml > "$render_dir/taxonomy-bedrock.yaml" + grep -A1 'name: TAXONOMY_LLM_STRUCTURED_OUTPUT_MODE' "$render_dir/taxonomy-bedrock.yaml" | grep -q 'value: "auto"' + grep -A1 'name: AWS_REGION' "$render_dir/taxonomy-bedrock.yaml" | grep -q 'value: "eu-central-1"' + ! grep -q 'TAXONOMY_VERTEX_' "$render_dir/taxonomy-bedrock.yaml" + ! grep -q 'TAXONOMY_GOOGLE_CLOUD_CREDENTIALS_JSON' "$render_dir/taxonomy-bedrock.yaml" + ! grep -q 'name: TAXONOMY_LLM_BASE_URL' "$render_dir/taxonomy-bedrock.yaml" + ! grep -q 'name: TAXONOMY_LLM_API_KEY' "$render_dir/taxonomy-bedrock.yaml" + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set taxonomy.enabled=true \ + --set taxonomy.llm.provider=bedrock \ + --set taxonomy.llm.model=operator-selected-bedrock-model \ + --set-json taxonomy.llm.bedrock=null \ + --set-string taxonomy.env.AWS_REGION=eu-central-1 \ + --set-string taxonomy.llm.contextWindowTokens=200000 \ + --show-only templates/taxonomy-deployment.yaml > "$render_dir/taxonomy-bedrock-null-map.yaml" + grep -A1 'name: AWS_REGION' "$render_dir/taxonomy-bedrock-null-map.yaml" | grep -q 'value: "eu-central-1"' + + expect_taxonomy_render_failure \ + taxonomy-provider-invalid \ + "taxonomy.llm.provider must be" \ + --set taxonomy.llm.provider=unsupported-provider + + expect_taxonomy_render_failure \ + taxonomy-model-missing \ + "taxonomy.llm.model is required" \ + --set-string taxonomy.llm.model= + + expect_taxonomy_render_failure \ + taxonomy-context-missing \ + "taxonomy.llm.contextWindowTokens is required" \ + --set-string taxonomy.llm.contextWindowTokens= + + expect_taxonomy_render_failure \ + taxonomy-openai-endpoint-missing \ + "taxonomy.llm.baseUrl or llm.enabled=true is required" \ + --set-string taxonomy.llm.baseUrl= + + expect_taxonomy_render_failure \ + taxonomy-structured-output-invalid \ + "taxonomy.llm.structuredOutputMode must be" \ + --set taxonomy.llm.structuredOutputMode=unsupported-mode + + expect_taxonomy_render_failure \ + taxonomy-bedrock-region-missing \ + "taxonomy.llm.bedrock.region or taxonomy.env.AWS_REGION is required" \ + --set taxonomy.llm.provider=bedrock + + expect_taxonomy_render_failure \ + taxonomy-bedrock-region-empty \ + "taxonomy.llm.bedrock.region or taxonomy.env.AWS_REGION is required" \ + --set taxonomy.llm.provider=bedrock \ + --set-string taxonomy.env.AWS_REGION= + + expect_taxonomy_render_failure \ + taxonomy-bedrock-region-conflict \ + "set either taxonomy.llm.bedrock.region or taxonomy.env.AWS_REGION" \ + --set taxonomy.llm.provider=bedrock \ + --set taxonomy.llm.bedrock.region=eu-central-1 \ + --set-string taxonomy.env.AWS_REGION=us-east-1 + + expect_taxonomy_render_failure \ + taxonomy-managed-env-conflict \ + "set taxonomy.heartbeatIntervalSeconds instead of taxonomy.env.HUB_HEARTBEAT_INTERVAL_SECONDS" \ + --set-string taxonomy.env.HUB_HEARTBEAT_INTERVAL_SECONDS=15 + + expect_taxonomy_render_failure \ + taxonomy-managed-hub-env-conflict \ + "set taxonomy.hubReaperIntervalSeconds instead of hub.env.TAXONOMY_REAPER_INTERVAL_SECONDS" \ + --set-string hub.env.TAXONOMY_REAPER_INTERVAL_SECONDS=30 + + expect_taxonomy_render_failure \ + taxonomy-vllm-model-selector-required \ + "taxonomy.llm.bundledModelSpecName is required when more than one bundled model is enabled" \ + --set llm.enabled=true \ + --set-string taxonomy.llm.baseUrl= \ + --set-json "llm.servingEngineSpec.modelSpec=$multi_bundled_model_spec" + + expect_taxonomy_render_failure \ + taxonomy-vllm-model-selector-missing \ + "taxonomy.llm.bundledModelSpecName must identify exactly one enabled" \ + --set llm.enabled=true \ + --set-string taxonomy.llm.baseUrl= \ + --set taxonomy.llm.bundledModelSpecName=missing \ + --set-json "llm.servingEngineSpec.modelSpec=$multi_bundled_model_spec" + + expect_taxonomy_render_failure \ + taxonomy-vllm-selected-context-invalid \ + "selected bundled vLLM maxModelLen must be >= taxonomy.llm.contextWindowTokens" \ + --set llm.enabled=true \ + --set-string taxonomy.llm.baseUrl= \ + --set taxonomy.llm.bundledModelSpecName=small \ + --set-json "llm.servingEngineSpec.modelSpec=$multi_bundled_model_spec" + + expect_taxonomy_render_failure \ + taxonomy-vllm-context-not-numeric \ + "taxonomy.llm.contextWindowTokens must be a positive integer when taxonomy uses the bundled vLLM runtime" \ + --set llm.enabled=true \ + --set-string taxonomy.llm.baseUrl= \ + --set-string taxonomy.llm.contextWindowTokens=not-a-number \ + --set-json "llm.servingEngineSpec.modelSpec=$single_bundled_model_spec" + + if helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set llm.enabled=true \ + --set taxonomy.enabled=true \ + --set-string taxonomy.llm.contextWindowTokens=65536 \ + --show-only templates/taxonomy-deployment.yaml \ + > "$render_dir/taxonomy-vllm-context-invalid.yaml" 2>&1; then + echo "Expected bundled vLLM context above maxModelLen to fail" >&2 + exit 1 + fi + grep -q 'selected bundled vLLM maxModelLen must be >= taxonomy.llm.contextWindowTokens' \ + "$render_dir/taxonomy-vllm-context-invalid.yaml" + + helm template qa "$chart" \ + --set formbricks.webappUrl=https://qa.example.com \ + --set taxonomy.enabled=true \ + --set taxonomy.llm.baseUrl=https://llm.example.com/v1 \ + --set-string taxonomy.maxRecords=50000 \ + --set-string taxonomy.llm.contextWindowTokens=65536 \ + --set-string taxonomy.env.TAXONOMY_LLM_SUBTREE_MAX_CLUSTERS=30 \ + --show-only templates/taxonomy-deployment.yaml > "$render_dir/taxonomy-upgrade.yaml" + grep -A1 'name: TAXONOMY_MAX_RECORDS' "$render_dir/taxonomy-upgrade.yaml" | grep -q 'value: "50000"' + grep -A1 'name: TAXONOMY_LLM_SUBTREE_MAX_CLUSTERS' "$render_dir/taxonomy-upgrade.yaml" | grep -q 'value: "30"' helm template qa "$chart" \ --set formbricks.webappUrl=https://qa.example.com \ diff --git a/.github/workflows/release-helm-chart.yml b/.github/workflows/release-helm-chart.yml index 7db8ebe9cace..ae73eb93c6e0 100644 --- a/.github/workflows/release-helm-chart.yml +++ b/.github/workflows/release-helm-chart.yml @@ -192,11 +192,13 @@ jobs: formbricks: webappUrl: https://qa.example.com - llm: - enabled: true - taxonomy: enabled: true + llm: + provider: openai-compatible + model: operator-selected-model + baseUrl: https://llm.example.com/v1 + contextWindowTokens: "65536" YAML rendered="$(helm template qa charts/formbricks \ @@ -213,8 +215,12 @@ jobs: "value: \"http://formbricks-taxonomy:8000\"" \ "name: HUB_INTERNAL_API_URL" \ "value: \"http://formbricks-hub:8080\"" \ + "name: TAXONOMY_LLM_PROVIDER" \ + "value: \"openai-compatible\"" \ + "name: TAXONOMY_LLM_MODEL" \ + "value: \"operator-selected-model\"" \ "name: TAXONOMY_LLM_BASE_URL" \ - "value: \"http://qa-router-service:8000/v1\"" \ + "value: \"https://llm.example.com/v1\"" \ "key: TAXONOMY_SERVICE_TOKEN" \ "key: HUB_INTERNAL_API_TOKEN"; do if ! grep -F "$expected" <<< "$rendered" >/dev/null; then diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts index 59db4072f253..269d3521cfd4 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts @@ -13,6 +13,7 @@ import { ZContactAttributeKeyUpdateSchema, } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { checkContactsEnabledApiV2 } from "@/modules/ee/license-check/lib/contacts-api-guard"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; export const GET = async ( @@ -28,6 +29,11 @@ export const GET = async ( handler: async ({ authentication, parsedInput }) => { const { params } = parsedInput; + const contactsNotEnabledError = await checkContactsEnabledApiV2(authentication.organizationId); + if (contactsNotEnabledError) { + return handleApiError(request, contactsNotEnabledError); + } + const res = await getContactAttributeKey(params.contactAttributeKeyId); if (!res.ok) { @@ -63,6 +69,11 @@ export const PUT = async ( auditLog.targetId = params.contactAttributeKeyId; } + const contactsNotEnabledError = await checkContactsEnabledApiV2(authentication.organizationId); + if (contactsNotEnabledError) { + return handleApiError(request, contactsNotEnabledError, auditLog); + } + const res = await getContactAttributeKey(params.contactAttributeKeyId); if (!res.ok) { @@ -135,6 +146,11 @@ export const DELETE = async ( auditLog.targetId = params.contactAttributeKeyId; } + const contactsNotEnabledError = await checkContactsEnabledApiV2(authentication.organizationId); + if (contactsNotEnabledError) { + return handleApiError(request, contactsNotEnabledError, auditLog); + } + const res = await getContactAttributeKey(params.contactAttributeKeyId); if (!res.ok) { diff --git a/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts b/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts index 522d2d6ec0a3..b61b2efb5bda 100644 --- a/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts +++ b/apps/web/modules/api/v2/management/contact-attribute-keys/route.ts @@ -12,6 +12,7 @@ import { } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys"; import { resolveBodyIdsV2 } from "@/modules/api/v2/management/lib/workspace-resolver"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { checkContactsEnabledApiV2 } from "@/modules/ee/license-check/lib/contacts-api-guard"; export const GET = async (request: NextRequest) => authenticatedApiClient({ @@ -22,6 +23,11 @@ export const GET = async (request: NextRequest) => handler: async ({ authentication, parsedInput }) => { const { query } = parsedInput; + const contactsNotEnabledError = await checkContactsEnabledApiV2(authentication.organizationId); + if (contactsNotEnabledError) { + return handleApiError(request, contactsNotEnabledError); + } + const workspaceIds = [ ...new Set(authentication.workspacePermissions.map((permission) => permission.workspaceId)), ]; @@ -47,9 +53,14 @@ export const POST = async (request: NextRequest) => if (!resolved.ok) throw resolved.error; return { ...body, ...resolved.data }; }, - handler: async ({ parsedInput, auditLog }) => { + handler: async ({ authentication, parsedInput, auditLog }) => { const { body } = parsedInput; + const contactsNotEnabledError = await checkContactsEnabledApiV2(authentication.organizationId); + if (contactsNotEnabledError) { + return handleApiError(request, contactsNotEnabledError, auditLog); + } + const createContactAttributeKeyResult = await createContactAttributeKey(body); if (!createContactAttributeKeyResult.ok) { diff --git a/apps/web/modules/ee/contacts/[contactId]/actions.ts b/apps/web/modules/ee/contacts/[contactId]/actions.ts index 360bc759a37e..aa755b1dcfeb 100644 --- a/apps/web/modules/ee/contacts/[contactId]/actions.ts +++ b/apps/web/modules/ee/contacts/[contactId]/actions.ts @@ -12,6 +12,7 @@ import { getWorkspaceIdFromSurveyId, } from "@/lib/utils/helper"; import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link"; +import { ensureContactsEnabled } from "@/modules/ee/contacts/lib/contacts-entitlement"; import { CONTACT_SURVEY_WORKSPACE_MISMATCH_ERROR_CODE } from "@/modules/ee/contacts/lib/personal-link-errors"; const ZGeneratePersonalSurveyLinkAction = z.object({ @@ -42,6 +43,8 @@ export const generatePersonalSurveyLinkAction = authenticatedActionClient ], }); + await ensureContactsEnabled(organizationId); + // Cross-tenant guard: the survey must belong to the same workspace as the // contact the caller was authorized against. Authorization above is derived // from `contactId` only, so without this a caller could pass a `surveyId` diff --git a/apps/web/modules/ee/contacts/actions.ts b/apps/web/modules/ee/contacts/actions.ts index 59d43367bd4d..2fd780a31243 100644 --- a/apps/web/modules/ee/contacts/actions.ts +++ b/apps/web/modules/ee/contacts/actions.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { prisma } from "@formbricks/database"; import { ZId } from "@formbricks/types/common"; import { ZContactAttributesInput } from "@formbricks/types/contact-attribute"; -import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { ResourceNotFoundError } from "@formbricks/types/errors"; import { capturePostHogEvent } from "@/lib/posthog"; import { authenticatedActionClient } from "@/lib/utils/action-client"; import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware"; @@ -14,7 +14,7 @@ import { getWorkspaceIdFromContactId, } from "@/lib/utils/helper"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; -import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; +import { ensureContactsEnabled } from "@/modules/ee/contacts/lib/contacts-entitlement"; import { createContactsFromCSV, deleteContact, getContact, getContacts } from "./lib/contacts"; import { updateContactAttributes } from "./lib/update-contact-attributes"; import { @@ -51,10 +51,7 @@ export const getContactsAction = authenticatedActionClient ], }); - const isContactsEnabled = await getIsContactsEnabled(organizationId); - if (!isContactsEnabled) { - throw new OperationNotAllowedError("Contacts are not enabled for this organization"); - } + await ensureContactsEnabled(organizationId); return getContacts(workspaceId, parsedInput.offset, parsedInput.searchValue); }); @@ -84,6 +81,8 @@ export const deleteContactAction = authenticatedActionClient.inputSchema(ZContac ], }); + await ensureContactsEnabled(organizationId); + ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.contactId = parsedInput.contactId; @@ -123,6 +122,8 @@ export const createContactsFromCSVAction = authenticatedActionClient ], }); + await ensureContactsEnabled(organizationId); + ctx.auditLoggingCtx.organizationId = organizationId; const existingContactCount = await prisma.contact.count({ where: { workspaceId }, @@ -186,6 +187,8 @@ export const updateContactAttributesAction = authenticatedActionClient ], }); + await ensureContactsEnabled(organizationId); + ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.contactId = parsedInput.contactId; diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts index da1e8d598da9..f0eba0ce9ffe 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/[contactAttributeKeyId]/route.ts @@ -4,6 +4,8 @@ import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { TApiKeyAuthentication, THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { CONTACTS_API_V1_NOT_ENABLED_MESSAGE } from "@/modules/ee/contacts/lib/contacts-entitlement"; +import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { deleteContactAttributeKey, @@ -14,15 +16,22 @@ import { ZContactAttributeKeyUpdateInput } from "./types/contact-attribute-keys" async function fetchAndAuthorizeContactAttributeKey( attributeKeyId: string, - workspacePermissions: NonNullable["workspacePermissions"], + authentication: NonNullable, requiredPermission: "GET" | "PUT" | "DELETE" ) { + // Entitlement first, matching the plural route: without the contacts feature the caller may + // not interact with attribute keys at all, regardless of workspace permissions. + const isContactsEnabled = await getIsContactsEnabled(authentication.organizationId); + if (!isContactsEnabled) { + return { error: responses.forbiddenResponse(CONTACTS_API_V1_NOT_ENABLED_MESSAGE) }; + } + const attributeKey = await getContactAttributeKey(attributeKeyId); if (!attributeKey) { return { error: responses.notFoundResponse("Attribute Key", attributeKeyId) }; } - if (!hasPermission(workspacePermissions, attributeKey.workspaceId, requiredPermission)) { + if (!hasPermission(authentication.workspacePermissions, attributeKey.workspaceId, requiredPermission)) { return { error: responses.unauthorizedResponse() }; } @@ -42,7 +51,7 @@ export const GET = withV1ApiWrapper({ const result = await fetchAndAuthorizeContactAttributeKey( params.contactAttributeKeyId, - authentication.workspacePermissions, + authentication, "GET" ); if (result.error) { @@ -55,14 +64,6 @@ export const GET = withV1ApiWrapper({ response: responses.successResponse(result.attributeKey), }; } catch (error) { - if ( - error instanceof Error && - error.message === "Contacts are only enabled for Enterprise Edition, please upgrade." - ) { - return { - response: responses.forbiddenResponse(error.message), - }; - } return handleErrorResponse(error); } }, @@ -85,7 +86,7 @@ export const DELETE = withV1ApiWrapper({ try { const result = await fetchAndAuthorizeContactAttributeKey( params.contactAttributeKeyId, - authentication.workspacePermissions, + authentication, "DELETE" ); @@ -132,7 +133,7 @@ export const PUT = withV1ApiWrapper({ try { const result = await fetchAndAuthorizeContactAttributeKey( params.contactAttributeKeyId, - authentication.workspacePermissions, + authentication, "PUT" ); if (result.error) { diff --git a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/route.ts b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/route.ts index bcd63150e387..50f39ccdb940 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/route.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/route.ts @@ -5,6 +5,7 @@ import { RequestBodyTooLargeError, parseJsonBodyWithLimit } from "@/app/lib/api/ import { responses } from "@/app/lib/api/response"; import { transformErrorToDetails } from "@/app/lib/api/validator"; import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging"; +import { CONTACTS_API_V1_NOT_ENABLED_MESSAGE } from "@/modules/ee/contacts/lib/contacts-entitlement"; import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { ZContactAttributeKeyCreateInput } from "./[contactAttributeKeyId]/types/contact-attribute-keys"; @@ -20,9 +21,7 @@ export const GET = withV1ApiWrapper({ const isContactsEnabled = await getIsContactsEnabled(authentication.organizationId); if (!isContactsEnabled) { return { - response: responses.forbiddenResponse( - "Contacts are only enabled for Enterprise Edition, please upgrade." - ), + response: responses.forbiddenResponse(CONTACTS_API_V1_NOT_ENABLED_MESSAGE), }; } @@ -56,9 +55,7 @@ export const POST = withV1ApiWrapper({ const isContactsEnabled = await getIsContactsEnabled(authentication.organizationId); if (!isContactsEnabled) { return { - response: responses.forbiddenResponse( - "Contacts are only enabled for Enterprise Edition, please upgrade." - ), + response: responses.forbiddenResponse(CONTACTS_API_V1_NOT_ENABLED_MESSAGE), }; } diff --git a/apps/web/modules/ee/contacts/attributes/actions.ts b/apps/web/modules/ee/contacts/attributes/actions.ts index 18dc7533ee34..15a96d3330a2 100644 --- a/apps/web/modules/ee/contacts/attributes/actions.ts +++ b/apps/web/modules/ee/contacts/attributes/actions.ts @@ -20,6 +20,7 @@ import { getContactAttributeKeyById, updateContactAttributeKey, } from "@/modules/ee/contacts/lib/contact-attribute-keys"; +import { ensureContactsEnabled } from "@/modules/ee/contacts/lib/contacts-entitlement"; const ZCreateContactAttributeKeyAction = z.object({ workspaceId: ZId, @@ -60,6 +61,8 @@ export const createContactAttributeKeyAction = authenticatedActionClient ], }); + await ensureContactsEnabled(organizationId); + ctx.auditLoggingCtx.organizationId = organizationId; const contactAttributeKey = await createContactAttributeKey({ @@ -122,6 +125,8 @@ export const updateContactAttributeKeyAction = authenticatedActionClient ], }); + await ensureContactsEnabled(organizationId); + ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.oldObject = existingKey; @@ -169,6 +174,8 @@ export const deleteContactAttributeKeyAction = authenticatedActionClient ], }); + await ensureContactsEnabled(organizationId); + ctx.auditLoggingCtx.organizationId = organizationId; ctx.auditLoggingCtx.oldObject = existingKey; diff --git a/apps/web/modules/ee/contacts/lib/contacts-entitlement.test.ts b/apps/web/modules/ee/contacts/lib/contacts-entitlement.test.ts new file mode 100644 index 000000000000..68e39b989520 --- /dev/null +++ b/apps/web/modules/ee/contacts/lib/contacts-entitlement.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { OperationNotAllowedError } from "@formbricks/types/errors"; + +const mocks = vi.hoisted(() => ({ + getIsContactsEnabled: vi.fn(), +})); + +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ + getIsContactsEnabled: mocks.getIsContactsEnabled, +})); + +const { CONTACTS_NOT_ENABLED_MESSAGE, ensureContactsEnabled } = await import("./contacts-entitlement"); + +describe("ensureContactsEnabled", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("throws OperationNotAllowedError when the entitlement is missing", async () => { + mocks.getIsContactsEnabled.mockResolvedValue(false); + + await expect(ensureContactsEnabled("org1")).rejects.toThrow(OperationNotAllowedError); + await expect(ensureContactsEnabled("org1")).rejects.toThrow(CONTACTS_NOT_ENABLED_MESSAGE); + expect(mocks.getIsContactsEnabled).toHaveBeenCalledWith("org1"); + }); + + test("resolves when the entitlement is present", async () => { + mocks.getIsContactsEnabled.mockResolvedValue(true); + + await expect(ensureContactsEnabled("org1")).resolves.toBeUndefined(); + }); +}); diff --git a/apps/web/modules/ee/contacts/lib/contacts-entitlement.ts b/apps/web/modules/ee/contacts/lib/contacts-entitlement.ts new file mode 100644 index 000000000000..1cd1bc579664 --- /dev/null +++ b/apps/web/modules/ee/contacts/lib/contacts-entitlement.ts @@ -0,0 +1,27 @@ +import "server-only"; +import { OperationNotAllowedError } from "@formbricks/types/errors"; +import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; + +export const CONTACTS_NOT_ENABLED_MESSAGE = "Contacts are not enabled for this organization"; + +/** + * The exact string the v1 management routes have always returned for a missing contacts + * entitlement — kept verbatim for API consumers that match on it. + */ +export const CONTACTS_API_V1_NOT_ENABLED_MESSAGE = + "Contacts are only enabled for Enterprise Edition, please upgrade."; + +/** + * Module-boundary guard for the contacts (EE) entitlement, for server actions. + * + * Every server action in `modules/ee/contacts` that reads or writes contact data must call this + * right after authorization. The entitlement check used to be inlined per call site and rotted + * out of several write paths over time (it survived only in some siblings), so new call sites + * must go through this helper instead of re-inlining `getIsContactsEnabled`. + */ +export const ensureContactsEnabled = async (organizationId: string): Promise => { + const isContactsEnabled = await getIsContactsEnabled(organizationId); + if (!isContactsEnabled) { + throw new OperationNotAllowedError(CONTACTS_NOT_ENABLED_MESSAGE); + } +}; diff --git a/apps/web/modules/ee/contacts/segments/actions.ts b/apps/web/modules/ee/contacts/segments/actions.ts index eddd8776cb3d..6e2689ccfe25 100644 --- a/apps/web/modules/ee/contacts/segments/actions.ts +++ b/apps/web/modules/ee/contacts/segments/actions.ts @@ -344,9 +344,11 @@ const ZGetDistinctAttributeValuesAction = z.object({ export const getDistinctAttributeValuesAction = authenticatedActionClient .inputSchema(ZGetDistinctAttributeValuesAction) .action(async ({ ctx, parsedInput }) => { + const organizationId = await getOrganizationIdFromContactAttributeKeyId(parsedInput.attributeKeyId); + await checkAuthorizationUpdated({ userId: ctx.user.id, - organizationId: await getOrganizationIdFromContactAttributeKeyId(parsedInput.attributeKeyId), + organizationId, access: [ { type: "organization", @@ -360,6 +362,8 @@ export const getDistinctAttributeValuesAction = authenticatedActionClient ], }); + await checkAdvancedTargetingPermission(organizationId); + return await getDistinctAttributeValues(parsedInput.attributeKeyId); }); diff --git a/apps/web/modules/ee/license-check/lib/contacts-api-guard.test.ts b/apps/web/modules/ee/license-check/lib/contacts-api-guard.test.ts new file mode 100644 index 000000000000..fa409f804bec --- /dev/null +++ b/apps/web/modules/ee/license-check/lib/contacts-api-guard.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getIsContactsEnabled: vi.fn(), +})); + +vi.mock("@/modules/ee/license-check/lib/utils", () => ({ + getIsContactsEnabled: mocks.getIsContactsEnabled, +})); + +const { checkContactsEnabledApiV2 } = await import("./contacts-api-guard"); + +describe("checkContactsEnabledApiV2", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("returns a forbidden error object when the entitlement is missing", async () => { + mocks.getIsContactsEnabled.mockResolvedValue(false); + + const error = await checkContactsEnabledApiV2("org1"); + + expect(error).toEqual({ + type: "forbidden", + details: [{ field: "contacts", issue: "Contacts feature is not enabled for this organization" }], + }); + expect(mocks.getIsContactsEnabled).toHaveBeenCalledWith("org1"); + }); + + test("returns null when the entitlement is present", async () => { + mocks.getIsContactsEnabled.mockResolvedValue(true); + + await expect(checkContactsEnabledApiV2("org1")).resolves.toBeNull(); + }); +}); diff --git a/apps/web/modules/ee/license-check/lib/contacts-api-guard.ts b/apps/web/modules/ee/license-check/lib/contacts-api-guard.ts new file mode 100644 index 000000000000..14068af2048e --- /dev/null +++ b/apps/web/modules/ee/license-check/lib/contacts-api-guard.ts @@ -0,0 +1,26 @@ +import "server-only"; +import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils"; + +/** + * Contacts entitlement guard for the v2/v3-flavored management APIs: returns the `forbidden` + * error object for `handleApiError` when the entitlement is missing, `null` when the caller may + * proceed. + * + * Lives in license-check (not `modules/ee/contacts`) because the v2 attribute-key routes under + * the OSS `modules/api/v2` path consume it, and license-check is the sanctioned import boundary + * for OSS code. The server-action flavour (`ensureContactsEnabled`) stays in + * `modules/ee/contacts/lib/contacts-entitlement.ts`, which only EE code imports. + */ +export const checkContactsEnabledApiV2 = async ( + organizationId: string +): Promise => { + const isContactsEnabled = await getIsContactsEnabled(organizationId); + if (isContactsEnabled) { + return null; + } + return { + type: "forbidden", + details: [{ field: "contacts", issue: "Contacts feature is not enabled for this organization" }], + }; +}; diff --git a/apps/web/modules/survey/editor/components/add-element-to-block-button.tsx b/apps/web/modules/survey/editor/components/add-element-to-block-button.tsx index 343542155df3..becd4b812e2c 100644 --- a/apps/web/modules/survey/editor/components/add-element-to-block-button.tsx +++ b/apps/web/modules/survey/editor/components/add-element-to-block-button.tsx @@ -7,16 +7,13 @@ import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { type Workspace } from "@formbricks/database/prisma-browser"; import { TSurveyBlock } from "@formbricks/types/surveys/blocks"; -import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurvey } from "@formbricks/types/surveys/types"; import { addMultiLanguageLabels, extractLanguageCodes } from "@/lib/i18n/utils"; import { addElementToBlock } from "@/modules/survey/editor/lib/blocks"; import { scrollElementCardIntoView } from "@/modules/survey/editor/lib/utils"; import { - getCXElementNameMap, getElementDefaults, - getElementIconMap, - getElementNameMap, + getGroupedElementTypes, universalElementPresets, } from "@/modules/survey/lib/elements"; import { Button } from "@/modules/ui/components/button"; @@ -24,6 +21,8 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/modules/ui/components/dropdown-menu"; @@ -46,8 +45,7 @@ export const AddElementToBlockButton = ({ }: AddElementToBlockButtonProps) => { const { t } = useTranslation(); const [open, setOpen] = useState(false); - const availableElementTypes = isCxMode ? getCXElementNameMap(t) : getElementNameMap(t); - const ELEMENTS_ICON_MAP = getElementIconMap(t); + const groupedElementTypes = getGroupedElementTypes(t, isCxMode); const handleAddElement = (elementType: string) => { // Get language symbols and add multi-language support @@ -91,11 +89,22 @@ export const AddElementToBlockButton = ({ - {Object.entries(availableElementTypes).map(([type, name]) => ( - handleAddElement(type)}> - {ELEMENTS_ICON_MAP[type as TSurveyElementTypeEnum]} - {name} - + {groupedElementTypes.map((group, index) => ( +
+ {index > 0 && } + + {group.category.label} + + {group.elements.map((elementType) => ( + handleAddElement(elementType.id)}> + + {elementType.label} + + ))} +
))}
diff --git a/apps/web/modules/survey/editor/components/editor-card-menu.tsx b/apps/web/modules/survey/editor/components/editor-card-menu.tsx index 43e1561e4083..cdde93ecd938 100644 --- a/apps/web/modules/survey/editor/components/editor-card-menu.tsx +++ b/apps/web/modules/survey/editor/components/editor-card-menu.tsx @@ -10,18 +10,15 @@ import { TSurveyBlockLogic } from "@formbricks/types/surveys/blocks"; import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements"; import { TSurvey, TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types"; import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; -import { - getCXElementNameMap, - getElementDefaults, - getElementIconMap, - getElementNameMap, -} from "@/modules/survey/lib/elements"; +import { getElementDefaults, getGroupedElementTypes } from "@/modules/survey/lib/elements"; import { Button } from "@/modules/ui/components/button"; import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, @@ -73,7 +70,6 @@ export const EditorCardMenu = ({ isCxMode = false, }: EditorCardMenuProps) => { const { t } = useTranslation(); - const ELEMENTS_ICON_MAP = getElementIconMap(t); const [logicWarningModal, setLogicWarningModal] = useState(false); const [changeToType, setChangeToType] = useState(() => { if (card.type !== "endScreen" && card.type !== "redirectToUrl") { @@ -87,7 +83,7 @@ export const EditorCardMenu = ({ const isDeleteDisabled = cardType === "element" ? elements.length === 1 : survey.type === "link" && survey.endings.length === 1; - const availableElementTypes = isCxMode ? getCXElementNameMap(t) : getElementNameMap(t); + const groupedElementTypes = getGroupedElementTypes(t, isCxMode); const changeElementType = (type?: TSurveyElementTypeEnum) => { if (!type) return; @@ -237,25 +233,36 @@ export const EditorCardMenu = ({ - {Object.entries(availableElementTypes).map(([type, name]) => { - if (type === card.type) return null; - return ( - { - setChangeToType(type as TSurveyElementTypeEnum); - if ((card as EditorCardMenuSurveyElement).logic) { - setLogicWarningModal(true); - return; - } + {groupedElementTypes + .map((group) => ({ + ...group, + elements: group.elements.filter((elementType) => elementType.id !== card.type), + })) + .filter((group) => group.elements.length > 0) + .map((group, index) => ( +
+ {index > 0 && } + + {group.category.label} + + {group.elements.map((elementType) => ( + { + setChangeToType(elementType.id as TSurveyElementTypeEnum); + if ((card as EditorCardMenuSurveyElement).logic) { + setLogicWarningModal(true); + return; + } - changeElementType(type as TSurveyElementTypeEnum); - }} - icon={ELEMENTS_ICON_MAP[type as TSurveyElementTypeEnum]}> - {name} - - ); - })} + changeElementType(elementType.id as TSurveyElementTypeEnum); + }} + icon={}> + {elementType.label} + + ))} +
+ ))}
)} @@ -277,22 +284,28 @@ export const EditorCardMenu = ({ - {Object.entries(availableElementTypes).map(([type, name]) => { - return ( - { - e.stopPropagation(); - if (cardType === "element") { - addElementCardBelow(type as TSurveyElementTypeEnum); - } - }}> - {ELEMENTS_ICON_MAP[type as TSurveyElementTypeEnum]} - {name} - - ); - })} + {groupedElementTypes.map((group, index) => ( +
+ {index > 0 && } + + {group.category.label} + + {group.elements.map((elementType) => ( + { + e.stopPropagation(); + if (cardType === "element") { + addElementCardBelow(elementType.id as TSurveyElementTypeEnum); + } + }}> + + {elementType.label} + + ))} +
+ ))}
)} diff --git a/apps/web/modules/survey/lib/elements.tsx b/apps/web/modules/survey/lib/elements.tsx index 4b76272a7188..df974f057958 100644 --- a/apps/web/modules/survey/lib/elements.tsx +++ b/apps/web/modules/survey/lib/elements.tsx @@ -368,6 +368,33 @@ export const getCXElementTypes = (t: TFunction) => ].includes(elementType.id as TSurveyElementTypeEnum); }); +export type TElementCategoryGroup = { + category: TElementCategoryMeta; + elements: TElement[]; +}; + +/** + * Groups the available element types by category, in the same category order and + * within-category order as the "Add Block" picker, omitting categories with no elements. + * Lets other element-type pickers (e.g. "Add question to block", "Change question type") + * reuse the same sections/ordering instead of rendering a flat list. + */ +export const getGroupedElementTypes = (t: TFunction, isCxMode = false): TElementCategoryGroup[] => { + const availableElementTypes = isCxMode ? getCXElementTypes(t) : getElementTypes(t); + const categories = getElementCategories(t); + + const elementsByCategory = new Map(); + for (const elementType of availableElementTypes) { + const group = elementsByCategory.get(elementType.category) ?? []; + group.push(elementType); + elementsByCategory.set(elementType.category, group); + } + + return categories + .map((category) => ({ category, elements: elementsByCategory.get(category.id) ?? [] })) + .filter((group) => group.elements.length > 0); +}; + export const getElementIconMap = (t: TFunction): Record => getElementTypes(t).reduce( (prev, curr) => ({ diff --git a/apps/web/modules/ui/components/dropdown-menu/index.tsx b/apps/web/modules/ui/components/dropdown-menu/index.tsx index f6dcca56f521..c07dd5acaa7f 100644 --- a/apps/web/modules/ui/components/dropdown-menu/index.tsx +++ b/apps/web/modules/ui/components/dropdown-menu/index.tsx @@ -53,7 +53,7 @@ const DropdownMenuSubContent: React.ComponentType HUB_INTERNAL_API_TOKEN: +TAXONOMY_STUCK_RUN_TIMEOUT_SECONDS: "1800" +TAXONOMY_REAPER_INTERVAL_SECONDS: "60" ``` If `llm.enabled=true` and `taxonomy.llm.baseUrl` is empty, taxonomy uses the bundled vLLM router at @@ -281,13 +354,50 @@ If `llm.enabled=true` and `taxonomy.llm.baseUrl` is empty, taxonomy uses the bun taxonomy: enabled: true llm: + provider: openai-compatible model: qwen3-14b-awq baseUrl: http://my-llm-gateway:8000/v1 existingSecret: taxonomy-llm-secret + structuredOutputMode: json-object + contextWindowTokens: "65536" ``` -The taxonomy service exposes public `/health` only for Kubernetes probes. Use authenticated `/v1/preflight` as an -operator check after install: +Generic OpenAI-compatible endpoints default to JSON-object mode; set `json-schema` only after the exact +deployment passes preflight. Vertex selects JSON Schema automatically. Bedrock selects prompt-only mode unless +you opt an exact supported model into schema mode. + +To use Amazon Bedrock instead: + +```yaml +taxonomy: + enabled: true + llm: + provider: bedrock + model: your-bedrock-model-id + contextWindowTokens: "200000" + bedrock: + region: us-east-1 +``` + +Prefer an IAM role delivered to the pod through EKS Pod Identity, IRSA, or the equivalent workload-identity +mechanism for your cluster. Configure that association for the Kubernetes service account used by the Taxonomy +pod. If role-based credentials are unavailable, create a Kubernetes Secret outside the values file and load it +through `taxonomy.envFrom` so the AWS SDK can read `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and, when +required, `AWS_SESSION_TOKEN`: + +```yaml +taxonomy: + envFrom: + - secretRef: + name: taxonomy-aws-credentials +``` + +Never put AWS credentials in `taxonomy.env`, a committed values file, or `--set` arguments. + +The default `v0.1.0` taxonomy image exposes public `/health`, so the chart uses it for both liveness and readiness +probes. Taxonomy images that implement the newer readiness contract also expose `/ready` for cached Hub-auth, +context-budget, provider, and structured-output readiness; set `taxonomy.probes.readinessProbe.httpGet.path` to +`/ready` only with such an image. Use authenticated `/v1/preflight` as an operator check after install: ```sh kubectl exec -n formbricks deploy/formbricks-taxonomy -- \ @@ -302,13 +412,14 @@ taxonomy: llm: provider: vertex-gemini model: gemini-2.5-flash + contextWindowTokens: "1048576" vertex: - project: formbricks-cloud - location: europe-west3 - existingSecret: taxonomy-vertex-secret + project: example-project-id + location: us-central1 + existingSecret: taxonomy-vertex-credentials ``` -The `taxonomy-vertex-secret` secret must contain `TAXONOMY_GOOGLE_CLOUD_CREDENTIALS_JSON` with service-account +The `taxonomy-vertex-credentials` secret must contain `TAXONOMY_GOOGLE_CLOUD_CREDENTIALS_JSON` with service-account JSON that can call Vertex AI. ## Hub and Taxonomy metrics and structured logs @@ -467,6 +578,7 @@ tokens, provider response bodies, and collector URLs are never telemetry fields. | hub.embeddings.background.batchMaxWaitMs | string | `"25"` | | | hub.embeddings.background.batchSize | string | `"1"` | | | hub.embeddings.background.enabled | bool | `false` | | +| hub.embeddings.background.httpDisableKeepAlives | string | `"false"` | Opens a new worker provider connection per request. Existing `hub.worker.env` override wins. | | hub.embeddings.background.maxConcurrent | string | `"5"` | | | hub.embeddings.background.persistence.enabled | bool | `true` | | | hub.embeddings.background.persistence.size | string | `"10Gi"` | One retained cache volume per StatefulSet replica. | @@ -653,15 +765,33 @@ tokens, provider response bodies, and collector URLs are never telemetry fields. | serviceMonitor.endpoints[0].port | string | `"metrics"` | | | taxonomy.autoConfigureHub | bool | `true` | Inject taxonomy service env vars into Hub API when taxonomy is enabled. | | taxonomy.enabled | bool | `false` | Deploy the optional standalone taxonomy service. | +| taxonomy.envFrom | list | `[]` | Secret or ConfigMap sources for Taxonomy runtime environment variables. | +| taxonomy.heartbeatIntervalSeconds | string | `"30"` | Hub heartbeat interval; `0` intentionally disables heartbeats in supporting images. | +| taxonomy.hubClientMaxAttempts | string | `"3"` | Maximum idempotent Hub callback/fetch attempts. | +| taxonomy.hubReaperIntervalSeconds | string | `"60"` | Interval between Hub stale-run reaper passes. | +| taxonomy.hubStaleRunTimeoutSeconds | string | `"1800"` | Hub stale-run timeout; lower only with a callback-heartbeating taxonomy image. | | taxonomy.image.repository | string | `"ghcr.io/formbricks/taxonomy"` | Taxonomy service image repository. | | taxonomy.image.tag | string | `"v0.1.0"` | Taxonomy service image tag. | | taxonomy.llm.baseUrl | string | `""` | Defaults to bundled vLLM router URL when `llm.enabled=true`; set for external LLMs. | +| taxonomy.llm.bedrock.region | string | `""` | AWS region for Bedrock; alternatively set `taxonomy.env.AWS_REGION`. | +| taxonomy.llm.bundledModelSpecName | string | `""` | Enabled bundled `modelSpec` used by Taxonomy; required when multiple bundled models are enabled. | +| taxonomy.llm.contextWindowTokens | string | `""` | Exact provider context window; required when taxonomy is enabled. | | taxonomy.llm.existingSecret | string | `""` | Existing secret containing `TAXONOMY_LLM_API_KEY`. | +| taxonomy.llm.labelMaxTokens | string | `"4096"` | Maximum cluster-label output tokens. | +| taxonomy.llm.maxAttempts | string | `"4"` | Maximum semantic validation/repair attempts. | | taxonomy.llm.model | string | `"qwen3-14b-awq"` | LLM model used by taxonomy labeling and tree generation. | -| taxonomy.llm.provider | string | `"openai-compatible"` | Taxonomy LLM provider. | +| taxonomy.llm.provider | string | `"openai-compatible"` | Runtime adapter: `openai-compatible`, `bedrock`, or `vertex-gemini`. | +| taxonomy.llm.providerMaxAttempts | string | `"3"` | Maximum timeout, 429, or 5xx provider attempts. | +| taxonomy.llm.promptTokenReserve | string | `"4096"` | Context safety reserve for bounded prompts. | +| taxonomy.llm.structuredOutputMode | string | `"auto"` | `auto`, `prompt-only`, `json-object`, or `json-schema`. | +| taxonomy.llm.treeMaxTokens | string | `"16384"` | Maximum hierarchy output tokens. | | taxonomy.llm.vertex.credentialsJson | string | `""` | Inline Vertex service-account JSON used only when no existing secret is set. | | taxonomy.llm.vertex.credentialsJsonSecretKey | string | `"TAXONOMY_GOOGLE_CLOUD_CREDENTIALS_JSON"` | Secret key containing Vertex service-account JSON. | | taxonomy.llm.vertex.existingSecret | string | `""` | Existing secret containing Vertex service-account JSON. | | taxonomy.llm.vertex.location | string | `""` | Vertex AI location for Gemini taxonomy calls. | | taxonomy.llm.vertex.project | string | `""` | Google Cloud project for Gemini taxonomy calls. | +| taxonomy.llm.vertex.thinkingBudget | string | `"0"` | Vertex Gemini thinking-token budget for taxonomy calls. | +| taxonomy.maxClusters | string | `"80"` | Compatibility value; production Taxonomy images enforce 80 at startup. | +| taxonomy.runDeadlineSeconds | string | `"900"` | Total taxonomy run deadline. | | taxonomy.service.type | string | `"ClusterIP"` | Internal taxonomy service type. | +| taxonomy.terminationGracePeriodSeconds | int | `930` | Recommended pod grace period for the default 900-second run deadline. | diff --git a/charts/formbricks/templates/_helpers.tpl b/charts/formbricks/templates/_helpers.tpl index 7403dfe8d606..ff12871e3de9 100644 --- a/charts/formbricks/templates/_helpers.tpl +++ b/charts/formbricks/templates/_helpers.tpl @@ -373,6 +373,10 @@ Hub env managed by taxonomy when the optional taxonomy service is enabled. secretKeyRef: name: {{ include "formbricks.taxonomyAuthSecretName" $root }} key: {{ $root.Values.taxonomy.auth.hubInternalApiTokenKey | default "HUB_INTERNAL_API_TOKEN" }} +- name: TAXONOMY_STUCK_RUN_TIMEOUT_SECONDS + value: {{ $root.Values.taxonomy.hubStaleRunTimeoutSeconds | quote }} +- name: TAXONOMY_REAPER_INTERVAL_SECONDS + value: {{ $root.Values.taxonomy.hubReaperIntervalSeconds | quote }} {{- end }} {{- end }} @@ -381,7 +385,7 @@ Returns true when an env var is managed by taxonomy auto-configuration and shoul */}} {{- define "formbricks.taxonomyHubEnvManaged" -}} {{- $key := .key -}} -{{- if has $key (list "TAXONOMY_SERVICE_URL" "TAXONOMY_SERVICE_TOKEN" "HUB_INTERNAL_API_TOKEN") -}} +{{- if has $key (list "TAXONOMY_SERVICE_URL" "TAXONOMY_SERVICE_TOKEN" "HUB_INTERNAL_API_TOKEN" "TAXONOMY_STUCK_RUN_TIMEOUT_SECONDS" "TAXONOMY_REAPER_INTERVAL_SECONDS") -}} true {{- end -}} {{- end }} @@ -391,7 +395,7 @@ Returns true when an env var is managed by the taxonomy deployment and should no */}} {{- define "formbricks.taxonomyEnvManaged" -}} {{- $key := .key -}} -{{- if has $key (list "APP_ENV" "HUB_INTERNAL_API_URL" "HUB_INTERNAL_API_TOKEN" "TAXONOMY_SERVICE_TOKEN" "TAXONOMY_LLM_PROVIDER" "TAXONOMY_LLM_MODEL" "TAXONOMY_LLM_BASE_URL" "TAXONOMY_LLM_API_KEY" "TAXONOMY_VERTEX_PROJECT" "TAXONOMY_VERTEX_LOCATION" "TAXONOMY_GOOGLE_CLOUD_CREDENTIALS_JSON" "TAXONOMY_LLM_TEMPERATURE" "TAXONOMY_LLM_MAX_ATTEMPTS" "TAXONOMY_LLM_TIMEOUT_SECONDS" "HUB_CLIENT_TIMEOUT_SECONDS" "TAXONOMY_EMBEDDING_DIMENSION" "TAXONOMY_MIN_EMBEDDED_RECORDS" "TAXONOMY_MAX_RECORDS" "TAXONOMY_MAX_CLUSTERS" "TAXONOMY_RANDOM_SEED") -}} +{{- if has $key (list "APP_ENV" "HUB_INTERNAL_API_URL" "HUB_INTERNAL_API_TOKEN" "TAXONOMY_SERVICE_TOKEN" "TAXONOMY_LLM_PROVIDER" "TAXONOMY_LLM_MODEL" "TAXONOMY_LLM_BASE_URL" "TAXONOMY_LLM_API_KEY" "TAXONOMY_VERTEX_PROJECT" "TAXONOMY_VERTEX_LOCATION" "TAXONOMY_GOOGLE_CLOUD_CREDENTIALS_JSON" "TAXONOMY_VERTEX_THINKING_BUDGET" "TAXONOMY_LLM_TEMPERATURE" "TAXONOMY_LLM_STRUCTURED_OUTPUT_MODE" "TAXONOMY_LLM_CONTEXT_WINDOW_TOKENS" "TAXONOMY_LLM_LABEL_MAX_TOKENS" "TAXONOMY_LLM_TREE_MAX_TOKENS" "TAXONOMY_LLM_PROMPT_TOKEN_RESERVE" "TAXONOMY_LLM_PROVIDER_MAX_ATTEMPTS" "TAXONOMY_LLM_MAX_ATTEMPTS" "TAXONOMY_LLM_TIMEOUT_SECONDS" "HUB_CLIENT_TIMEOUT_SECONDS" "HUB_CLIENT_MAX_ATTEMPTS" "HUB_HEARTBEAT_INTERVAL_SECONDS" "TAXONOMY_RUN_TIMEOUT_SECONDS" "TAXONOMY_EMBEDDING_DIMENSION" "TAXONOMY_MIN_EMBEDDED_RECORDS" "TAXONOMY_MAX_RECORDS" "TAXONOMY_MAX_CLUSTERS" "TAXONOMY_RANDOM_SEED") -}} true {{- end -}} {{- end }} @@ -487,6 +491,7 @@ self-hosted runtime is enabled so Hub API and Hub worker cannot drift. {{- define "formbricks.hubEmbeddingEnv" -}} {{- $root := .root -}} {{- $worker := .worker | default false -}} +{{- $env := .env | default (dict) -}} {{- if $root.Values.hub.embeddings.enabled }} - name: EMBEDDING_PROVIDER value: "openai" @@ -518,6 +523,12 @@ self-hosted runtime is enabled so Hub API and Hub worker cannot drift. value: {{ ternary $root.Values.hub.embeddings.background.batchMaxWaitMs "25" $root.Values.hub.embeddings.background.enabled | quote }} - name: EMBEDDING_BATCH_MAX_IN_FLIGHT value: {{ ternary $root.Values.hub.embeddings.background.batchMaxInFlight "1" $root.Values.hub.embeddings.background.enabled | quote }} +- name: EMBEDDING_HTTP_DISABLE_KEEP_ALIVES + {{- if hasKey $env "EMBEDDING_HTTP_DISABLE_KEEP_ALIVES" }} + value: {{ index $env "EMBEDDING_HTTP_DISABLE_KEEP_ALIVES" | quote }} + {{- else }} + value: {{ ternary $root.Values.hub.embeddings.background.httpDisableKeepAlives "false" $root.Values.hub.embeddings.background.enabled | quote }} + {{- end }} {{- end }} {{- end }} {{- end }} @@ -527,7 +538,7 @@ Returns true when an env var is managed by hub.embeddings and should not be rend */}} {{- define "formbricks.hubEmbeddingEnvManaged" -}} {{- $key := .key -}} -{{- if has $key (list "EMBEDDING_PROVIDER" "EMBEDDING_MODEL" "EMBEDDING_BASE_URL" "EMBEDDING_PROVIDER_API_KEY" "EMBEDDING_MAX_CONCURRENT" "EMBEDDING_NORMALIZE" "EMBEDDING_BATCH_SIZE" "EMBEDDING_BATCH_MAX_WAIT_MS" "EMBEDDING_BATCH_MAX_IN_FLIGHT") -}} +{{- if has $key (list "EMBEDDING_PROVIDER" "EMBEDDING_MODEL" "EMBEDDING_BASE_URL" "EMBEDDING_PROVIDER_API_KEY" "EMBEDDING_MAX_CONCURRENT" "EMBEDDING_NORMALIZE" "EMBEDDING_BATCH_SIZE" "EMBEDDING_BATCH_MAX_WAIT_MS" "EMBEDDING_BATCH_MAX_IN_FLIGHT" "EMBEDDING_HTTP_DISABLE_KEEP_ALIVES") -}} true {{- end -}} {{- end }} diff --git a/charts/formbricks/templates/hub-worker-deployment.yaml b/charts/formbricks/templates/hub-worker-deployment.yaml index 6d26c151bed0..e0db4fbb03d2 100644 --- a/charts/formbricks/templates/hub-worker-deployment.yaml +++ b/charts/formbricks/templates/hub-worker-deployment.yaml @@ -86,8 +86,9 @@ spec: name: {{ include "formbricks.hubSecretName" . }} {{- if or .Values.hub.embeddings.enabled (gt (len .Values.hub.env) 0) (gt (len .Values.hub.worker.env) 0) }} env: - {{- $workerEnv := merge (dict) .Values.hub.env .Values.hub.worker.env }} - {{- include "formbricks.hubEmbeddingEnv" (dict "root" $ "env" $workerEnv "worker" true) | nindent 12 }} + {{- $keepAliveKey := "EMBEDDING_HTTP_DISABLE_KEEP_ALIVES" }} + {{- $embeddingWorkerEnv := mergeOverwrite (dict) (pick .Values.hub.env $keepAliveKey) (pick .Values.hub.worker.env $keepAliveKey) }} + {{- include "formbricks.hubEmbeddingEnv" (dict "root" $ "env" $embeddingWorkerEnv "worker" true) | nindent 12 }} {{- range $key, $value := .Values.hub.env }} {{- if and (not (hasKey $.Values.hub.worker.env $key)) (not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key)))) }} {{- include "formbricks.envVar" (dict "name" $key "value" $value "context" $) | nindent 12 }} diff --git a/charts/formbricks/templates/taxonomy-deployment.yaml b/charts/formbricks/templates/taxonomy-deployment.yaml index a84b100175e3..d75651be648e 100644 --- a/charts/formbricks/templates/taxonomy-deployment.yaml +++ b/charts/formbricks/templates/taxonomy-deployment.yaml @@ -1,11 +1,107 @@ {{- if .Values.taxonomy.enabled }} {{- $provider := .Values.taxonomy.llm.provider | default "openai-compatible" -}} +{{- $taxonomyEnv := .Values.taxonomy.env | default dict -}} +{{- $bedrock := .Values.taxonomy.llm.bedrock | default dict -}} +{{- $bedrockRegion := get $bedrock "region" | default "" -}} +{{- $bedrockEnvRegion := get $taxonomyEnv "AWS_REGION" | default "" -}} +{{- $vertex := .Values.taxonomy.llm.vertex | default dict -}} +{{- $vertexProject := get $vertex "project" | default "" -}} +{{- $vertexLocation := get $vertex "location" | default "" -}} {{- if not (has $provider (list "openai-compatible" "bedrock" "vertex-gemini")) }} {{- fail "taxonomy.llm.provider must be 'openai-compatible', 'bedrock', or 'vertex-gemini'" }} {{- end }} -{{- if and (eq $provider "vertex-gemini") (or (not .Values.taxonomy.llm.vertex.project) (not .Values.taxonomy.llm.vertex.location)) }} +{{- if not .Values.taxonomy.llm.model }} + {{- fail "taxonomy.llm.model is required when taxonomy.enabled=true" }} +{{- end }} +{{- if and (eq $provider "vertex-gemini") (or (not $vertexProject) (not $vertexLocation)) }} {{- fail "taxonomy.llm.vertex.project and taxonomy.llm.vertex.location are required when taxonomy.llm.provider is 'vertex-gemini'" }} {{- end }} +{{- if and (eq $provider "openai-compatible") (not .Values.taxonomy.llm.baseUrl) (not .Values.llm.enabled) }} + {{- fail "taxonomy.llm.baseUrl or llm.enabled=true is required when taxonomy.llm.provider is 'openai-compatible'" }} +{{- end }} +{{- $structuredOutputMode := .Values.taxonomy.llm.structuredOutputMode | default "auto" -}} +{{- if not (has $structuredOutputMode (list "auto" "prompt-only" "json-object" "json-schema")) }} + {{- fail "taxonomy.llm.structuredOutputMode must be 'auto', 'prompt-only', 'json-object', or 'json-schema'" }} +{{- end }} +{{- if not .Values.taxonomy.llm.contextWindowTokens }} + {{- fail "taxonomy.llm.contextWindowTokens is required when taxonomy.enabled=true and must match the selected provider/model deployment" }} +{{- end }} +{{- if and (eq $provider "openai-compatible") .Values.llm.enabled (not .Values.taxonomy.llm.baseUrl) }} + {{- $enabledBundledModels := list -}} + {{- range $modelSpec := .Values.llm.servingEngineSpec.modelSpec }} + {{- if ne $modelSpec.enabled false }} + {{- $enabledBundledModels = append $enabledBundledModels $modelSpec -}} + {{- end }} + {{- end }} + {{- if eq (len $enabledBundledModels) 0 }} + {{- fail "llm.servingEngineSpec.modelSpec must contain an enabled model when taxonomy uses the bundled vLLM runtime" }} + {{- end }} + {{- $bundledModelSpecName := .Values.taxonomy.llm.bundledModelSpecName | default "" -}} + {{- $matchingBundledModels := list -}} + {{- if $bundledModelSpecName }} + {{- range $modelSpec := $enabledBundledModels }} + {{- if eq (get $modelSpec "name") $bundledModelSpecName }} + {{- $matchingBundledModels = append $matchingBundledModels $modelSpec -}} + {{- end }} + {{- end }} + {{- if ne (len $matchingBundledModels) 1 }} + {{- fail "taxonomy.llm.bundledModelSpecName must identify exactly one enabled llm.servingEngineSpec.modelSpec entry" }} + {{- end }} + {{- else }} + {{- if ne (len $enabledBundledModels) 1 }} + {{- fail "taxonomy.llm.bundledModelSpecName is required when more than one bundled model is enabled" }} + {{- end }} + {{- $matchingBundledModels = $enabledBundledModels -}} + {{- end }} + {{- $bundledModel := index $matchingBundledModels 0 -}} + {{- $bundledVllmConfig := get $bundledModel "vllmConfig" | default dict -}} + {{- $bundledMaxModelLenValue := get $bundledVllmConfig "maxModelLen" -}} + {{- if not (regexMatch "^[1-9][0-9]*$" (toString .Values.taxonomy.llm.contextWindowTokens)) }} + {{- fail "taxonomy.llm.contextWindowTokens must be a positive integer when taxonomy uses the bundled vLLM runtime" }} + {{- end }} + {{- if not (regexMatch "^[1-9][0-9]*$" (toString $bundledMaxModelLenValue)) }} + {{- fail "the selected bundled vLLM model vllmConfig.maxModelLen must be a positive integer" }} + {{- end }} + {{- $bundledMaxModelLen := int $bundledMaxModelLenValue -}} + {{- if lt $bundledMaxModelLen (int .Values.taxonomy.llm.contextWindowTokens) }} + {{- fail "the selected bundled vLLM maxModelLen must be >= taxonomy.llm.contextWindowTokens" }} + {{- end }} +{{- end }} +{{- if and (eq $provider "bedrock") (not $bedrockRegion) (not $bedrockEnvRegion) }} + {{- fail "taxonomy.llm.bedrock.region or taxonomy.env.AWS_REGION is required when taxonomy.llm.provider is 'bedrock'" }} +{{- end }} +{{- if and (eq $provider "bedrock") $bedrockRegion $bedrockEnvRegion }} + {{- fail "set either taxonomy.llm.bedrock.region or taxonomy.env.AWS_REGION, not both" }} +{{- end }} +{{- $managedTaxonomyOverrides := dict + "TAXONOMY_LLM_STRUCTURED_OUTPUT_MODE" "taxonomy.llm.structuredOutputMode" + "TAXONOMY_LLM_CONTEXT_WINDOW_TOKENS" "taxonomy.llm.contextWindowTokens" + "TAXONOMY_LLM_LABEL_MAX_TOKENS" "taxonomy.llm.labelMaxTokens" + "TAXONOMY_LLM_TREE_MAX_TOKENS" "taxonomy.llm.treeMaxTokens" + "TAXONOMY_LLM_PROMPT_TOKEN_RESERVE" "taxonomy.llm.promptTokenReserve" + "TAXONOMY_LLM_PROVIDER_MAX_ATTEMPTS" "taxonomy.llm.providerMaxAttempts" + "TAXONOMY_VERTEX_THINKING_BUDGET" "taxonomy.llm.vertex.thinkingBudget" + "HUB_CLIENT_MAX_ATTEMPTS" "taxonomy.hubClientMaxAttempts" + "HUB_HEARTBEAT_INTERVAL_SECONDS" "taxonomy.heartbeatIntervalSeconds" + "TAXONOMY_RUN_TIMEOUT_SECONDS" "taxonomy.runDeadlineSeconds" +-}} +{{- range $envName, $valueName := $managedTaxonomyOverrides }} + {{- if hasKey $taxonomyEnv $envName }} + {{- fail (printf "set %s instead of taxonomy.env.%s" $valueName $envName) }} + {{- end }} +{{- end }} +{{- if .Values.taxonomy.autoConfigureHub }} + {{- $hubEnv := .Values.hub.env | default dict -}} + {{- $managedHubOverrides := dict + "TAXONOMY_STUCK_RUN_TIMEOUT_SECONDS" "taxonomy.hubStaleRunTimeoutSeconds" + "TAXONOMY_REAPER_INTERVAL_SECONDS" "taxonomy.hubReaperIntervalSeconds" + -}} + {{- range $envName, $valueName := $managedHubOverrides }} + {{- if hasKey $hubEnv $envName }} + {{- fail (printf "set %s instead of hub.env.%s when taxonomy.autoConfigureHub=true" $valueName $envName) }} + {{- end }} + {{- end }} +{{- end }} --- apiVersion: apps/v1 kind: Deployment @@ -40,6 +136,7 @@ spec: {{- toYaml . | nindent 8 }} {{- end }} spec: + terminationGracePeriodSeconds: {{ .Values.taxonomy.terminationGracePeriodSeconds }} {{- with .Values.taxonomy.podSecurityContext }} securityContext: {{- toYaml . | nindent 8 }} @@ -153,12 +250,38 @@ spec: {{- end }} - name: TAXONOMY_LLM_TEMPERATURE value: {{ .Values.taxonomy.llm.temperature | quote }} + - name: TAXONOMY_LLM_STRUCTURED_OUTPUT_MODE + value: {{ $structuredOutputMode | quote }} + - name: TAXONOMY_LLM_CONTEXT_WINDOW_TOKENS + value: {{ .Values.taxonomy.llm.contextWindowTokens | quote }} + - name: TAXONOMY_LLM_LABEL_MAX_TOKENS + value: {{ .Values.taxonomy.llm.labelMaxTokens | quote }} + - name: TAXONOMY_LLM_TREE_MAX_TOKENS + value: {{ .Values.taxonomy.llm.treeMaxTokens | quote }} + - name: TAXONOMY_LLM_PROMPT_TOKEN_RESERVE + value: {{ .Values.taxonomy.llm.promptTokenReserve | quote }} + - name: TAXONOMY_LLM_PROVIDER_MAX_ATTEMPTS + value: {{ .Values.taxonomy.llm.providerMaxAttempts | quote }} - name: TAXONOMY_LLM_MAX_ATTEMPTS value: {{ .Values.taxonomy.llm.maxAttempts | quote }} - name: TAXONOMY_LLM_TIMEOUT_SECONDS value: {{ .Values.taxonomy.llm.timeoutSeconds | quote }} + {{- if and (eq $provider "bedrock") $bedrockRegion }} + - name: AWS_REGION + value: {{ $bedrockRegion | quote }} + {{- end }} + {{- if eq $provider "vertex-gemini" }} + - name: TAXONOMY_VERTEX_THINKING_BUDGET + value: {{ .Values.taxonomy.llm.vertex.thinkingBudget | quote }} + {{- end }} - name: HUB_CLIENT_TIMEOUT_SECONDS value: {{ .Values.taxonomy.hubClientTimeoutSeconds | quote }} + - name: HUB_CLIENT_MAX_ATTEMPTS + value: {{ .Values.taxonomy.hubClientMaxAttempts | quote }} + - name: HUB_HEARTBEAT_INTERVAL_SECONDS + value: {{ .Values.taxonomy.heartbeatIntervalSeconds | quote }} + - name: TAXONOMY_RUN_TIMEOUT_SECONDS + value: {{ .Values.taxonomy.runDeadlineSeconds | quote }} - name: TAXONOMY_EMBEDDING_DIMENSION value: {{ .Values.taxonomy.embeddingDimension | quote }} - name: TAXONOMY_MIN_EMBEDDED_RECORDS diff --git a/charts/formbricks/values.yaml b/charts/formbricks/values.yaml index 593de33387cf..3aa5d38fc38c 100644 --- a/charts/formbricks/values.yaml +++ b/charts/formbricks/values.yaml @@ -375,20 +375,42 @@ taxonomy: hubInternalApiToken: "" llm: + # Select the taxonomy runtime adapter. The chart defaults to the provider-neutral + # OpenAI-compatible protocol; Vertex and Bedrock settings are rendered only when selected. + # Supported values: openai-compatible, bedrock, vertex-gemini. provider: openai-compatible + # Provider/deployment-specific model identifier. Override this for the selected LLM. model: qwen3-14b-awq # Defaults to the bundled vLLM router URL when llm.enabled=true. Required otherwise. baseUrl: "" + # Selects the enabled llm.servingEngineSpec.modelSpec entry used by taxonomy. It may + # remain empty only when exactly one bundled model is enabled. + bundledModelSpecName: "" # Existing secret must contain apiKeySecretKey. When unset, the chart creates a non-empty dummy key. existingSecret: "" apiKeySecretKey: TAXONOMY_LLM_API_KEY apiKey: "" temperature: "0.1" + # auto delegates the output mode to the selected runtime adapter. Set json-schema + # explicitly only when the exact provider/model deployment passes preflight. + structuredOutputMode: auto + # Must match the exact configured provider/model deployment. Taxonomy validates its + # complete prompt/output budget during startup and readiness. It may remain empty while + # taxonomy is disabled, but is required when taxonomy.enabled=true. + contextWindowTokens: "" + labelMaxTokens: "4096" + treeMaxTokens: "16384" + promptTokenReserve: "4096" + providerMaxAttempts: "3" + # Semantic repair attempts across branch plans, assignments, and hierarchy plans. maxAttempts: "4" timeoutSeconds: "60" + bedrock: + region: "" vertex: project: "" location: "" + thinkingBudget: "0" # Existing secret must contain credentialsJsonSecretKey. existingSecret: "" credentialsJsonSecretKey: TAXONOMY_GOOGLE_CLOUD_CREDENTIALS_JSON @@ -396,10 +418,22 @@ taxonomy: embeddingDimension: "768" minEmbeddedRecords: "20" - maxRecords: "50000" + maxRecords: "10000" + # Kept as a chart value for configuration compatibility. Production Taxonomy images + # enforce the 80-cluster quality invariant at startup. maxClusters: "80" randomSeed: "42" hubClientTimeoutSeconds: "120" + hubClientMaxAttempts: "3" + # Set to 0 only when intentionally disabling Taxonomy heartbeats. Keep the default well + # below hubStaleRunTimeoutSeconds for normal production operation. + heartbeatIntervalSeconds: "30" + runDeadlineSeconds: "900" + # Keep this compatible with the default v0.1.0 image, which does not heartbeat through + # terminal Hub callbacks. It can be lowered after pinning a heartbeat-capable image. + hubStaleRunTimeoutSeconds: "1800" + hubReaperIntervalSeconds: "60" + terminationGracePeriodSeconds: 930 envFrom: [] env: {} @@ -453,6 +487,8 @@ taxonomy: periodSeconds: 10 timeoutSeconds: 5 httpGet: + # The default v0.1.0 image exposes /health. Override this to /ready only + # when using a taxonomy image that implements the readiness contract. path: /health port: http livenessProbe: @@ -1137,6 +1173,7 @@ hub: batchSize: "1" batchMaxWaitMs: "25" batchMaxInFlight: "1" + httpDisableKeepAlives: "false" replicas: 1 service: