From df74f5c2c9ab371d46c366fa06832e900b0e9596 Mon Sep 17 00:00:00 2001 From: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:55:04 +0530 Subject: [PATCH 1/2] Updated K8s helm charts for api tool calling and vault updates (#483) --- .../script/delete_secrets_from_vault.sh | 17 +- .../script/store_secrets_in_vault.sh | 17 +- kubernetes/Chart.yaml | 5 +- kubernetes/LANGFUSE_SETUP.md | 5 +- .../templates/deployment-byk-cronmanager.yaml | 38 +++- kubernetes/charts/CronManager/values.yaml | 2 +- .../GUI/templates/configmap-vite-config.yaml | 17 +- kubernetes/charts/GUI/values.yaml | 28 +-- .../deployment-byk-llm-orchestration.yaml | 6 + .../LLM-Orchestration-Service/values.yaml | 18 +- .../deployment-byk-langfuse-web.yaml | 4 + kubernetes/charts/Langfuse-Web/values.yaml | 9 +- kubernetes/charts/Langfuse-Worker/values.yaml | 9 +- .../Loki/templates/deployment-loki.yaml | 1 + .../charts/Notifications-Node/Chart.yaml | 6 + .../deployment-byk-notifications.yaml | 69 +++++++ .../templates/secret-byk-notifications.yaml | 13 ++ .../templates/service-byk-notifications.yaml | 16 ++ .../charts/Notifications-Node/values.yaml | 52 +++++ kubernetes/charts/OpenSearch/Chart.yaml | 6 + .../templates/deployment-byk-opensearch.yaml | 77 +++++++ .../charts/OpenSearch/templates/ingress.yaml | 43 ++++ .../OpenSearch/templates/pvc-opensearch.yaml | 22 ++ .../templates/service-byk-opensearch.yaml | 19 ++ kubernetes/charts/OpenSearch/values.yaml | 80 ++++++++ .../configmap-byk-ruuter-private.yaml | 9 +- kubernetes/charts/Ruuter-Private/values.yaml | 11 +- .../configmap-byk-ruuter-public.yaml | 9 +- kubernetes/charts/Ruuter-Public/values.yaml | 10 +- .../Vault-Agent-Cron/templates/configmap.yaml | 16 +- .../Vault-Agent-GUI/templates/configmap.yaml | 15 +- .../Vault-Agent-LLM/templates/configmap.yaml | 22 +- .../Vault-Agent-LLM/templates/deployment.yaml | 107 ---------- .../Vault-Init/templates/configmap.yaml | 190 ++++++++++-------- .../charts/Vault-Init/templates/job.yaml | 3 + .../charts/Vault/templates/configmap.yaml | 56 +++--- src/api_tool_indexer/constants.py | 2 +- 37 files changed, 712 insertions(+), 317 deletions(-) create mode 100644 kubernetes/charts/Notifications-Node/Chart.yaml create mode 100644 kubernetes/charts/Notifications-Node/templates/deployment-byk-notifications.yaml create mode 100644 kubernetes/charts/Notifications-Node/templates/secret-byk-notifications.yaml create mode 100644 kubernetes/charts/Notifications-Node/templates/service-byk-notifications.yaml create mode 100644 kubernetes/charts/Notifications-Node/values.yaml create mode 100644 kubernetes/charts/OpenSearch/Chart.yaml create mode 100644 kubernetes/charts/OpenSearch/templates/deployment-byk-opensearch.yaml create mode 100644 kubernetes/charts/OpenSearch/templates/ingress.yaml create mode 100644 kubernetes/charts/OpenSearch/templates/pvc-opensearch.yaml create mode 100644 kubernetes/charts/OpenSearch/templates/service-byk-opensearch.yaml create mode 100644 kubernetes/charts/OpenSearch/values.yaml delete mode 100644 kubernetes/charts/Vault-Agent-LLM/templates/deployment.yaml diff --git a/DSL/CronManager/script/delete_secrets_from_vault.sh b/DSL/CronManager/script/delete_secrets_from_vault.sh index 3b405927..0e457e41 100644 --- a/DSL/CronManager/script/delete_secrets_from_vault.sh +++ b/DSL/CronManager/script/delete_secrets_from_vault.sh @@ -6,9 +6,18 @@ set -e # Exit on any error # Configuration -# Use vaultAgentUrl which points to vault-agent-cron proxy -# The agent automatically injects the authentication token -VAULT_ADDR="${vaultAgentUrl:-http://vault-agent-cron:8203}" +# Resolve Vault Agent URL: +# 1. Use vaultAgentUrl env var if set (from container env or CronManager request) +# 2. Auto-detect Kubernetes via KUBERNETES_SERVICE_HOST (injected by kubelet, cannot be disabled) +# 3. Auto-detect Kubernetes via service account token (mounted by default in every pod) +# 4. Fallback to Docker Compose hostname +if [ -n "$vaultAgentUrl" ]; then + VAULT_ADDR="$vaultAgentUrl" +elif [ -n "$KUBERNETES_SERVICE_HOST" ] || [ -f "/var/run/secrets/kubernetes.io/serviceaccount/token" ]; then + VAULT_ADDR="http://localhost:8203" +else + VAULT_ADDR="http://vault-agent-cron:8203" +fi # Logging function log() { @@ -169,4 +178,4 @@ delete_llm_secrets # Delete embedding secrets delete_embedding_secrets -log "=== Vault secrets deletion completed ===" +log "=== Vault secrets deletion completed ===" \ No newline at end of file diff --git a/DSL/CronManager/script/store_secrets_in_vault.sh b/DSL/CronManager/script/store_secrets_in_vault.sh index 60784eed..d977f1ef 100644 --- a/DSL/CronManager/script/store_secrets_in_vault.sh +++ b/DSL/CronManager/script/store_secrets_in_vault.sh @@ -6,9 +6,20 @@ set -e # Exit on any error # Configuration -# Use vaultAgentUrl which points to vault-agent-cron proxy -# The agent automatically injects the authentication token -VAULT_ADDR="${vaultAgentUrl:-http://vault-agent-cron:8203}" +# Resolve Vault Agent URL: +# 1. Use vaultAgentUrl env var if set (from container env or CronManager request) +# 2. Auto-detect Kubernetes via KUBERNETES_SERVICE_HOST (injected by kubelet, cannot be disabled) +# 3. Auto-detect Kubernetes via service account token (mounted by default in every pod) +# 4. Fallback to Docker Compose hostname +if [ -n "$vaultAgentUrl" ]; then + VAULT_ADDR="$vaultAgentUrl" +elif [ -n "$KUBERNETES_SERVICE_HOST" ] || [ -f "/var/run/secrets/kubernetes.io/serviceaccount/token" ]; then + VAULT_ADDR="http://localhost:8203" +else + VAULT_ADDR="http://vault-agent-cron:8203" +fi + +echo "DEBUG: VAULT_ADDR=$VAULT_ADDR vaultAgentUrl=$vaultAgentUrl KUBERNETES_SERVICE_HOST=$KUBERNETES_SERVICE_HOST" # Decryption Configuration PRIVATE_KEY_CACHE="" diff --git a/kubernetes/Chart.yaml b/kubernetes/Chart.yaml index eb9a316a..ef6d5a2c 100644 --- a/kubernetes/Chart.yaml +++ b/kubernetes/Chart.yaml @@ -113,4 +113,7 @@ dependencies: version: 0.1.0 repository: "file://./charts/Notifications-Node" condition: Notifications-Node.enabled - + - name: OpenSearch + version: 0.1.0 + repository: "file://./charts/OpenSearch" + condition: OpenSearch.enabled \ No newline at end of file diff --git a/kubernetes/LANGFUSE_SETUP.md b/kubernetes/LANGFUSE_SETUP.md index 6c0f11bd..c54d91af 100644 --- a/kubernetes/LANGFUSE_SETUP.md +++ b/kubernetes/LANGFUSE_SETUP.md @@ -51,9 +51,12 @@ kubectl cp store-langfuse-secrets.sh rag-module/vault-0:/tmp/store-langfuse-secr kubectl exec -n your-namespace vault-0 -- sh -c \ "LANGFUSE_INIT_PROJECT_PUBLIC_KEY=pk-lf-YOUR_KEY \ LANGFUSE_INIT_PROJECT_SECRET_KEY=sk-lf-YOUR_KEY \ + LANGFUSE_HOST=http://langfuse-web:3005 \ sh /tmp/store-langfuse-secrets.sh" ``` Replace `pk-lf-YOUR_KEY` and `sk-lf-YOUR_KEY` with the actual keys from step 3. -The script stores them at `secret/data/langfuse/config` in Vault, where the LLM Orchestration Service reads them. +> **Note:** In Kubernetes, the Langfuse-Web service port is `3005` (mapped to container port 3000), so `LANGFUSE_HOST` must be set explicitly. In Docker Compose, the default (`http://langfuse-web:3000`) is used automatically. + +The script stores them at `secret/data/langfuse/config` in Vault, where the LLM Orchestration Service reads them. \ No newline at end of file diff --git a/kubernetes/charts/CronManager/templates/deployment-byk-cronmanager.yaml b/kubernetes/charts/CronManager/templates/deployment-byk-cronmanager.yaml index 15dc9615..bdf53252 100644 --- a/kubernetes/charts/CronManager/templates/deployment-byk-cronmanager.yaml +++ b/kubernetes/charts/CronManager/templates/deployment-byk-cronmanager.yaml @@ -36,6 +36,14 @@ spec: mountPath: /app/scripts - name: vector-indexer mountPath: /app/src/vector_indexer + - name: tool-classifier + mountPath: /app/src/tool_classifier + - name: intent-data-enrichment + mountPath: /app/src/intent_data_enrichment + - name: api-tool-indexer + mountPath: /app/src/api_tool_indexer + - name: src-utils + mountPath: /app/src/utils command: - sh - -c @@ -45,12 +53,20 @@ spec: mkdir -p /app/src/vector_indexer && mkdir -p /app/scripts && mkdir -p /DSL && - mkdir -p /app/src/utils + mkdir -p /app/src/utils && + mkdir -p /app/src/tool_classifier && + mkdir -p /app/src/intent_data_enrichment && + mkdir -p /app/src/api_tool_indexer cp -r /tmp/rag/DSL/CronManager/DSL/* /DSL/ && cp -r /tmp/rag/DSL/CronManager/script/* /app/scripts/ && cp -r /tmp/rag/src/vector_indexer/* /app/src/vector_indexer/ && - cp -r /tmp/rag/src/utils/decrypt_vault_secrets.py /app/src/utils/ && + cp -r /tmp/rag/src/tool_classifier/* /app/src/tool_classifier/ && + cp -r /tmp/rag/src/intent_data_enrichment/* /app/src/intent_data_enrichment/ && + cp -r /tmp/rag/src/api_tool_indexer/* /app/src/api_tool_indexer/ && + cp /tmp/rag/src/utils/decrypt_vault_secrets.py /app/src/utils/ && + cp /tmp/rag/src/__init__.py /app/src/__init__.py && + cp /tmp/rag/grafana-configs/loki_logger.py /app/src/vector_indexer/loki_logger.py && # Set execute permissions on all shell scripts chmod +x /app/scripts/*.sh && @@ -91,7 +107,7 @@ spec: value: {{ .Values.cronmanager.environment.pythonPath | quote }} {{- if .Values.vaultAgent.enabled }} # Vault Agent proxy URL (localhost sidecar) - - name: VAULT_AGENT_URL + - name: vaultAgentUrl value: "http://localhost:8203" {{- end }} - name: RAG_MODULE_RUUTER_PRIVATE @@ -112,6 +128,14 @@ spec: mountPath: /app/scripts - name: vector-indexer mountPath: /app/src/vector_indexer + - name: tool-classifier + mountPath: /app/src/tool_classifier + - name: intent-data-enrichment + mountPath: /app/src/intent_data_enrichment + - name: api-tool-indexer + mountPath: /app/src/api_tool_indexer + - name: src-utils + mountPath: /app/src/utils - name: datasets mountPath: /app/datasets @@ -122,8 +146,16 @@ spec: emptyDir: {} - name: vector-indexer emptyDir: {} + - name: tool-classifier + emptyDir: {} + - name: intent-data-enrichment + emptyDir: {} + - name: api-tool-indexer + emptyDir: {} - name: datasets emptyDir: {} + - name: src-utils + emptyDir: {} - name: cronmanager-data persistentVolumeClaim: claimName: "{{ .Values.release_name }}-data" diff --git a/kubernetes/charts/CronManager/values.yaml b/kubernetes/charts/CronManager/values.yaml index df8013cf..4db03e3e 100644 --- a/kubernetes/charts/CronManager/values.yaml +++ b/kubernetes/charts/CronManager/values.yaml @@ -11,7 +11,7 @@ cronmanager: environment: containerPort: "8080" - pythonPath: "/app:/app/src/vector_indexer" + pythonPath: "/app:/app/src:/app/src/vector_indexer:/app/src/intent_data_enrichment:/app/src/api_tool_indexer" VAULT_ADDR: "http://vault:8200" service: diff --git a/kubernetes/charts/GUI/templates/configmap-vite-config.yaml b/kubernetes/charts/GUI/templates/configmap-vite-config.yaml index 7110554b..fe4e29cc 100644 --- a/kubernetes/charts/GUI/templates/configmap-vite-config.yaml +++ b/kubernetes/charts/GUI/templates/configmap-vite-config.yaml @@ -45,6 +45,21 @@ data: 'Content-Security-Policy': process.env.REACT_APP_CSP, }), }, + proxy: { + '/vault-agent-gui': { + target: 'http://localhost:8202', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/vault-agent-gui/, ''), + }, + '/sse': { + target: 'http://notifications-node:4040', + changeOrigin: true, + }, + '/channels': { + target: 'http://notifications-node:4040', + changeOrigin: true, + }, + }, }, resolve: { alias: { @@ -53,4 +68,4 @@ data: }, }, }); -{{- end }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/GUI/values.yaml b/kubernetes/charts/GUI/values.yaml index 03257659..50c1ac9a 100644 --- a/kubernetes/charts/GUI/values.yaml +++ b/kubernetes/charts/GUI/values.yaml @@ -14,10 +14,10 @@ gui: #service URLs services: - ruuterPublic: "http:///ruuter-public" - ruuterPrivate: "http:///ruuter-private" - authenticationLayer: "http://" - notificationNode: "http://notifications-node:4040" + ruuterPublic: "http://localhost:8086" + ruuterPrivate: "http://localhost:8088" + authenticationLayer: "http://localhost:3004" + notificationNode: "http://localhost:3003" datasetGenerator: "http://dataset-gen-service:8000" # Content Security Policy - Updated for browser access @@ -33,7 +33,7 @@ gui: # Ingress host ingress: - host: "" # Update with actual domain + host: "localhost" # Update with actual domain resources: limits: @@ -52,20 +52,4 @@ gui: # Vault Agent sidecar configuration vaultAgent: - enabled: true - - - # ingress: - # enabled: true - # className: nginx - # annotations: - # nginx.ingress.kubernetes.io/rewrite-target: / - # nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" - # nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" - # nginx.ingress.kubernetes.io/proxy-body-size: "50m" - # hosts: - # - host: rag.local - # paths: - # - path: / - # pathType: Prefix - # tls: [] \ No newline at end of file + enabled: true \ No newline at end of file diff --git a/kubernetes/charts/LLM-Orchestration-Service/templates/deployment-byk-llm-orchestration.yaml b/kubernetes/charts/LLM-Orchestration-Service/templates/deployment-byk-llm-orchestration.yaml index 3e0feacb..fdfca98b 100644 --- a/kubernetes/charts/LLM-Orchestration-Service/templates/deployment-byk-llm-orchestration.yaml +++ b/kubernetes/charts/LLM-Orchestration-Service/templates/deployment-byk-llm-orchestration.yaml @@ -21,6 +21,7 @@ spec: initContainers: - name: volume-init image: "{{ .Values.initContainer.image.repository }}:{{ .Values.initContainer.image.tag }}" + imagePullPolicy: {{ .Values.initContainer.image.pullPolicy }} command: - sh - -c @@ -146,6 +147,11 @@ spec: - name: logs-volume mountPath: {{ .Values.volumes.logs.mountPath }} {{- end }} + {{- if .Values.vaultAgent.enabled }} + - name: vault-agent-llm-token + mountPath: /agent/llm-token + readOnly: true + {{- end }} resources: requests: diff --git a/kubernetes/charts/LLM-Orchestration-Service/values.yaml b/kubernetes/charts/LLM-Orchestration-Service/values.yaml index 7e457d22..6db9bcfe 100644 --- a/kubernetes/charts/LLM-Orchestration-Service/values.yaml +++ b/kubernetes/charts/LLM-Orchestration-Service/values.yaml @@ -50,6 +50,7 @@ initContainer: image: repository: "ghcr.io/buerokratt/llm-orchestration-service" # Update with actual llm-orchestration image repository tag: "latest" + pullPolicy: "IfNotPresent" # InitContainer will prepare the runtime volumes prepareVolumes: true @@ -73,9 +74,22 @@ healthcheck: # Additional readiness checks readinessPath: "/ready" +# Environment variables injected into the LLM container +# Redis defaults match the in-cluster Redis service (see Redis chart) +env: + REDIS_HOST: "redis" + REDIS_PORT: "6379" + REDIS_AUTH: "myredissecret" + REDIS_SESSION_DB: "0" + VAULT_AGENT_PROXY: "true" + TOOL_CLASSIFIER_ENABLED: "true" + SERVICE_WORKFLOW_ENABLED: "true" + API_TOOL_CALLING_WORKFLOW_ENABLED: "true" + CONTEXT_WORKFLOW_ENABLED: "true" + MULTI_INTENT_ENABLED: "true" + # Vault Agent sidecar configuration # WHY: LLM Orchestration needs read access to encrypted LLM API keys # Security: Agent enforces policy - read-only access to LLM secrets vaultAgent: - enabled: true - + enabled: true \ No newline at end of file diff --git a/kubernetes/charts/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml b/kubernetes/charts/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml index 18d14804..f7e3e054 100644 --- a/kubernetes/charts/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml +++ b/kubernetes/charts/Langfuse-Web/templates/deployment-byk-langfuse-web.yaml @@ -50,6 +50,10 @@ spec: - name: http containerPort: {{ .Values.service.targetPort }} protocol: TCP + {{- if .Values.envFrom }} + envFrom: + {{- toYaml .Values.envFrom | nindent 12 }} + {{- end }} env: {{- range $key, $value := .Values.env }} - name: {{ $key }} diff --git a/kubernetes/charts/Langfuse-Web/values.yaml b/kubernetes/charts/Langfuse-Web/values.yaml index 6dfaf1cf..ce4da5b7 100644 --- a/kubernetes/charts/Langfuse-Web/values.yaml +++ b/kubernetes/charts/Langfuse-Web/values.yaml @@ -17,6 +17,7 @@ service: # Environment variables env: # Non-sensitive configuration + HOSTNAME: "0.0.0.0" NEXTAUTH_URL: "http://localhost:3000" TELEMETRY_ENABLED: "true" LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: "true" @@ -53,9 +54,9 @@ env: REDIS_HOST: "redis" REDIS_PORT: "6379" REDIS_TLS_ENABLED: "false" - REDIS_TLS_CA: "" - REDIS_TLS_CERT: "" - REDIS_TLS_KEY: "" + REDIS_TLS_CA: "/certs/ca.crt" + REDIS_TLS_CERT: "/certs/redis.crt" + REDIS_TLS_KEY: "/certs/redis.key" # Email configuration EMAIL_FROM_ADDRESS: "" @@ -90,7 +91,7 @@ resources: pullPolicy: IfNotPresent healthcheck: - enabled: true + enabled: false initialDelaySeconds: 60 periodSeconds: 30 timeoutSeconds: 10 diff --git a/kubernetes/charts/Langfuse-Worker/values.yaml b/kubernetes/charts/Langfuse-Worker/values.yaml index 0a7343eb..b02bfe4a 100644 --- a/kubernetes/charts/Langfuse-Worker/values.yaml +++ b/kubernetes/charts/Langfuse-Worker/values.yaml @@ -16,6 +16,7 @@ service: # Environment variables env: # Non-sensitive configuration + HOSTNAME: "0.0.0.0" NEXTAUTH_URL: "http://localhost:3000" TELEMETRY_ENABLED: "true" LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: "true" @@ -52,9 +53,9 @@ env: REDIS_HOST: "redis" REDIS_PORT: "6379" REDIS_TLS_ENABLED: "false" - REDIS_TLS_CA: "" - REDIS_TLS_CERT: "" - REDIS_TLS_KEY: "" + REDIS_TLS_CA: "/certs/ca.crt" + REDIS_TLS_CERT: "/certs/redis.crt" + REDIS_TLS_KEY: "/certs/redis.key" # Email configuration EMAIL_FROM_ADDRESS: "" @@ -77,7 +78,7 @@ resources: pullPolicy: IfNotPresent healthcheck: - enabled: true + enabled: false initialDelaySeconds: 60 periodSeconds: 30 timeoutSeconds: 10 diff --git a/kubernetes/charts/Loki/templates/deployment-loki.yaml b/kubernetes/charts/Loki/templates/deployment-loki.yaml index 7967b8a3..9bf85dd3 100644 --- a/kubernetes/charts/Loki/templates/deployment-loki.yaml +++ b/kubernetes/charts/Loki/templates/deployment-loki.yaml @@ -25,6 +25,7 @@ spec: volumeMounts: - name: config mountPath: /etc/loki/local-config.yaml + subPath: loki.yaml {{- if .Values.persistence.enabled }} - name: storage mountPath: /loki diff --git a/kubernetes/charts/Notifications-Node/Chart.yaml b/kubernetes/charts/Notifications-Node/Chart.yaml new file mode 100644 index 00000000..a7756889 --- /dev/null +++ b/kubernetes/charts/Notifications-Node/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: Notifications-Node +description: A Helm chart for Notifications server +type: application +version: 0.1.0 +appVersion: "1.0" \ No newline at end of file diff --git a/kubernetes/charts/Notifications-Node/templates/deployment-byk-notifications.yaml b/kubernetes/charts/Notifications-Node/templates/deployment-byk-notifications.yaml new file mode 100644 index 00000000..81141337 --- /dev/null +++ b/kubernetes/charts/Notifications-Node/templates/deployment-byk-notifications.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.release_name }} + labels: + app: {{ .Values.release_name }} +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: {{ .Values.release_name }} + template: + metadata: + labels: + app: {{ .Values.release_name }} + spec: + containers: + - name: {{ .Values.release_name }} + image: "{{ .Values.notifications.image.repository }}:{{ .Values.notifications.image.tag }}" + imagePullPolicy: {{ .Values.notifications.image.pullPolicy }} + ports: + - containerPort: {{ .Values.notifications.port }} + protocol: TCP + env: + # Node.js application configuration + - name: NODE_ENV + value: {{ .Values.notifications.nodeEnv | quote }} + - name: PORT + value: {{ .Values.notifications.port | quote }} + - name: REFRESH_INTERVAL + value: {{ .Values.notifications.refreshInterval | quote }} + + # OpenSearch configuration + {{- if .Values.notifications.opensearch.enabled }} + - name: OPENSEARCH_PROTOCOL + value: {{ .Values.notifications.opensearch.protocol | quote }} + - name: OPENSEARCH_HOST + value: {{ .Values.notifications.opensearch.host | quote }} + - name: OPENSEARCH_PORT + value: {{ .Values.notifications.opensearch.port | quote }} + - name: OPENSEARCH_USERNAME + valueFrom: + secretKeyRef: + name: notifications-env-secret + key: OPENSEARCH_USERNAME + - name: OPENSEARCH_PASSWORD + valueFrom: + secretKeyRef: + name: notifications-env-secret + key: OPENSEARCH_PASSWORD + {{- end }} + + # CORS configuration + - name: CORS_WHITELIST_ORIGINS + value: {{ .Values.notifications.cors.whitelistOrigins | quote }} + + # BYK Stack integration + - name: RUUTER_URL + value: {{ .Values.notifications.services.ruuterUrl | quote }} + + resources: + limits: + cpu: {{ .Values.notifications.resources.limits.cpu }} + memory: {{ .Values.notifications.resources.limits.memory }} + requests: + cpu: {{ .Values.notifications.resources.requests.cpu }} + memory: {{ .Values.notifications.resources.requests.memory }} + + restartPolicy: Always \ No newline at end of file diff --git a/kubernetes/charts/Notifications-Node/templates/secret-byk-notifications.yaml b/kubernetes/charts/Notifications-Node/templates/secret-byk-notifications.yaml new file mode 100644 index 00000000..5119a1c7 --- /dev/null +++ b/kubernetes/charts/Notifications-Node/templates/secret-byk-notifications.yaml @@ -0,0 +1,13 @@ +{{- if .Values.notifications.opensearch.enabled }} +apiVersion: v1 +kind: Secret +metadata: + name: notifications-env-secret + labels: + app: {{ .Values.release_name }} + +type: Opaque +data: + OPENSEARCH_USERNAME: {{ .Values.notifications.opensearch.username | b64enc }} + OPENSEARCH_PASSWORD: {{ .Values.notifications.opensearch.password | b64enc }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Notifications-Node/templates/service-byk-notifications.yaml b/kubernetes/charts/Notifications-Node/templates/service-byk-notifications.yaml new file mode 100644 index 00000000..fbd8da49 --- /dev/null +++ b/kubernetes/charts/Notifications-Node/templates/service-byk-notifications.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.release_name }} + labels: + app: {{ .Values.release_name }} +spec: + type: {{ .Values.notifications.service.type }} + ports: + - port: {{ .Values.notifications.service.port }} + targetPort: {{ .Values.notifications.service.targetPort }} + protocol: TCP + name: http + selector: + app: {{ .Values.release_name }} + \ No newline at end of file diff --git a/kubernetes/charts/Notifications-Node/values.yaml b/kubernetes/charts/Notifications-Node/values.yaml new file mode 100644 index 00000000..9ad67631 --- /dev/null +++ b/kubernetes/charts/Notifications-Node/values.yaml @@ -0,0 +1,52 @@ +replicas: 1 + +podAnnotations: {} +podSecurityContext: {} +securityContext: {} + +release_name: "notifications-node" + +notifications: + image: + repository: public.ecr.aws/e7g9l0j0/rag-module/notification-server + tag: latest + pullPolicy: IfNotPresent + + # Node.js application configuration + port: 4040 + refreshInterval: 1000 + nodeEnv: production + + # OpenSearch configuration + opensearch: + enabled: true + protocol: http + host: opensearch-node + port: 9200 + username: admin + password: admin + + # CORS configuration for frontend access + cors: + whitelistOrigins: "http://gui:3001,http://gui:3002,http://gui:3003,http://authentication-layer:3004,http://ruuter-public:8086,http://ruuter-private:8088" + + # BYK Stack integration + services: + ruuterUrl: "http://ruuter-public:8086" + + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 128Mi + + service: + type: ClusterIP + port: 4040 + targetPort: 4040 + + # Security configuration + security: + csrfEnabled: true \ No newline at end of file diff --git a/kubernetes/charts/OpenSearch/Chart.yaml b/kubernetes/charts/OpenSearch/Chart.yaml new file mode 100644 index 00000000..58322f7c --- /dev/null +++ b/kubernetes/charts/OpenSearch/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: OpenSearch +description: A Helm chart for OpenSearch search and analytics engine +type: application +version: 0.1.0 +appVersion: "2.11.1" \ No newline at end of file diff --git a/kubernetes/charts/OpenSearch/templates/deployment-byk-opensearch.yaml b/kubernetes/charts/OpenSearch/templates/deployment-byk-opensearch.yaml new file mode 100644 index 00000000..48239ee6 --- /dev/null +++ b/kubernetes/charts/OpenSearch/templates/deployment-byk-opensearch.yaml @@ -0,0 +1,77 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ .Values.release_name }} + labels: + app: {{ .Values.release_name }} +spec: + replicas: {{ .Values.replicas }} + selector: + matchLabels: + app: {{ .Values.release_name }} + template: + metadata: + labels: + app: {{ .Values.release_name }} + spec: + containers: + - name: {{ .Values.release_name }} + image: "{{ .Values.opensearch.image.repository }}:{{ .Values.opensearch.image.tag }}" + imagePullPolicy: {{ .Values.opensearch.image.pullPolicy }} + securityContext: + capabilities: + add: ["IPC_LOCK", "SYS_RESOURCE"] + ports: + - containerPort: {{ .Values.opensearch.ports.api }} + name: api + protocol: TCP + - containerPort: {{ .Values.opensearch.ports.performance }} + name: performance + protocol: TCP + env: + # Cluster configuration + - name: node.name + value: {{ .Values.opensearch.cluster.nodeName | quote }} + - name: cluster.name + value: {{ .Values.opensearch.cluster.name | quote }} + - name: discovery.type + value: {{ .Values.opensearch.cluster.discoveryType | quote }} + - name: discovery.seed_hosts + value: {{ .Values.opensearch.cluster.seed_hosts | quote }} + + # Java memory configuration + - name: OPENSEARCH_JAVA_OPTS + value: {{ .Values.opensearch.javaOpts | quote }} + + # Performance configuration + - name: bootstrap.memory_lock + value: {{ .Values.opensearch.bootstrapMemoryLock | quote }} + + # Security configuration + {{- if not .Values.opensearch.security.enabled }} + - name: plugins.security.disabled + value: "true" + {{- end }} + + {{- if .Values.opensearch.persistence.enabled }} + volumeMounts: + - name: opensearch-data + mountPath: {{ .Values.opensearch.persistence.mountPath }} + {{- end }} + + resources: + limits: + cpu: {{ .Values.opensearch.resources.limits.cpu }} + memory: {{ .Values.opensearch.resources.limits.memory }} + requests: + cpu: {{ .Values.opensearch.resources.requests.cpu }} + memory: {{ .Values.opensearch.resources.requests.memory }} + + {{- if .Values.opensearch.persistence.enabled }} + volumes: + - name: opensearch-data + persistentVolumeClaim: + claimName: {{ .Values.release_name }}-data-pvc + {{- end }} + + restartPolicy: Always \ No newline at end of file diff --git a/kubernetes/charts/OpenSearch/templates/ingress.yaml b/kubernetes/charts/OpenSearch/templates/ingress.yaml new file mode 100644 index 00000000..201079f1 --- /dev/null +++ b/kubernetes/charts/OpenSearch/templates/ingress.yaml @@ -0,0 +1,43 @@ +{{- if .Values.opensearch.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ .Values.release_name }}-ingress + labels: + app: {{ .Values.release_name }} + {{- with .Values.opensearch.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.opensearch.ingress.className }} + ingressClassName: {{ .Values.opensearch.ingress.className }} + {{- end }} + {{- if .Values.opensearch.ingress.tls }} + tls: + {{- range .Values.opensearch.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.opensearch.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if .pathType }} + pathType: {{ .pathType }} + {{- end }} + backend: + service: + name: {{ $.Values.release_name }} + port: + number: {{ $.Values.opensearch.service.apiPort }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/OpenSearch/templates/pvc-opensearch.yaml b/kubernetes/charts/OpenSearch/templates/pvc-opensearch.yaml new file mode 100644 index 00000000..09417ffc --- /dev/null +++ b/kubernetes/charts/OpenSearch/templates/pvc-opensearch.yaml @@ -0,0 +1,22 @@ +{{- if .Values.opensearch.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ .Values.release_name }}-data-pvc + namespace: {{ .Release.Namespace }} + labels: + app: {{ .Values.release_name }} +spec: + accessModes: + - {{ .Values.opensearch.persistence.accessMode }} + resources: + requests: + storage: {{ .Values.opensearch.persistence.size }} + {{- if .Values.opensearch.persistence.storageClass }} + {{- if (eq "-" .Values.opensearch.persistence.storageClass) }} + storageClassName: "" + {{- else }} + storageClassName: {{ .Values.opensearch.persistence.storageClass | quote }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/OpenSearch/templates/service-byk-opensearch.yaml b/kubernetes/charts/OpenSearch/templates/service-byk-opensearch.yaml new file mode 100644 index 00000000..cf40517d --- /dev/null +++ b/kubernetes/charts/OpenSearch/templates/service-byk-opensearch.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ .Values.release_name }} + labels: + app: {{ .Values.release_name }} +spec: + type: {{ .Values.opensearch.service.type }} + ports: + - port: {{ .Values.opensearch.service.api }} + targetPort: {{ .Values.opensearch.ports.api }} + protocol: TCP + name: api + - port: {{ .Values.opensearch.service.performance }} + targetPort: {{ .Values.opensearch.ports.performance }} + protocol: TCP + name: performance + selector: + app: {{ .Values.release_name }} \ No newline at end of file diff --git a/kubernetes/charts/OpenSearch/values.yaml b/kubernetes/charts/OpenSearch/values.yaml new file mode 100644 index 00000000..185806c7 --- /dev/null +++ b/kubernetes/charts/OpenSearch/values.yaml @@ -0,0 +1,80 @@ +replicas: 1 + +podAnnotations: {} +podSecurityContext: {} +securityContext: {} + +release_name: "opensearch-node" +dashboards_release_name: "opensearch-dashboards" + +opensearch: + image: + repository: opensearchproject/opensearch + tag: "2.11.1" + pullPolicy: IfNotPresent + + + cluster: + name: "opensearch-cluster" + nodeName: "opensearch-node" + discoveryType: "single-node" + seed_hosts: "opensearch" + + # Java memory configuration + javaOpts: "-Xms512m -Xmx512m" + + # Security configuration + security: + enabled: false + + # Performance settings + bootstrapMemoryLock: false + + # Ports configuration for pod + ports: + api: 9200 + performance: 9600 + + # Persistent storage configuration + persistence: + enabled: true + size: 10Gi + storageClass: "" + accessMode: ReadWriteOnce + mountPath: /usr/share/opensearch/data + + resources: + limits: + cpu: 1000m + memory: 2Gi + requests: + cpu: 200m + memory: 1Gi + + # Service configuration + service: + type: ClusterIP + api: 9200 + performance: 9600 + + # Ingress configuration + ingress: + enabled: false + className: nginx + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / + hosts: + - host: opensearch.global-classifier.local + paths: + - path: / + pathType: Prefix + tls: [] + + # Ulimits (required for OpenSearch memory locking) + ulimits: + memlock: + soft: -1 + hard: -1 + nofile: + soft: 65536 + hard: 65536 \ No newline at end of file diff --git a/kubernetes/charts/Ruuter-Private/templates/configmap-byk-ruuter-private.yaml b/kubernetes/charts/Ruuter-Private/templates/configmap-byk-ruuter-private.yaml index 6f84c283..6b158fb9 100644 --- a/kubernetes/charts/Ruuter-Private/templates/configmap-byk-ruuter-private.yaml +++ b/kubernetes/charts/Ruuter-Private/templates/configmap-byk-ruuter-private.yaml @@ -7,12 +7,13 @@ metadata: data: constants.ini: | [DSL] - RAG_SEARCH_RUUTER_PUBLIC=http://ruuter-public:8086/rag-search - RAG_SEARCH_RUUTER_PRIVATE=http://ruuter-private:8088/rag-search - RAG_SEARCH_DMAPPER=http://data-mapper:3000 + RAG_SEARCH_RUUTER_PUBLIC=http://ruuter-public:8086 + RAG_SEARCH_RUUTER_PRIVATE=http://ruuter-private:8088 + RAG_SEARCH_DMAPPER=http://data-mapper:3001 RAG_SEARCH_RESQL=http://resql:8082/rag-search RAG_SEARCH_PROJECT_LAYER=rag-search RAG_SEARCH_TIM=http://tim:8085 RAG_SEARCH_CRON_MANAGER=http://cron-manager:9010 RAG_SEARCH_LLM_ORCHESTRATOR=http://llm-orchestration-service:8100/orchestrate - DOMAIN=localhost \ No newline at end of file + DOMAIN=localhost + DB_PASSWORD=dbadmin \ No newline at end of file diff --git a/kubernetes/charts/Ruuter-Private/values.yaml b/kubernetes/charts/Ruuter-Private/values.yaml index 3a48ac17..f3d5a644 100644 --- a/kubernetes/charts/Ruuter-Private/values.yaml +++ b/kubernetes/charts/Ruuter-Private/values.yaml @@ -6,7 +6,7 @@ images: scope: registry: "ghcr.io" repository: "buerokratt/ruuter" - tag: "v2.2.1" + tag: "v2.2.8" service: type: ClusterIP @@ -15,7 +15,7 @@ service: env: - APPLICATION_CORS_ALLOWEDORIGINS: "http://gui:3001,http://ruuter-private:8088,http://ruuter-public:8086,http://authentication-layer:3004,http://notifications-node:4040,http://dataset-gen-service:8000,http://localhost:3001" + APPLICATION_CORS_ALLOWEDORIGINS: "https://ec2-34-253-140-113.eu-west-1.compute.amazonaws.com:32017,http://gui:3001,http://ruuter-private:8088,http://ruuter-public:8086,http://authentication-layer:3004,http://notifications-node:4040,http://dataset-gen-service:8000,http://localhost:3001" APPLICATION_HTTPCODESALLOWLIST: "200,201,202,400,401,403,500" APPLICATION_INTERNALREQUESTS_ALLOWEDIPS: "127.0.0.1" APPLICATION_LOGGING_DISPLAYREQUESTCONTENT: "true" @@ -42,9 +42,9 @@ resources: ingress: - enabled: false - host: "rag.local" #change this to domain - corsAllowOrigin: "http://localhost:3001,http://localhost:3003,http://localhost:8088,http://localhost:3002,http://localhost:3004,http://localhost:8000" + enabled: true + host: "ec2-34-253-140-113.eu-west-1.compute.amazonaws.com" #change this to domain + corsAllowOrigin: "https://ec2-34-253-140-113.eu-west-1.compute.amazonaws.com:32017,http://localhost:3001,http://localhost:3003,http://localhost:8088,http://localhost:3002,http://localhost:3004,http://localhost:8000" ssl: enabled: false certIssuerName: "letsencrypt-prod" @@ -55,4 +55,3 @@ pullPolicy: IfNotPresent podAnnotations: dsl-checksum: "initial" - diff --git a/kubernetes/charts/Ruuter-Public/templates/configmap-byk-ruuter-public.yaml b/kubernetes/charts/Ruuter-Public/templates/configmap-byk-ruuter-public.yaml index a6a56c0c..67b08aae 100644 --- a/kubernetes/charts/Ruuter-Public/templates/configmap-byk-ruuter-public.yaml +++ b/kubernetes/charts/Ruuter-Public/templates/configmap-byk-ruuter-public.yaml @@ -7,12 +7,13 @@ metadata: data: constants.ini: | [DSL] - RAG_SEARCH_RUUTER_PUBLIC=http://ruuter-public:8086/rag-search - RAG_SEARCH_RUUTER_PRIVATE=http://ruuter-private:8088/rag-search - RAG_SEARCH_DMAPPER=http://data-mapper:3000 + RAG_SEARCH_RUUTER_PUBLIC=http://ruuter-public:8086 + RAG_SEARCH_RUUTER_PRIVATE=http://ruuter-private:8088 + RAG_SEARCH_DMAPPER=http://data-mapper:3001 RAG_SEARCH_RESQL=http://resql:8082/rag-search RAG_SEARCH_PROJECT_LAYER=rag-search RAG_SEARCH_TIM=http://tim:8085 RAG_SEARCH_CRON_MANAGER=http://cron-manager:9010 RAG_SEARCH_LLM_ORCHESTRATOR=http://llm-orchestration-service:8100/orchestrate - DOMAIN=localhost \ No newline at end of file + DOMAIN=localhost + DB_PASSWORD=dbadmin \ No newline at end of file diff --git a/kubernetes/charts/Ruuter-Public/values.yaml b/kubernetes/charts/Ruuter-Public/values.yaml index 320d43f6..22f27d89 100644 --- a/kubernetes/charts/Ruuter-Public/values.yaml +++ b/kubernetes/charts/Ruuter-Public/values.yaml @@ -6,7 +6,7 @@ images: scope: registry: "ghcr.io" repository: "buerokratt/ruuter" - tag: v2.2.1 + tag: "v2.2.8" service: type: ClusterIP @@ -14,7 +14,7 @@ service: targetPort: 8086 env: - APPLICATION_CORS_ALLOWEDORIGINS: "http://localhost:8086,http://localhost:3001,http://localhost:3003,http://localhost:3004,http://localhost:8080,http://localhost:8000,http://localhost:8090" + APPLICATION_CORS_ALLOWEDORIGINS: "https://ec2-34-253-140-113.eu-west-1.compute.amazonaws.com:32017,http://localhost:8086,http://localhost:3001,http://localhost:3003,http://localhost:3004,http://localhost:8080,http://localhost:8000,http://localhost:8090" APPLICATION_HTTPCODESALLOWLIST: "200,201,202,204,400,401,403,500" APPLICATION_INTERNALREQUESTS_ALLOWEDIPS: "127.0.0.1" APPLICATION_LOGGING_DISPLAYREQUESTCONTENT: "true" @@ -40,8 +40,8 @@ resources: ingress: enabled: true - host: "rag.local" # Change this to domain - corsAllowOrigin: "http://localhost:8086,http://localhost:3001,http://localhost:3003,http://localhost:3004,http://localhost:8080,http://localhost:8000,http://localhost:8090" + host: "ec2-34-253-140-113.eu-west-1.compute.amazonaws.com" # EC2 domain + corsAllowOrigin: "https://ec2-34-253-140-113.eu-west-1.compute.amazonaws.com:32017,http://localhost:8086,http://localhost:3001,http://localhost:3003,http://localhost:3004,http://localhost:8080,http://localhost:8000,http://localhost:8090" ssl: enabled: false # Set to true for production with proper certificates certIssuerName: "letsencrypt-prod" @@ -51,4 +51,4 @@ ingress: pullPolicy: IfNotPresent podAnnotations: - dsl-checksum: "94b84bb5ff4d" + dsl-checksum: "ac610bf9ecc0" \ No newline at end of file diff --git a/kubernetes/charts/Vault-Agent-Cron/templates/configmap.yaml b/kubernetes/charts/Vault-Agent-Cron/templates/configmap.yaml index 37a7af6e..4bebfa76 100644 --- a/kubernetes/charts/Vault-Agent-Cron/templates/configmap.yaml +++ b/kubernetes/charts/Vault-Agent-Cron/templates/configmap.yaml @@ -10,7 +10,9 @@ metadata: component: vault-agent data: cron-agent.hcl: | - + # Vault Agent Configuration for CronManager Service + # This agent provides CronManager with access to encryption keys and write access to secrets + vault { address = "http://vault:8200" retry { @@ -29,7 +31,7 @@ data: } } - # Write token to shared volume for agent to use + # Write token to file for CronManager service to use sink "file" { config = { path = "{{ .Values.agent.tokenPath }}/token" @@ -38,12 +40,12 @@ data: } } - # Caching configuration for CronManager + # Caching configuration cache { - default_lease_duration = "{{ .Values.agent.tokenTTL }}" + default_lease_duration = "{{ .Values.agent.tokenTTL }}" # Medium TTL for CronManager } - # API proxy listener - CronManager connects to localhost:{{ .Values.agent.port }} + # API proxy listener for CronManager service listener "tcp" { address = "0.0.0.0:{{ .Values.agent.port }}" tls_disable = true @@ -52,7 +54,5 @@ data: # API proxy configuration api_proxy { use_auto_auth_token = true - enforce_consistency = "always" - when_inconsistent = "forward" } -{{- end }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Vault-Agent-GUI/templates/configmap.yaml b/kubernetes/charts/Vault-Agent-GUI/templates/configmap.yaml index 72ce877b..11e36dd8 100644 --- a/kubernetes/charts/Vault-Agent-GUI/templates/configmap.yaml +++ b/kubernetes/charts/Vault-Agent-GUI/templates/configmap.yaml @@ -10,7 +10,8 @@ metadata: data: gui-agent.hcl: | # Vault Agent Configuration for GUI Service - + # This agent provides GUI with access to public encryption key only + vault { address = "http://vault:8200" retry { @@ -29,7 +30,7 @@ data: } } - # Write token to shared volume for agent to use + # Write token to file for GUI service to use sink "file" { config = { path = "{{ .Values.agent.tokenPath }}/token" @@ -38,12 +39,12 @@ data: } } - # Caching configuration for GUI + # Caching configuration cache { - default_lease_duration = "{{ .Values.agent.tokenTTL }}" + default_lease_duration = "{{ .Values.agent.tokenTTL }}" # Short-lived tokens for GUI } - # API proxy listener - GUI connects to localhost:{{ .Values.agent.port }} + # API proxy listener for GUI service listener "tcp" { address = "0.0.0.0:{{ .Values.agent.port }}" tls_disable = true @@ -52,7 +53,5 @@ data: # API proxy configuration api_proxy { use_auto_auth_token = true - enforce_consistency = "always" - when_inconsistent = "forward" } -{{- end }} +{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Vault-Agent-LLM/templates/configmap.yaml b/kubernetes/charts/Vault-Agent-LLM/templates/configmap.yaml index 08c38ea2..1af16338 100644 --- a/kubernetes/charts/Vault-Agent-LLM/templates/configmap.yaml +++ b/kubernetes/charts/Vault-Agent-LLM/templates/configmap.yaml @@ -7,15 +7,13 @@ metadata: component: vault-agent data: agent.hcl: | - # Vault Agent Configuration for LLM Orchestration Service - vault { address = "http://vault:8200" retry { num_retries = 5 } } - + auto_auth { method "approle" { mount_path = "auth/approle" @@ -25,8 +23,7 @@ data: remove_secret_id_file_after_reading = false } } - - # Write token to shared volume for agent to use + sink "file" { config = { path = "/agent/llm-token/token" @@ -34,19 +31,16 @@ data: } } } - - # Caching configuration for LLM (longer TTL) + cache { - default_lease_duration = "1h" + default_lease_duration = "1h" # Longer TTL for LLM service } - + listener "tcp" { - address = "0.0.0.0:8201" + address = "0.0.0.0:8201" # Listen on all interfaces tls_disable = true } - + api_proxy { use_auto_auth_token = true - enforce_consistency = "always" - when_inconsistent = "forward" - } + } \ No newline at end of file diff --git a/kubernetes/charts/Vault-Agent-LLM/templates/deployment.yaml b/kubernetes/charts/Vault-Agent-LLM/templates/deployment.yaml deleted file mode 100644 index 943597a0..00000000 --- a/kubernetes/charts/Vault-Agent-LLM/templates/deployment.yaml +++ /dev/null @@ -1,107 +0,0 @@ -# DEPRECATED: This standalone deployment is no longer used -# WHY: Vault Agent now runs as a SIDECAR in LLM-Orchestration-Service pod -# This ensures LLM cannot bypass the agent and access Vault directly -# Keeping this file for any future reference -{{- if .Values.deployment.standalone }} -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ .Values.release_name }} - labels: - app: {{ .Values.release_name }} - component: vault-agent-llm -spec: - replicas: {{ .Values.deployment.replicas }} - selector: - matchLabels: - app: {{ .Values.release_name }} - component: vault-agent-llm - template: - metadata: - labels: - app: {{ .Values.release_name }} - component: vault-agent-llm - spec: - {{- if .Values.affinity.enabled }} - affinity: - podAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - - labelSelector: - matchExpressions: - - key: app - operator: In - values: - - {{ .Values.vault.serviceName }} - topologyKey: kubernetes.io/hostname - {{- end }} - volumes: - {{- if .Values.volumes.agentCredentials.enabled }} - - name: vault-agent-creds - persistentVolumeClaim: - claimName: vault-agent-creds - {{- end }} - {{- if .Values.volumes.agentToken.enabled }} - - name: vault-agent-token - persistentVolumeClaim: - claimName: vault-agent-token - {{- end }} - {{- if .Values.volumes.agentConfig.enabled }} - - name: vault-agent-config - configMap: - name: {{ .Values.release_name }}-config - defaultMode: 0644 - {{- end }} - containers: - - name: vault-agent - image: "{{ .Values.images.vault.registry }}/{{ .Values.images.vault.repository }}:{{ .Values.images.vault.tag }}" - imagePullPolicy: {{ .Values.pullPolicy }} - command: - - vault - - agent - - -config=/agent/config/agent.hcl - - -log-level=info - env: - - name: VAULT_ADDR - value: {{ .Values.vault.addr | quote }} - - name: VAULT_SKIP_VERIFY - value: "true" - volumeMounts: - {{- if .Values.volumes.agentCredentials.enabled }} - - name: vault-agent-creds - mountPath: {{ .Values.volumes.agentCredentials.mountPath }} - readOnly: true - {{- end }} - {{- if .Values.volumes.agentToken.enabled }} - - name: vault-agent-token - mountPath: {{ .Values.volumes.agentToken.mountPath }} - {{- end }} - {{- if .Values.volumes.agentConfig.enabled }} - - name: vault-agent-config - mountPath: {{ .Values.volumes.agentConfig.mountPath }} - readOnly: true - {{- end }} - {{- if .Values.probes.livenessProbe.enabled }} - livenessProbe: - httpGet: - path: {{ .Values.probes.livenessProbe.httpGet.path }} - port: {{ .Values.probes.livenessProbe.httpGet.port }} - initialDelaySeconds: {{ .Values.probes.livenessProbe.initialDelaySeconds }} - periodSeconds: {{ .Values.probes.livenessProbe.periodSeconds }} - {{- end }} - {{- if .Values.probes.readinessProbe.enabled }} - readinessProbe: - httpGet: - path: {{ .Values.probes.readinessProbe.httpGet.path }} - port: {{ .Values.probes.readinessProbe.httpGet.port }} - initialDelaySeconds: {{ .Values.probes.readinessProbe.initialDelaySeconds }} - periodSeconds: {{ .Values.probes.readinessProbe.periodSeconds }} - {{- end }} - {{- if .Values.resources }} - resources: -{{ toYaml .Values.resources | indent 10 }} - {{- end }} - securityContext: - capabilities: - add: - - IPC_LOCK -{{- end }} \ No newline at end of file diff --git a/kubernetes/charts/Vault-Init/templates/configmap.yaml b/kubernetes/charts/Vault-Init/templates/configmap.yaml index 9cc2b12a..f8c02f64 100644 --- a/kubernetes/charts/Vault-Init/templates/configmap.yaml +++ b/kubernetes/charts/Vault-Init/templates/configmap.yaml @@ -9,13 +9,95 @@ data: {{ .Values.initScript.filename }}: | #!/bin/sh set -e - + VAULT_ADDR="${VAULT_ADDR:-http://vault:8200}" UNSEAL_KEYS_FILE="/vault/data/unseal-keys.json" INIT_FLAG="/vault/data/.initialized" - + echo "=== Vault Initialization Script ===" - + + # --------------------------------------------------------------------------- + # Helpers (used by the SUBSEQUENT DEPLOYMENT branch) + # --------------------------------------------------------------------------- + + # Ensure a role_id file exists on disk; fetch from Vault if missing. + # Usage: ensure_role_id + ensure_role_id() { + role="$1"; rid_file="$2" + if [ -f "$rid_file" ] && [ -s "$rid_file" ]; then + return 0 + fi + echo "Fetching role_id for $role..." + rid=$(wget -q -O- \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + "$VAULT_ADDR/v1/auth/approle/role/$role/role-id" | \ + grep -o '"role_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') + echo "$rid" > "$rid_file" + chmod 640 "$rid_file" + } + + # Return 0 if the on-disk role_id + secret_id still authenticate, 1 otherwise. + # Usage: validate_secret_id + validate_secret_id() { + rid_file="$1"; sid_file="$2" + [ -f "$rid_file" ] && [ -f "$sid_file" ] || return 1 + rid=$(cat "$rid_file"); sid=$(cat "$sid_file") + [ -n "$rid" ] && [ -n "$sid" ] || return 1 + # wget returns non-zero on HTTP 400 (invalid creds); also confirm a token came back. + resp=$(wget -q -O- \ + --post-data="{\"role_id\":\"$rid\",\"secret_id\":\"$sid\"}" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/auth/approle/login" 2>/dev/null) || return 1 + echo "$resp" | grep -q '"client_token"' || return 1 + return 0 + } + + # Mint a fresh secret_id for a role and write it to disk. + # Usage: mint_secret_id + mint_secret_id() { + role="$1"; sid_file="$2" + sid=$(wget -q -O- --post-data='' \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + "$VAULT_ADDR/v1/auth/approle/role/$role/secret-id" | \ + grep -o '"secret_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') + echo "$sid" > "$sid_file" + chmod 640 "$sid_file" + } + + # Reuse the existing secret_id if it still authenticates; otherwise mint a new one. + # Usage: reconcile_secret_id + reconcile_secret_id() { + role="$1"; rid_file="$2"; sid_file="$3" + ensure_role_id "$role" "$rid_file" + if validate_secret_id "$rid_file" "$sid_file"; then + echo "$role: existing secret_id still valid - reusing" + else + echo "$role: secret_id invalid or missing - minting a new one" + mint_secret_id "$role" "$sid_file" + fi + } + + # Create or update an AppRole that issues a PERIODIC token (no max_ttl): the + # agent renews it forever and never re-runs approle/login in steady state. + # secret_id_ttl=0 + secret_id_num_uses=0 keep the secret_id valid across + # restarts. Idempotent: does not invalidate existing secret_ids, safe per run. + # Usage: upsert_approle + upsert_approle() { + role="$1"; policy="$2"; period="$3" + wget -q -O- --post-data='{"token_policies":["'"$policy"'"],"token_period":"'"$period"'","token_num_uses":0,"secret_id_ttl":"0","secret_id_num_uses":0,"bind_secret_id":true}' \ + --header="X-Vault-Token: $ROOT_TOKEN" \ + --header='Content-Type: application/json' \ + "$VAULT_ADDR/v1/auth/approle/role/$role" >/dev/null + } + + # Apply the current AppRole definitions for all three services. + ensure_approles() { + echo "Ensuring AppRole configs (periodic tokens)..." + upsert_approle "gui-service" "gui-policy" "20m" + upsert_approle "cron-manager-service" "cron-manager-policy" "30m" + upsert_approle "llm-orchestration-service" "llm-orchestration-policy" "1h" + } + # Wait for Vault to be ready echo "Waiting for Vault..." for i in $(seq 1 30); do @@ -26,7 +108,7 @@ data: echo "Waiting... ($i/30)" sleep 2 done - + # Check if this is first time if [ ! -f "$INIT_FLAG" ]; then echo "=== FIRST TIME DEPLOYMENT ===" @@ -115,6 +197,8 @@ data: path "secret/data/embeddings/connections/*" { capabilities = ["read", "list"] } path "secret/metadata/embeddings/connections/*" { capabilities = ["read", "list"] } path "secret/data/encryption/*" { capabilities = ["deny"] } + path "secret/data/langfuse/*" { capabilities = ["read"] } + path "secret/metadata/langfuse/*" { capabilities = ["read", "list"] } path "auth/token/lookup-self" { capabilities = ["read"] }' LLM_POLICY_JSON=$(echo "$LLM_POLICY" | jq -Rs '{"policy":.}') @@ -123,27 +207,9 @@ data: --header='Content-Type: application/json' \ "$VAULT_ADDR/v1/sys/policies/acl/llm-orchestration-policy" >/dev/null - # Create GUI AppRole - echo "Creating gui-service AppRole..." - wget -q -O- --post-data='{"token_policies":["gui-policy"],"token_no_default_policy":true,"token_ttl":"15m","token_max_ttl":"1h","secret_id_ttl":"24h","secret_id_num_uses":0,"bind_secret_id":true}' \ - --header="X-Vault-Token: $ROOT_TOKEN" \ - --header='Content-Type: application/json' \ - "$VAULT_ADDR/v1/auth/approle/role/gui-service" >/dev/null - - # Create CronManager AppRole - echo "Creating cron-manager-service AppRole..." - wget -q -O- --post-data='{"token_policies":["cron-manager-policy"],"token_no_default_policy":true,"token_ttl":"30m","token_max_ttl":"8h","secret_id_ttl":"24h","secret_id_num_uses":0,"bind_secret_id":true}' \ - --header="X-Vault-Token: $ROOT_TOKEN" \ - --header='Content-Type: application/json' \ - "$VAULT_ADDR/v1/auth/approle/role/cron-manager-service" >/dev/null - - # Create LLM Orchestration AppRole - echo "Creating llm-orchestration-service AppRole..." - wget -q -O- --post-data='{"token_policies":["llm-orchestration-policy"],"token_no_default_policy":true,"token_ttl":"1h","token_max_ttl":"24h","secret_id_ttl":"24h","secret_id_num_uses":0,"bind_secret_id":true}' \ - --header="X-Vault-Token: $ROOT_TOKEN" \ - --header='Content-Type: application/json' \ - "$VAULT_ADDR/v1/auth/approle/role/llm-orchestration-service" >/dev/null - + # Create the three AppRoles (periodic tokens - see upsert_approle). + ensure_approles + # Ensure credentials directory exists mkdir -p /agent/credentials @@ -248,8 +314,7 @@ data: # Mark as initialized touch "$INIT_FLAG" echo "=== First time setup complete ===" - - + else echo "=== SUBSEQUENT DEPLOYMENT ===" @@ -285,65 +350,22 @@ data: # Get root token ROOT_TOKEN=$(grep -o '"root_token":"[^"]*"' "$UNSEAL_KEYS_FILE" | cut -d':' -f2 | tr -d '"') export VAULT_TOKEN="$ROOT_TOKEN" - + + # Re-apply AppRole definitions so config changes (e.g. periodic tokens) + # take effect on redeploy without re-initializing Vault. Idempotent and + # does not invalidate existing secret_ids. + ensure_approles + # Ensure credentials directory exists mkdir -p /agent/credentials - # Always regenerate all secret_ids on restart - echo "Regenerating GUI secret_id..." - GUI_SECRET_ID=$(wget -q -O- --post-data='' \ - --header="X-Vault-Token: $ROOT_TOKEN" \ - "$VAULT_ADDR/v1/auth/approle/role/gui-service/secret-id" | \ - grep -o '"secret_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') - echo "$GUI_SECRET_ID" > /agent/credentials/gui_secret_id - - echo "Regenerating CronManager secret_id..." - CRON_SECRET_ID=$(wget -q -O- --post-data='' \ - --header="X-Vault-Token: $ROOT_TOKEN" \ - "$VAULT_ADDR/v1/auth/approle/role/cron-manager-service/secret-id" | \ - grep -o '"secret_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') - echo "$CRON_SECRET_ID" > /agent/credentials/cron_secret_id - - echo "Regenerating LLM secret_id..." - LLM_SECRET_ID=$(wget -q -O- --post-data='' \ - --header="X-Vault-Token: $ROOT_TOKEN" \ - "$VAULT_ADDR/v1/auth/approle/role/llm-orchestration-service/secret-id" | \ - grep -o '"secret_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') - echo "$LLM_SECRET_ID" > /agent/credentials/llm_secret_id - - # Set permissions - chmod 640 /agent/credentials/*_secret_id - - # Ensure role_ids exist - if [ ! -f /agent/credentials/gui_role_id ]; then - echo "Copying GUI role_id..." - GUI_ROLE_ID=$(wget -q -O- \ - --header="X-Vault-Token: $ROOT_TOKEN" \ - "$VAULT_ADDR/v1/auth/approle/role/gui-service/role-id" | \ - grep -o '"role_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') - echo "$GUI_ROLE_ID" > /agent/credentials/gui_role_id - chmod 640 /agent/credentials/gui_role_id - fi - - if [ ! -f /agent/credentials/cron_role_id ]; then - echo "Copying CronManager role_id..." - CRON_ROLE_ID=$(wget -q -O- \ - --header="X-Vault-Token: $ROOT_TOKEN" \ - "$VAULT_ADDR/v1/auth/approle/role/cron-manager-service/role-id" | \ - grep -o '"role_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') - echo "$CRON_ROLE_ID" > /agent/credentials/cron_role_id - chmod 640 /agent/credentials/cron_role_id - fi - - if [ ! -f /agent/credentials/llm_role_id ]; then - echo "Copying LLM role_id..." - LLM_ROLE_ID=$(wget -q -O- \ - --header="X-Vault-Token: $ROOT_TOKEN" \ - "$VAULT_ADDR/v1/auth/approle/role/llm-orchestration-service/role-id" | \ - grep -o '"role_id":"[^"]*"' | cut -d':' -f2 | tr -d '"') - echo "$LLM_ROLE_ID" > /agent/credentials/llm_role_id - chmod 640 /agent/credentials/llm_role_id - fi + # Reconcile secret_ids: reuse the existing one if it still authenticates, + # mint a new one only if invalid or missing - keeps one stable secret_id + # across restarts instead of rotating every boot. reconcile_secret_id also + # ensures the role_id file exists first (validation needs both). + reconcile_secret_id "gui-service" /agent/credentials/gui_role_id /agent/credentials/gui_secret_id + reconcile_secret_id "cron-manager-service" /agent/credentials/cron_role_id /agent/credentials/cron_secret_id + reconcile_secret_id "llm-orchestration-service" /agent/credentials/llm_role_id /agent/credentials/llm_secret_id fi - + echo "=== Vault init complete ===" \ No newline at end of file diff --git a/kubernetes/charts/Vault-Init/templates/job.yaml b/kubernetes/charts/Vault-Init/templates/job.yaml index 4c1f9811..52758783 100644 --- a/kubernetes/charts/Vault-Init/templates/job.yaml +++ b/kubernetes/charts/Vault-Init/templates/job.yaml @@ -14,6 +14,9 @@ spec: component: vault-init spec: restartPolicy: {{ .Values.job.restartPolicy }} + # Run as root to allow chown of PVC directories (mirrors docker-compose user: "0") + securityContext: + runAsUser: 0 {{- if .Values.affinity.enabled }} affinity: podAffinity: diff --git a/kubernetes/charts/Vault/templates/configmap.yaml b/kubernetes/charts/Vault/templates/configmap.yaml index 1e32fd90..cea5b77c 100644 --- a/kubernetes/charts/Vault/templates/configmap.yaml +++ b/kubernetes/charts/Vault/templates/configmap.yaml @@ -9,24 +9,29 @@ metadata: data: vault.hcl: | # HashiCorp Vault Server Configuration - # Production-ready configuration for LLM Orchestration Service - - # Storage backend - Raft for high availability + # Single-node Raft for the RAG-Module services + + # Storage backend - Raft storage "raft" { path = "/vault/file" node_id = "vault-node-1" - - # Retry join configuration for clustering (single node for now) - retry_join { - leader_api_addr = "http://vault:8200" - } + + # NOTE: No retry_join for a single node. A lone node self-bootstraps. + # A retry_join pointing at itself causes repeated + # "failed to get raft challenge ... Vault is sealed" errors and a + # messy double Raft init on every boot. Add retry_join back only when + # you actually have peer nodes to join. } - - # HTTP listener configuration + + # HTTP API listener. + # Vault automatically uses the next port up (8201) as its internal + # cluster port, so do NOT define a separate listener on 8201 — that + # collides with the cluster listener ("bind: address already in use") + # and degrades the login/request-forwarding path the agents rely on. listener "tcp" { - address = "0.0.0.0:8200" - tls_disable = true - + address = "0.0.0.0:8200" + tls_disable = true + # Enable CORS for web UI access cors_enabled = true cors_allowed_origins = [ @@ -34,33 +39,24 @@ data: "http://vault:8200" ] } - - # Cluster listener for HA (required even for single node) - listener "tcp" { - address = "0.0.0.0:8201" - cluster_addr = "http://0.0.0.0:8201" - tls_disable = true - } - - # API and cluster addresses + + # API and cluster addresses. + # cluster_addr tells Vault where its internal cluster port (8201) is + # reachable; Vault binds that port itself — no listener block needed. api_addr = "http://vault:8200" cluster_addr = "http://vault:8201" - + # Security and performance settings disable_mlock = false disable_cache = false ui = false - + # Default lease and maximum lease durations default_lease_ttl = "168h" # 7 days max_lease_ttl = "720h" # 30 days - + # Logging configuration - log_level = "INFO" + log_level = "INFO" log_format = "json" - - # Development settings (remove in production) - # Note: In production, you should not use dev mode - # and should properly initialize and unseal the vault {{- end }} \ No newline at end of file diff --git a/src/api_tool_indexer/constants.py b/src/api_tool_indexer/constants.py index 1bdd7b6d..43701a64 100644 --- a/src/api_tool_indexer/constants.py +++ b/src/api_tool_indexer/constants.py @@ -23,7 +23,7 @@ class ApiToolIndexerConstants: # LLM / Embedding API DEFAULT_API_BASE_URL = "http://llm-orchestration-service:8100" DEFAULT_ENVIRONMENT = "production" - DEFAULT_CONNECTION_ID = "gpt-4o-mini" + DEFAULT_CONNECTION_ID = "" # Retry Configuration MAX_RETRIES = 3 From c8024273de20764a4c258c923f320c4229623574 Mon Sep 17 00:00:00 2001 From: Charith Nuwan Bimsara <59943919+nuwangeek@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:56:33 +0530 Subject: [PATCH 2/2] unit tests for api tool calling (#485) --- tests/test_agentic_loop.py | 138 +++++++- tests/test_api_caller.py | 6 +- tests/test_api_response_formatter.py | 2 +- tests/test_api_semantic_searcher.py | 65 +++- tests/test_api_tool_session_store.py | 46 ++- tests/test_api_tool_workflow.py | 34 +- tests/test_api_tool_workflow_integration.py | 340 +++++++++++++++++++- tests/test_atc_cache.py | 12 +- tests/test_atc_cache_store.py | 104 ++---- tests/test_direct_step_executor.py | 4 +- tests/test_follow_up_detector.py | 2 +- tests/test_multi_api_caller.py | 8 +- tests/test_multi_response_formatter.py | 2 +- tests/test_param_extractor.py | 2 +- tests/test_qdrant_manager.py | 2 - tests/test_tool_classifier.py | 282 ++++++++++++++++ 16 files changed, 903 insertions(+), 146 deletions(-) diff --git a/tests/test_agentic_loop.py b/tests/test_agentic_loop.py index b8903788..d52fb3ce 100644 --- a/tests/test_agentic_loop.py +++ b/tests/test_agentic_loop.py @@ -5,9 +5,9 @@ import pytest -from src.tool_classifier.agentic_loop import AgenticLoop -from src.tool_classifier.enums import AgenticLoopStatus -from src.tool_classifier.param_extractor import ParamExtractionResult +from tool_classifier.agentic_loop import AgenticLoop +from tool_classifier.enums import AgenticLoopStatus +from tool_classifier.param_extractor import ParamExtractionResult # --------------------------------------------------------------------------- @@ -540,6 +540,7 @@ async def fake_to_thread(fn: Any, *args: Any, **kwargs: Any) -> Any: _HISTORY, {"validFrom": "2026-01-01"}, "en", + 1, ) @@ -963,3 +964,134 @@ async def test_user_exit_during_stream_returns_empty_tokens(self) -> None: assert tokens == [] # Collected params returned unchanged on exit assert result.collected_params == {"validFrom": "2026-01-01"} + + +# --------------------------------------------------------------------------- +# seeded_params — L2 param_update pre-population at turn 0 +# --------------------------------------------------------------------------- + + +class TestSeededParamsTurn0: + """Verify that seeded_params from L2 follow-up routing are merged into + collected_params at turn 0 only, with collected_params taking priority.""" + + @pytest.mark.asyncio + async def test_seeded_params_merged_at_turn_0(self) -> None: + """seeded_params are prepended to collected_params when turn_count=0.""" + # Extractor returns only validFrom as newly extracted; countryIsoCode comes + # from seeded_params. + extractor_mock = _make_extractor_mock( + _extraction( + {"validFrom": "2026-01-01"}, + [], # nothing missing — both params will be present after seed merge + "none", + ) + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="January 2026", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=0, + seeded_params={"countryIsoCode": "EE"}, + ) + + # Both params present → COMPLETED + assert result.status == AgenticLoopStatus.COMPLETED + assert result.collected_params.get("countryIsoCode") == "EE" + assert result.collected_params.get("validFrom") == "2026-01-01" + + @pytest.mark.asyncio + async def test_collected_params_override_seeded_params(self) -> None: + """collected_params values beat seeded_params when the key overlaps.""" + extractor_mock = _make_extractor_mock( + _extraction( + {"validFrom": "2026-06-01"}, + [], + "none", + ) + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="June 2026", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={"countryIsoCode": "LV"}, # explicit value takes priority + turn_count=0, + seeded_params={"countryIsoCode": "EE"}, # seeded value must be overridden + ) + + assert result.collected_params.get("countryIsoCode") == "LV" + + @pytest.mark.asyncio + async def test_seeded_params_not_applied_on_subsequent_turns(self) -> None: + """seeded_params are ignored when turn_count > 0.""" + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode", "validFrom"], "Which country and date?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="hello", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=1, # NOT turn 0 → seeded_params must be ignored + seeded_params={"countryIsoCode": "EE", "validFrom": "2026-01-01"}, + ) + + # Even though seeded_params would satisfy all required params, they should + # not be applied after turn 0 → still NEEDS_INPUT + assert result.status == AgenticLoopStatus.NEEDS_INPUT + # seeded values not present in collected_params + assert "countryIsoCode" not in result.collected_params + assert "validFrom" not in result.collected_params + + @pytest.mark.asyncio + async def test_seeded_params_none_does_not_raise(self) -> None: + """Passing seeded_params=None (default) at turn 0 behaves normally.""" + extractor_mock = _make_extractor_mock( + _extraction({}, ["countryIsoCode", "validFrom"], "Which country?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="hello", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=0, + seeded_params=None, + ) + + assert result.status == AgenticLoopStatus.NEEDS_INPUT + + @pytest.mark.asyncio + async def test_seeded_params_partial_fill_still_asks_for_missing(self) -> None: + """seeded_params satisfy only one of two required params → still NEEDS_INPUT.""" + extractor_mock = _make_extractor_mock( + _extraction({}, ["validFrom"], "From which date?") + ) + loop = _make_loop(extractor_mock) + + result = await loop.run_turn( + chat_id=_CHAT_ID, + user_message="Estonia", + conversation_history=[], + params_schema=_SCHEMA_TWO_REQUIRED, + collected_params={}, + turn_count=0, + seeded_params={"countryIsoCode": "EE"}, # only one param seeded + ) + + # validFrom still missing → NEEDS_INPUT + assert result.status == AgenticLoopStatus.NEEDS_INPUT + # But seeded countryIsoCode should be present + assert result.collected_params.get("countryIsoCode") == "EE" diff --git a/tests/test_api_caller.py b/tests/test_api_caller.py index a6713fdb..de7a535c 100644 --- a/tests/test_api_caller.py +++ b/tests/test_api_caller.py @@ -7,8 +7,8 @@ import httpx import pytest -from src.tool_classifier.api_caller import APICaller, CircuitBreaker -from src.tool_classifier.constants import ( +from tool_classifier.api_caller import APICaller, CircuitBreaker +from tool_classifier.constants import ( CB_STATE_CLOSED, CB_STATE_HALF_OPEN, CB_STATE_OPEN, @@ -16,7 +16,7 @@ SERVICE_TIMEOUT_MESSAGES, SERVICE_UNAVAILABLE_MESSAGES, ) -from src.tool_classifier.models import APICallResult +from tool_classifier.models import APICallResult # --------------------------------------------------------------------------- diff --git a/tests/test_api_response_formatter.py b/tests/test_api_response_formatter.py index 752c57a2..05fe683f 100644 --- a/tests/test_api_response_formatter.py +++ b/tests/test_api_response_formatter.py @@ -9,7 +9,7 @@ import dspy.streaming import pytest -from src.tool_classifier.api_response_formatter import ( +from tool_classifier.api_response_formatter import ( APIResponseFormatterModule, _FORMATTER_ERROR_MESSAGES, ) diff --git a/tests/test_api_semantic_searcher.py b/tests/test_api_semantic_searcher.py index 15711269..f1b44040 100644 --- a/tests/test_api_semantic_searcher.py +++ b/tests/test_api_semantic_searcher.py @@ -448,13 +448,6 @@ async def test_multiple_medium_triggers_disambiguation(self) -> None: client.get = AsyncMock(return_value=count_resp) client.post = AsyncMock(side_effect=[dense_resp, hybrid_resp]) - mock_disambiguator = MagicMock() - mock_disambiguator.return_value = None # "forward" returns string or None - # Wrap in a module-like object that has a forward() callable via __call__ - mock_disambiguator_module = MagicMock() - mock_disambiguator_module.forward = MagicMock(return_value="ep-holidays") - mock_disambiguator_module.__call__ = MagicMock(return_value="ep-holidays") - # Inject our disambiguator — searcher calls self._disambiguator(query, candidates) # which in turn calls forward() via __call__ async_disambiguator = MagicMock() @@ -507,6 +500,64 @@ async def test_disambiguation_rejects_all_returns_empty(self) -> None: assert results == [] + @pytest.mark.asyncio + async def test_disambiguation_rejects_all_multi_candidates_returns_top_with_hint( + self, + ) -> None: + """Disambiguator returns None for >1 medium candidates → top candidate returned + with multi_intent_hint=True and llm_validated=False so IntentDecomposer gate + can run in the classifier.""" + cos_a = API_TOOL_MIN_THRESHOLD + 0.08 # higher cosine → becomes 'top' + cos_b = API_TOOL_MIN_THRESHOLD + 0.02 + + dense_points = [ + _point({**_EP_HOLIDAYS}, cos_a), + _point({**_EP_WEATHER}, cos_b), + ] + hybrid_points = [ + _point({**_EP_HOLIDAYS}, 0.012), + _point({**_EP_WEATHER}, 0.009), + ] + + dense_resp = _make_qdrant_dense_response(dense_points) + hybrid_resp = _make_qdrant_hybrid_response(hybrid_points) + count_resp = _make_count_response(10) + + client = AsyncMock() + client.get = AsyncMock(return_value=count_resp) + client.post = AsyncMock(side_effect=[dense_resp, hybrid_resp]) + + searcher = _make_searcher(client) + + # asyncio.to_thread is called twice: + # 1st call → _get_query_embedding → must return a valid embedding vector + # 2nd call → disambiguator.forward → must return None ("none" response) + precomputed = [0.1] * 10 + _call_count = 0 + + async def _to_thread_side_effect(fn: Any, *args: Any, **kwargs: Any) -> Any: + nonlocal _call_count + _call_count += 1 + if _call_count == 1: + return precomputed # embedding call + return None # disambiguator call → rejects all candidates + + with patch( + "tool_classifier.api_semantic_searcher.asyncio.to_thread", + side_effect=_to_thread_side_effect, + ): + results = await searcher.search("holidays AND weather") + + # Must return exactly one result — the top cosine candidate + assert len(results) == 1 + top = results[0] + # Top candidate by cosine score is ep-holidays + assert top.endpoint_id == "ep-holidays" + # NOT llm_validated — disambiguator explicitly rejected it + assert top.llm_validated is False + # multi_intent_hint signals the classifier to try IntentDecomposer + assert top.multi_intent_hint is True + class TestSearchBelowThreshold: @pytest.mark.asyncio diff --git a/tests/test_api_tool_session_store.py b/tests/test_api_tool_session_store.py index f6e5fb2d..9b877e0b 100644 --- a/tests/test_api_tool_session_store.py +++ b/tests/test_api_tool_session_store.py @@ -5,8 +5,8 @@ import pytest from pydantic import ValidationError -from src.models.session_models import APIToolSession, EndpointSessionState -from src.utils.api_tool_session_store import ( +from models.session_models import APIToolSession, EndpointSessionState +from utils.api_tool_session_store import ( APIToolSessionStore, _key, require_session_store, @@ -185,7 +185,7 @@ async def test_get_returns_none_when_key_missing(self): redis_mock.get = AsyncMock(return_value=None) with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + "utils.api_tool_session_store.get_redis_client", return_value=redis_mock ): result = await store.get("missing-chat") @@ -199,7 +199,7 @@ async def test_get_returns_session_when_key_exists(self): redis_mock.get = AsyncMock(return_value=session.model_dump_json()) with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + "utils.api_tool_session_store.get_redis_client", return_value=redis_mock ): result = await store.get(session.chat_id) @@ -210,9 +210,7 @@ async def test_get_returns_session_when_key_exists(self): @pytest.mark.asyncio async def test_get_returns_none_when_redis_unavailable(self): store = APIToolSessionStore() - with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=None - ): + with patch("utils.api_tool_session_store.get_redis_client", return_value=None): result = await store.get("any-chat") assert result is None @@ -231,7 +229,7 @@ async def test_save_calls_redis_set_with_correct_key_and_ttl(self): redis_mock = _make_redis_mock() with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + "utils.api_tool_session_store.get_redis_client", return_value=redis_mock ): await store.save(session) @@ -245,9 +243,7 @@ async def test_save_skips_when_redis_unavailable(self): store = APIToolSessionStore() session = _make_session() - with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=None - ): + with patch("utils.api_tool_session_store.get_redis_client", return_value=None): # Should not raise await store.save(session) @@ -284,7 +280,7 @@ async def test_update_merges_fields_and_resets_ttl(self): redis_mock.pipeline = MagicMock(return_value=pipe_mock) with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + "utils.api_tool_session_store.get_redis_client", return_value=redis_mock ): result = await store.update( original.chat_id, @@ -313,7 +309,7 @@ async def test_update_returns_none_when_session_missing(self): redis_mock.pipeline = MagicMock(return_value=pipe_mock) with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + "utils.api_tool_session_store.get_redis_client", return_value=redis_mock ): result = await store.update("ghost-chat", turn_count=3) @@ -338,7 +334,7 @@ async def test_update_resets_ttl(self): redis_mock.pipeline = MagicMock(return_value=pipe_mock) with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + "utils.api_tool_session_store.get_redis_client", return_value=redis_mock ): await store.update(session.chat_id, state="ready") @@ -360,7 +356,7 @@ async def test_delete_calls_redis_delete(self): redis_mock = _make_redis_mock() with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + "utils.api_tool_session_store.get_redis_client", return_value=redis_mock ): await store.delete("chat-to-delete") @@ -369,9 +365,7 @@ async def test_delete_calls_redis_delete(self): @pytest.mark.asyncio async def test_delete_skips_when_redis_unavailable(self): store = APIToolSessionStore() - with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=None - ): + with patch("utils.api_tool_session_store.get_redis_client", return_value=None): await store.delete("any-chat") # Should not raise @@ -388,7 +382,7 @@ async def test_exists_returns_true_when_key_present(self): redis_mock.exists = AsyncMock(return_value=1) with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + "utils.api_tool_session_store.get_redis_client", return_value=redis_mock ): result = await store.exists("chat-123") @@ -401,7 +395,7 @@ async def test_exists_returns_false_when_key_absent(self): redis_mock.exists = AsyncMock(return_value=0) with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + "utils.api_tool_session_store.get_redis_client", return_value=redis_mock ): result = await store.exists("chat-123") @@ -410,9 +404,7 @@ async def test_exists_returns_false_when_key_absent(self): @pytest.mark.asyncio async def test_exists_returns_false_when_redis_unavailable(self): store = APIToolSessionStore() - with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=None - ): + with patch("utils.api_tool_session_store.get_redis_client", return_value=None): result = await store.exists("any-chat") assert result is False @@ -431,7 +423,7 @@ async def test_get_returns_none_on_redis_error(self): redis_mock.get = AsyncMock(side_effect=ConnectionError("timeout")) with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + "utils.api_tool_session_store.get_redis_client", return_value=redis_mock ): result = await store.get("chat-xyz") @@ -445,7 +437,7 @@ async def test_save_does_not_raise_on_redis_error(self): redis_mock.set = AsyncMock(side_effect=ConnectionError("timeout")) with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + "utils.api_tool_session_store.get_redis_client", return_value=redis_mock ): await store.save(session) # Should not raise @@ -456,7 +448,7 @@ async def test_delete_does_not_raise_on_redis_error(self): redis_mock.delete = AsyncMock(side_effect=ConnectionError("timeout")) with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + "utils.api_tool_session_store.get_redis_client", return_value=redis_mock ): await store.delete("chat-xyz") # Should not raise @@ -467,7 +459,7 @@ async def test_exists_returns_false_on_redis_error(self): redis_mock.exists = AsyncMock(side_effect=ConnectionError("timeout")) with patch( - "src.utils.api_tool_session_store.get_redis_client", return_value=redis_mock + "utils.api_tool_session_store.get_redis_client", return_value=redis_mock ): result = await store.exists("chat-xyz") diff --git a/tests/test_api_tool_workflow.py b/tests/test_api_tool_workflow.py index 6fc640bb..55483e00 100644 --- a/tests/test_api_tool_workflow.py +++ b/tests/test_api_tool_workflow.py @@ -135,6 +135,9 @@ def _format_sse(chat_id: str, content: str) -> str: return f'data: {{"chatId":"{chat_id}","payload":{{"content":"{content}"}}}}\n\n' svc.format_sse = _format_sse + svc.handle_output_guardrails = AsyncMock( + side_effect=lambda _adapter, response, _req, _costs: response + ) return svc @@ -494,19 +497,25 @@ async def _fake_stream(**kwargs: Any) -> AsyncIterator[str]: for token in ["Holiday", " info", " here."]: yield token - executor._formatter.stream_forward = _fake_stream + mock_formatter = MagicMock() + mock_formatter.stream_forward = _fake_stream - frames = [ - frame - async for frame in executor._stream_api_and_format( - chat_id=_CHAT_ID, - endpoint=_ENDPOINT_HOLIDAYS, - collected_params={"countryIsoCode": "EE"}, - user_query="holidays", - detected_language="en", - orchestration_service=svc, - ) - ] + with patch( + "tool_classifier.workflows.api_tool_workflow.APIResponseFormatterModule", + return_value=mock_formatter, + ): + frames = [ + frame + async for frame in executor._stream_api_and_format( + chat_id=_CHAT_ID, + endpoint=_ENDPOINT_HOLIDAYS, + collected_params={"countryIsoCode": "EE"}, + user_query="holidays", + detected_language="en", + orchestration_service=svc, + request=_make_request(), + ) + ] # 3 token frames + 1 END frame assert len(frames) == 4 @@ -536,6 +545,7 @@ async def test_api_failure_streams_error_frame(self) -> None: user_query="holidays", detected_language="et", orchestration_service=svc, + request=_make_request(), ) ] diff --git a/tests/test_api_tool_workflow_integration.py b/tests/test_api_tool_workflow_integration.py index 4d97fd81..7db8a5a9 100644 --- a/tests/test_api_tool_workflow_integration.py +++ b/tests/test_api_tool_workflow_integration.py @@ -6,6 +6,8 @@ Covers: - Phase 2: Full multi-turn workflow, fast-path, streaming, cost tracking - Phase 4: Fallback chain regression tests +- Parallel execution mode (ExecutionMode.PARALLEL end-to-end) +- Test-endpoint session wipe guard """ import json @@ -17,13 +19,14 @@ import pytest from models.request_models import OrchestrationRequest, OrchestrationResponse -from models.session_models import APIToolSession +from models.session_models import APIToolSession, EndpointSessionState from tool_classifier.classifier import ToolClassifier -from tool_classifier.enums import AgenticLoopStatus, WorkflowType +from tool_classifier.enums import AgenticLoopStatus, ExecutionMode, WorkflowType from tool_classifier.models import ( AgenticLoopResult, APICallResult, ClassificationResult, + MultiAPICallResult, ) @@ -127,6 +130,9 @@ async def _mock_rag(**kwargs: Any) -> OrchestrationResponse: svc._execute_orchestration_pipeline = AsyncMock(side_effect=_mock_rag) svc._initialize_service_components = MagicMock(return_value={}) + svc.handle_output_guardrails = AsyncMock( + side_effect=lambda _adapter, response, _req, _costs: response + ) async def _mock_rag_stream(**kwargs: Any) -> AsyncGenerator[str, None]: yield 'data: {"chatId":"test","payload":{"content":"RAG stream answer"}}\n\n' @@ -484,6 +490,9 @@ async def _fake_stream_forward(**kwargs: Any) -> AsyncIterator[str]: for token in ["It is ", "15°C ", "in Tallinn."]: yield token + mock_formatter = MagicMock() + mock_formatter.stream_forward = _fake_stream_forward + with ( patch.object( classifier.api_tool_workflow._api_caller, @@ -491,10 +500,9 @@ async def _fake_stream_forward(**kwargs: Any) -> AsyncIterator[str]: new_callable=AsyncMock, return_value=api_call_result, ), - patch.object( - classifier.api_tool_workflow._formatter, - "stream_forward", - side_effect=_fake_stream_forward, + patch( + "tool_classifier.workflows.api_tool_workflow.APIResponseFormatterModule", + return_value=mock_formatter, ), ): stream = await classifier.route_to_workflow( @@ -896,3 +904,323 @@ def _make_mock_loop( loop = MagicMock() loop.stream_run_turn = AsyncMock(return_value=(result, question_tokens)) return loop + + +# --------------------------------------------------------------------------- +# TestParallelExecutionMode +# --------------------------------------------------------------------------- + + +class TestParallelExecutionMode: + """Full parallel path: classify → ExecutionMode.PARALLEL → MultiEndpointAgenticLoop + → MultiAPICaller → MultiResponseFormatterModule. + + Only DSPy (formatter/extractor), Qdrant HTTP, and Redis are mocked. + """ + + @pytest.mark.asyncio + async def test_parallel_fast_path_no_required_params_both_apis_called( + self, + classifier: ToolClassifier, + mock_session_store: AsyncMock, + ) -> None: + """Both endpoints have no required params → immediate parallel API calls, no session.""" + + # Two endpoints with no required params + ep_weather_no_params = {**_ENDPOINT_WEATHER, "params": []} + ep_holidays_no_params = {**_ENDPOINT_HOLIDAYS, "params": []} + + classification = ClassificationResult( + workflow=WorkflowType.API_TOOL_CALLING, + confidence=0.68, + metadata={ + "execution_mode": ExecutionMode.PARALLEL, + "matched_endpoints": [ep_weather_no_params, ep_holidays_no_params], + }, + ) + request = _make_request("holidays AND weather") + + weather_result = APICallResult( + success=True, status_code=200, response_data={"temp": 22}, error=None + ) + holidays_result = APICallResult( + success=True, + status_code=200, + response_data={"holidays": ["Jõulupüha"]}, + error=None, + ) + multi_result = MultiAPICallResult( + results=[weather_result, holidays_result], + endpoints=[ + {**ep_weather_no_params, "call_params": {}}, + {**ep_holidays_no_params, "call_params": {}}, + ], + ) + + with ( + patch.object( + classifier.api_tool_workflow._api_caller.__class__, + "__init__", + return_value=None, + ), + patch( + "tool_classifier.workflows.api_tool_workflow.MultiAPICaller", + ) as mock_multi_caller_cls, + patch( + "tool_classifier.workflows.api_tool_workflow.asyncio.to_thread", + new_callable=AsyncMock, + return_value="It is 22°C and there are public holidays.", + ), + ): + mock_multi_caller_inst = AsyncMock() + mock_multi_caller_inst.call_all = AsyncMock(return_value=multi_result) + mock_multi_caller_cls.return_value = mock_multi_caller_inst + + response = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + ) + + assert isinstance(response, OrchestrationResponse) + assert response.content != "" + # No session created (fast path) + assert await mock_session_store.get(_CHAT_ID) is None + + @pytest.mark.asyncio + async def test_parallel_session_created_when_params_needed( + self, + classifier: ToolClassifier, + mock_session_store: AsyncMock, + ) -> None: + """When endpoints have required params, a PARALLEL session is created and a + clarifying question is returned for the first turn.""" + + # Both endpoints have required params + classification = ClassificationResult( + workflow=WorkflowType.API_TOOL_CALLING, + confidence=0.68, + metadata={ + "execution_mode": ExecutionMode.PARALLEL, + "matched_endpoints": [_ENDPOINT_HOLIDAYS, _ENDPOINT_WEATHER], + }, + ) + request = _make_request("holidays AND weather please") + + loop_result = AgenticLoopResult( + status=AgenticLoopStatus.NEEDS_INPUT, + collected_params={}, + clarifying_question="Which country for holidays?", + turn_count=1, + ) + + with patch( + "tool_classifier.workflows.api_tool_workflow.MultiEndpointAgenticLoop", + ) as mock_multi_loop_cls: + mock_multi_loop_inst = MagicMock() + mock_multi_loop_inst.stream_run_turn = AsyncMock( + return_value=(loop_result, ["Which", " country", "?"]) + ) + mock_multi_loop_cls.return_value = mock_multi_loop_inst + + response = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + ) + + assert isinstance(response, OrchestrationResponse) + assert response.content != "" + # Session created in Redis with parallel execution mode + session = await mock_session_store.get(_CHAT_ID) + assert session is not None + assert session.execution_mode == ExecutionMode.PARALLEL.value + + @pytest.mark.asyncio + async def test_parallel_max_turns_falls_back_to_rag( + self, + classifier: ToolClassifier, + mock_session_store: AsyncMock, + ) -> None: + """Parallel loop MAX_TURNS_REACHED → session deleted → RAG fallback.""" + # Seed a parallel session + session = APIToolSession( + chat_id=_CHAT_ID, + state="collecting_params", + selected_endpoint=_ENDPOINT_HOLIDAYS, + collected_params={}, + turn_count=6, + max_turns=6, + awaiting_continuation=False, + detected_language="en", + original_query="holidays AND weather", + execution_mode=ExecutionMode.PARALLEL.value, + parallel_endpoints=[ + EndpointSessionState(endpoint=_ENDPOINT_HOLIDAYS), + EndpointSessionState(endpoint=_ENDPOINT_WEATHER), + ], + ) + await mock_session_store.save(session) + + classification = ClassificationResult( + workflow=WorkflowType.API_TOOL_CALLING, + confidence=1.0, + metadata={"reason": "active_session_resume"}, + ) + request = _make_request("I give up") + + max_turns_result = AgenticLoopResult( + status=AgenticLoopStatus.MAX_TURNS_REACHED, + collected_params={}, + clarifying_question="", + turn_count=7, + ) + + with patch( + "tool_classifier.workflows.api_tool_workflow.MultiEndpointAgenticLoop", + ) as mock_multi_loop_cls: + mock_multi_loop_inst = MagicMock() + mock_multi_loop_inst.stream_run_turn = AsyncMock( + return_value=(max_turns_result, []) + ) + mock_multi_loop_cls.return_value = mock_multi_loop_inst + + response = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=False, + ) + + # Session deleted + assert await mock_session_store.get(_CHAT_ID) is None + # Falls back to RAG + assert isinstance(response, OrchestrationResponse) + + @pytest.mark.asyncio + async def test_parallel_streaming_question_yields_sse_frames( + self, + classifier: ToolClassifier, + mock_session_store: AsyncMock, + ) -> None: + """Streaming parallel path: first turn → clarifying question → SSE frames.""" + + classification = ClassificationResult( + workflow=WorkflowType.API_TOOL_CALLING, + confidence=0.68, + metadata={ + "execution_mode": ExecutionMode.PARALLEL, + "matched_endpoints": [_ENDPOINT_HOLIDAYS, _ENDPOINT_WEATHER], + }, + ) + request = _make_request("holidays AND weather") + + loop_result = AgenticLoopResult( + status=AgenticLoopStatus.NEEDS_INPUT, + collected_params={}, + clarifying_question="Which country for holidays?", + turn_count=1, + ) + + with patch( + "tool_classifier.workflows.api_tool_workflow.MultiEndpointAgenticLoop", + ) as mock_multi_loop_cls: + mock_multi_loop_inst = MagicMock() + mock_multi_loop_inst.stream_run_turn = AsyncMock( + return_value=(loop_result, ["Which", " country", "?"]) + ) + mock_multi_loop_cls.return_value = mock_multi_loop_inst + + stream = await classifier.route_to_workflow( + classification=classification, + request=request, + is_streaming=True, + ) + frames = [frame async for frame in stream] + + assert len(frames) >= 1 + for frame in frames: + assert frame.startswith("data: ") or frame.strip() == "" + + +# --------------------------------------------------------------------------- +# TestTestEndpointSessionWipe +# --------------------------------------------------------------------------- + + +class TestTestEndpointSessionWipe: + """Verify that the /orchestrate/test endpoint deletes any stale 'test-session' + in the API tool session store before each request so multi-turn state never + leaks between consecutive test API calls.""" + + @pytest.mark.asyncio + async def test_session_store_delete_called_with_test_session_key(self) -> None: + """The endpoint must call session_store.delete('test-session') on every request + regardless of whether a session exists.""" + from httpx import AsyncClient, ASGITransport + from llm_orchestration_service_api import app + + session_store_mock = AsyncMock() + session_store_mock.delete = AsyncMock(return_value=None) + + # Minimal orchestration service mock that returns a valid response + orch_mock = AsyncMock() + orch_mock.process_orchestration_request = AsyncMock( + return_value=OrchestrationResponse( + chatId="test-session", + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content="Test answer.", + ) + ) + + app.state.orchestration_service = orch_mock + app.state.session_store = session_store_mock + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + await client.post( + "/orchestrate/test", + json={"message": "hello", "environment": "production"}, + ) + + # The endpoint must have called delete("test-session") before processing + session_store_mock.delete.assert_awaited_with("test-session") + + @pytest.mark.asyncio + async def test_stale_session_cleared_before_request_not_after(self) -> None: + """If session_store.delete raises, the endpoint propagates the error as HTTP 500 + because the wipe-guard is not wrapped in try/except.""" + from httpx import AsyncClient, ASGITransport + from llm_orchestration_service_api import app + + session_store_mock = AsyncMock() + session_store_mock.delete = AsyncMock( + side_effect=RuntimeError("Redis unavailable") + ) + + orch_mock = AsyncMock() + orch_mock.process_orchestration_request = AsyncMock( + return_value=OrchestrationResponse( + chatId="test-session", + llmServiceActive=True, + questionOutOfLLMScope=False, + inputGuardFailed=False, + content="Fallback answer.", + ) + ) + + app.state.orchestration_service = orch_mock + app.state.session_store = session_store_mock + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + resp = await client.post( + "/orchestrate/test", + json={"message": "hello", "environment": "production"}, + ) + + # delete() raised → the unguarded await bubbles up as HTTP 500 + assert resp.status_code == 500 diff --git a/tests/test_atc_cache.py b/tests/test_atc_cache.py index f8390f5e..50ae95b4 100644 --- a/tests/test_atc_cache.py +++ b/tests/test_atc_cache.py @@ -19,10 +19,18 @@ import pytest from models.request_models import OrchestrationRequest -from models.session_models import APIToolSession, EndpointSessionState, LastCallContext +from models.session_models import ( + APIToolSession, + EndpointSessionState, + LastCallContext, +) from tool_classifier.classifier import ToolClassifier from tool_classifier.enums import AgenticLoopStatus, WorkflowType -from tool_classifier.models import AgenticLoopResult, APICallResult, MultiAPICallResult +from tool_classifier.models import ( + AgenticLoopResult, + APICallResult, + MultiAPICallResult, +) from tool_classifier.workflows.api_tool_workflow import APIToolWorkflowExecutor from utils.atc_cache_store import ATCCacheStore diff --git a/tests/test_atc_cache_store.py b/tests/test_atc_cache_store.py index 28e9532b..38593f47 100644 --- a/tests/test_atc_cache_store.py +++ b/tests/test_atc_cache_store.py @@ -5,14 +5,14 @@ import pytest -from src.models.session_models import LastCallContext -from src.tool_classifier.constants import ( +from models.session_models import LastCallContext +from tool_classifier.constants import ( ATC_CACHE_DEFAULT_TTL_SECONDS, ATC_CACHE_KEY_PREFIX, ATC_LAST_CALL_KEY_PREFIX, ATC_LAST_CALL_TTL_SECONDS, ) -from src.utils.atc_cache_store import ATCCacheStore +from utils.atc_cache_store import ATCCacheStore # --------------------------------------------------------------------------- # Shared fixtures @@ -170,9 +170,7 @@ async def test_returns_deserialised_value_on_hit(self): redis_mock = _make_redis_mock() redis_mock.get = AsyncMock(return_value=json.dumps(RESPONSE)) - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): result = await store.get_l1(CHAT_ID, API_NAME, PARAMS) assert result == RESPONSE @@ -183,9 +181,7 @@ async def test_returns_none_on_miss(self): redis_mock = _make_redis_mock() redis_mock.get = AsyncMock(return_value=None) - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): result = await store.get_l1(CHAT_ID, API_NAME, PARAMS) assert result is None @@ -193,7 +189,7 @@ async def test_returns_none_on_miss(self): @pytest.mark.asyncio async def test_returns_none_when_redis_unavailable(self): store = ATCCacheStore() - with patch("src.utils.atc_cache_store.get_redis_client", return_value=None): + with patch("utils.atc_cache_store.get_redis_client", return_value=None): result = await store.get_l1(CHAT_ID, API_NAME, PARAMS) assert result is None @@ -204,9 +200,7 @@ async def test_returns_none_on_redis_exception(self): redis_mock = _make_redis_mock() redis_mock.get = AsyncMock(side_effect=RuntimeError("connection lost")) - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): result = await store.get_l1(CHAT_ID, API_NAME, PARAMS) assert result is None @@ -224,9 +218,7 @@ async def selective_get(key): redis_mock = _make_redis_mock() redis_mock.get = AsyncMock(side_effect=selective_get) - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): result = await store.get_l1(CHAT_ID, API_NAME, different_params) assert result is None @@ -244,9 +236,7 @@ async def test_calls_redis_set_with_correct_key_and_value(self): redis_mock = _make_redis_mock() expected_key = ATCCacheStore._l1_key(CHAT_ID, API_NAME, PARAMS) - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): await store.set_l1(CHAT_ID, API_NAME, PARAMS, RESPONSE) redis_mock.set.assert_called_once_with( @@ -260,9 +250,7 @@ async def test_uses_default_ttl_when_not_specified(self): store = ATCCacheStore() redis_mock = _make_redis_mock() - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): await store.set_l1(CHAT_ID, API_NAME, PARAMS, RESPONSE) assert redis_mock.set.call_args[1]["ex"] == ATC_CACHE_DEFAULT_TTL_SECONDS @@ -272,9 +260,7 @@ async def test_uses_custom_ttl_when_provided(self): store = ATCCacheStore() redis_mock = _make_redis_mock() - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): await store.set_l1(CHAT_ID, API_NAME, PARAMS, RESPONSE, ttl=120) assert redis_mock.set.call_args[1]["ex"] == 120 @@ -282,7 +268,7 @@ async def test_uses_custom_ttl_when_provided(self): @pytest.mark.asyncio async def test_no_op_when_redis_unavailable(self): store = ATCCacheStore() - with patch("src.utils.atc_cache_store.get_redis_client", return_value=None): + with patch("utils.atc_cache_store.get_redis_client", return_value=None): await store.set_l1(CHAT_ID, API_NAME, PARAMS, RESPONSE) # must not raise @pytest.mark.asyncio @@ -291,9 +277,7 @@ async def test_no_op_on_redis_exception(self): redis_mock = _make_redis_mock() redis_mock.set = AsyncMock(side_effect=RuntimeError("write failed")) - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): await store.set_l1(CHAT_ID, API_NAME, PARAMS, RESPONSE) # must not raise @@ -308,9 +292,7 @@ async def test_set_then_get_returns_same_response(self): store = ATCCacheStore() _, redis_mock = _fake_redis_store() - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): await store.set_l1(CHAT_ID, API_NAME, PARAMS, RESPONSE) result = await store.get_l1(CHAT_ID, API_NAME, PARAMS) @@ -324,9 +306,7 @@ async def test_string_year_hits_entry_stored_with_int_year(self): params_int = {"country": "EE", "year": 2026} params_str = {"country": "EE", "year": "2026"} - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): await store.set_l1(CHAT_ID, API_NAME, params_int, RESPONSE) result = await store.get_l1(CHAT_ID, API_NAME, params_str) @@ -339,9 +319,7 @@ async def test_list_response_survives_round_trip(self): _, redis_mock = _fake_redis_store() list_response = [{"date": "2026-02-24"}, {"date": "2026-06-23"}] - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): await store.set_l1(CHAT_ID, API_NAME, PARAMS, list_response) result = await store.get_l1(CHAT_ID, API_NAME, PARAMS) @@ -360,9 +338,7 @@ async def test_round_trip_returns_correct_context_list(self): ctx = _make_last_call_ctx() _, redis_mock = _fake_redis_store() - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): await store.set_l2(CHAT_ID, [ctx]) result = await store.get_l2(CHAT_ID) @@ -381,9 +357,7 @@ async def test_multi_intent_stores_all_entries(self): ctx2 = _make_last_call_ctx("get_electricity_prices") _, redis_mock = _fake_redis_store() - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): await store.set_l2(CHAT_ID, [ctx1, ctx2]) result = await store.get_l2(CHAT_ID) @@ -400,9 +374,7 @@ async def test_set_l2_uses_correct_ttl(self): redis_mock = _make_redis_mock() ctx = _make_last_call_ctx() - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): await store.set_l2(CHAT_ID, [ctx]) assert redis_mock.set.call_args[1]["ex"] == ATC_LAST_CALL_TTL_SECONDS @@ -414,9 +386,7 @@ async def test_set_l2_writes_to_correct_key(self): ctx = _make_last_call_ctx() expected_key = ATCCacheStore._l2_key(CHAT_ID) - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): await store.set_l2(CHAT_ID, [ctx]) assert redis_mock.set.call_args[0][0] == expected_key @@ -426,9 +396,7 @@ async def test_get_l2_returns_none_on_miss(self): store = ATCCacheStore() redis_mock = _make_redis_mock() - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): result = await store.get_l2(CHAT_ID) assert result is None @@ -436,7 +404,7 @@ async def test_get_l2_returns_none_on_miss(self): @pytest.mark.asyncio async def test_get_l2_returns_none_when_redis_unavailable(self): store = ATCCacheStore() - with patch("src.utils.atc_cache_store.get_redis_client", return_value=None): + with patch("utils.atc_cache_store.get_redis_client", return_value=None): result = await store.get_l2(CHAT_ID) assert result is None @@ -447,9 +415,7 @@ async def test_get_l2_returns_none_on_exception(self): redis_mock = _make_redis_mock() redis_mock.get = AsyncMock(side_effect=RuntimeError("connection reset")) - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): result = await store.get_l2(CHAT_ID) assert result is None @@ -458,7 +424,7 @@ async def test_get_l2_returns_none_on_exception(self): async def test_set_l2_no_op_when_redis_unavailable(self): store = ATCCacheStore() ctx = _make_last_call_ctx() - with patch("src.utils.atc_cache_store.get_redis_client", return_value=None): + with patch("utils.atc_cache_store.get_redis_client", return_value=None): await store.set_l2(CHAT_ID, [ctx]) # must not raise @pytest.mark.asyncio @@ -468,9 +434,7 @@ async def test_set_l2_no_op_on_redis_exception(self): redis_mock.set = AsyncMock(side_effect=RuntimeError("write error")) ctx = _make_last_call_ctx() - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): await store.set_l2(CHAT_ID, [ctx]) # must not raise @@ -486,9 +450,7 @@ async def test_calls_delete_with_correct_l2_key(self): redis_mock = _make_redis_mock() expected_key = ATCCacheStore._l2_key(CHAT_ID) - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): await store.invalidate_l2(CHAT_ID) redis_mock.delete.assert_called_once_with(expected_key) @@ -499,9 +461,7 @@ async def test_get_l2_returns_none_after_invalidate(self): _, redis_mock = _fake_redis_store() ctx = _make_last_call_ctx() - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): await store.set_l2(CHAT_ID, [ctx]) await store.invalidate_l2(CHAT_ID) result = await store.get_l2(CHAT_ID) @@ -514,9 +474,7 @@ async def test_invalidate_only_deletes_l2_key_not_l1(self): store = ATCCacheStore() redis_mock = _make_redis_mock() - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): await store.invalidate_l2(CHAT_ID) deleted_key: str = redis_mock.delete.call_args[0][0] @@ -526,7 +484,7 @@ async def test_invalidate_only_deletes_l2_key_not_l1(self): @pytest.mark.asyncio async def test_no_op_when_redis_unavailable(self): store = ATCCacheStore() - with patch("src.utils.atc_cache_store.get_redis_client", return_value=None): + with patch("utils.atc_cache_store.get_redis_client", return_value=None): await store.invalidate_l2(CHAT_ID) # must not raise @pytest.mark.asyncio @@ -535,7 +493,5 @@ async def test_no_op_on_redis_exception(self): redis_mock = _make_redis_mock() redis_mock.delete = AsyncMock(side_effect=RuntimeError("gone")) - with patch( - "src.utils.atc_cache_store.get_redis_client", return_value=redis_mock - ): + with patch("utils.atc_cache_store.get_redis_client", return_value=redis_mock): await store.invalidate_l2(CHAT_ID) # must not raise diff --git a/tests/test_direct_step_executor.py b/tests/test_direct_step_executor.py index 95bc582e..be351ecf 100644 --- a/tests/test_direct_step_executor.py +++ b/tests/test_direct_step_executor.py @@ -10,8 +10,8 @@ import pytest -from src.models.request_models import OrchestrationRequest -from src.tool_classifier.workflows.service_workflow import ServiceWorkflowExecutor +from models.request_models import OrchestrationRequest +from tool_classifier.workflows.service_workflow import ServiceWorkflowExecutor def _make_request( diff --git a/tests/test_follow_up_detector.py b/tests/test_follow_up_detector.py index 6877aa2e..135f8305 100644 --- a/tests/test_follow_up_detector.py +++ b/tests/test_follow_up_detector.py @@ -7,7 +7,7 @@ import dspy import pytest -from src.tool_classifier.follow_up_detector import ( +from tool_classifier.follow_up_detector import ( FollowUpDetectorModule, _validate_updated_params, ) diff --git a/tests/test_multi_api_caller.py b/tests/test_multi_api_caller.py index 906faf4f..1a115100 100644 --- a/tests/test_multi_api_caller.py +++ b/tests/test_multi_api_caller.py @@ -4,16 +4,16 @@ import pytest -from src.tool_classifier.api_caller import APICaller -from src.tool_classifier.constants import ( +from tool_classifier.api_caller import APICaller +from tool_classifier.constants import ( CIRCUIT_BREAKER_OPEN_MESSAGES, MULTI_API_BATCH_TIMEOUT, MULTI_API_PARTIAL_FAILURE_MESSAGES, SERVICE_TIMEOUT_MESSAGES, SERVICE_UNAVAILABLE_MESSAGES, ) -from src.tool_classifier.models import APICallResult -from src.tool_classifier.multi_api_caller import MultiAPICaller +from tool_classifier.models import APICallResult +from tool_classifier.multi_api_caller import MultiAPICaller # --------------------------------------------------------------------------- diff --git a/tests/test_multi_response_formatter.py b/tests/test_multi_response_formatter.py index c5eaaea0..92562168 100644 --- a/tests/test_multi_response_formatter.py +++ b/tests/test_multi_response_formatter.py @@ -8,7 +8,7 @@ import dspy.streaming import pytest -from src.tool_classifier.multi_response_formatter import ( +from tool_classifier.multi_response_formatter import ( MultiResponseFormatterModule, _MULTI_FORMATTER_ERROR_MESSAGES, _MAX_TOTAL_RESPONSE_BYTES, diff --git a/tests/test_param_extractor.py b/tests/test_param_extractor.py index 500fa2d7..f47d2450 100644 --- a/tests/test_param_extractor.py +++ b/tests/test_param_extractor.py @@ -8,7 +8,7 @@ import dspy.streaming import pytest -from src.tool_classifier.param_extractor import ( +from tool_classifier.param_extractor import ( ParamExtractionModule, strip_format_hints, ) diff --git a/tests/test_qdrant_manager.py b/tests/test_qdrant_manager.py index 58b96778..73554999 100644 --- a/tests/test_qdrant_manager.py +++ b/tests/test_qdrant_manager.py @@ -103,8 +103,6 @@ def _make_qdrant_client( """Build a mock QdrantClient.""" client = MagicMock() - col_mock = MagicMock() - col_mock.name = "some_collection" collections_result = MagicMock() collections_result.collections = [MagicMock(name=n) for n in collection_names] # Fix: MagicMock(name=n) doesn't work as expected — set attribute explicitly diff --git a/tests/test_tool_classifier.py b/tests/test_tool_classifier.py index a9158b42..5bda698a 100644 --- a/tests/test_tool_classifier.py +++ b/tests/test_tool_classifier.py @@ -12,6 +12,8 @@ - Qdrant timeout during classification → fallback """ +from __future__ import annotations + from typing import Any, Dict, List, Optional from unittest.mock import AsyncMock, MagicMock, patch @@ -21,6 +23,7 @@ from models.request_models import OrchestrationRequest from tool_classifier.classifier import ToolClassifier from tool_classifier.enums import WorkflowType +from tool_classifier.intent_decomposer import IntentDecomposerModule # --------------------------------------------------------------------------- @@ -476,3 +479,282 @@ async def test_qdrant_timeout_falls_back_to_context(self) -> None: ) assert result.workflow == WorkflowType.CONTEXT + + +# --------------------------------------------------------------------------- +# IntentDecomposerModule +# --------------------------------------------------------------------------- + + +class TestIntentDecomposer: + """Unit tests for IntentDecomposerModule.forward() and .decompose(). + + The DSPy predictor is replaced with a MagicMock so no real LLM is called. + """ + + def _make_module_with_prediction( + self, + mode: str, + sub_queries: str, + ) -> IntentDecomposerModule: + module = IntentDecomposerModule() + mock_pred = MagicMock() + mock_pred.mode = mode + mock_pred.sub_queries = sub_queries + module.predictor = MagicMock(return_value=mock_pred) + return module + + def test_forward_single_mode_returns_single(self) -> None: + from tool_classifier.intent_decomposer import DecompositionResult + + module = self._make_module_with_prediction("single", "[]") + result = module.forward("What are the public holidays in Estonia?") + + assert isinstance(result, DecompositionResult) + assert result.mode == "single" + assert result.sub_queries == [] + + def test_forward_parallel_mode_returns_sub_queries(self) -> None: + from tool_classifier.intent_decomposer import DecompositionResult + + module = self._make_module_with_prediction( + "parallel", + '["public holidays in Estonia", "weather in Tallinn"]', + ) + result = module.forward( + "What are the public holidays in Estonia AND the weather in Tallinn?" + ) + + assert isinstance(result, DecompositionResult) + assert result.mode == "parallel" + assert len(result.sub_queries) == 2 + assert "public holidays in Estonia" in result.sub_queries + + def test_forward_caps_sub_queries_at_max_endpoints(self) -> None: + """sub_queries exceeding MULTI_API_MAX_ENDPOINTS are truncated.""" + from tool_classifier.intent_decomposer import ( + DecompositionResult, + IntentDecomposerModule, + ) + from tool_classifier.constants import MULTI_API_MAX_ENDPOINTS + + module = IntentDecomposerModule() + # Build a prediction with more sub-queries than the cap allows + over_cap = ["query " + str(i) for i in range(MULTI_API_MAX_ENDPOINTS + 2)] + import json + + mock_pred = MagicMock() + mock_pred.mode = "parallel" + mock_pred.sub_queries = json.dumps(over_cap) + module.predictor = MagicMock(return_value=mock_pred) + + result = module.forward("many intents query") + + assert isinstance(result, DecompositionResult) + assert result.mode == "parallel" + assert len(result.sub_queries) == MULTI_API_MAX_ENDPOINTS + + def test_forward_unexpected_mode_falls_back_to_single(self) -> None: + from tool_classifier.intent_decomposer import DecompositionResult + + module = self._make_module_with_prediction("unknown_value", "[]") + result = module.forward("some query") + + assert isinstance(result, DecompositionResult) + assert result.mode == "single" + assert result.sub_queries == [] + + def test_forward_parallel_with_fewer_than_2_sub_queries_falls_back(self) -> None: + """mode=parallel but only 1 sub-query parsed → conservative fallback to single.""" + from tool_classifier.intent_decomposer import DecompositionResult + + module = self._make_module_with_prediction("parallel", '["only one query"]') + result = module.forward("something") + + assert isinstance(result, DecompositionResult) + assert result.mode == "single" + + def test_forward_predictor_exception_falls_back_to_single(self) -> None: + """Any exception from the DSPy predictor → conservative single fallback.""" + from tool_classifier.intent_decomposer import ( + DecompositionResult, + IntentDecomposerModule, + ) + + module = IntentDecomposerModule() + module.predictor = MagicMock(side_effect=RuntimeError("LLM unavailable")) + + result = module.forward("multi intent query") + + assert isinstance(result, DecompositionResult) + assert result.mode == "single" + + def test_forward_parallel_with_invalid_json_falls_back_to_single(self) -> None: + """Invalid JSON in sub_queries → falls back to mode=single.""" + from tool_classifier.intent_decomposer import DecompositionResult + + module = self._make_module_with_prediction("parallel", "not valid json") + result = module.forward("holidays and weather") + + assert isinstance(result, DecompositionResult) + assert result.mode == "single" + + def test_forward_markdown_fenced_json_parsed_correctly(self) -> None: + """sub_queries wrapped in markdown code fences are unwrapped before JSON parse.""" + from tool_classifier.intent_decomposer import DecompositionResult + + fenced = '```json\n["query A", "query B"]\n```' + module = self._make_module_with_prediction("parallel", fenced) + result = module.forward("query") + + assert isinstance(result, DecompositionResult) + assert result.mode == "parallel" + assert result.sub_queries == ["query A", "query B"] + + @pytest.mark.asyncio + async def test_decompose_async_wraps_forward(self) -> None: + """.decompose() is the async wrapper — result matches .forward() output.""" + from tool_classifier.intent_decomposer import DecompositionResult + + module = self._make_module_with_prediction("parallel", '["sub A", "sub B"]') + + with patch( + "tool_classifier.intent_decomposer.asyncio.to_thread", + new_callable=AsyncMock, + ) as mock_thread: + mock_thread.return_value = DecompositionResult( + mode="parallel", sub_queries=["sub A", "sub B"] + ) + result = await module.decompose("two intents") + + assert isinstance(result, DecompositionResult) + assert result.mode == "parallel" + assert result.sub_queries == ["sub A", "sub B"] + + +# --------------------------------------------------------------------------- +# classify() — MULTI_INTENT_ENABLED feature flag toggle +# --------------------------------------------------------------------------- + + +class TestClassifyMultiIntentFeatureFlag: + """Verify the MULTI_INTENT_ENABLED flag gates the parallel decomposition path.""" + + @pytest.mark.asyncio + async def test_multi_intent_disabled_suppresses_hint_result(self) -> None: + """When MULTI_INTENT_ENABLED=False a multi_intent_hint result is suppressed + and the classifier falls through to CONTEXT/RAG.""" + from tool_classifier.api_semantic_searcher import APIToolSearchResult + + svc = _make_orchestration_service(session_store=None) + classifier = _make_classifier(svc) + + # Build a result with multi_intent_hint=True (disambiguator rejected all) + hint_result = MagicMock(spec=APIToolSearchResult) + hint_result.endpoint_id = "ep-holidays" + hint_result.name = "get_public_holidays" + hint_result.description = "Returns public holidays" + hint_result.method = "GET" + hint_result.url = "https://openholidaysapi.org/PublicHolidays" + hint_result.params = [] + hint_result.cosine_score = 0.55 + hint_result.rrf_score = 0.01 + hint_result.confidence = "medium" + hint_result.llm_validated = False + hint_result.multi_intent_hint = True + hint_result.to_dict.return_value = {"endpoint_id": "ep-holidays"} + + classifier.api_tool_searcher.search = AsyncMock(return_value=[hint_result]) + + with ( + patch( + "tool_classifier.classifier.FeatureFlags.API_TOOL_CALLING_WORKFLOW_ENABLED", + True, + ), + patch( + "tool_classifier.classifier.FeatureFlags.MULTI_INTENT_ENABLED", + False, + ), + patch( + "tool_classifier.classifier.FeatureFlags.SERVICE_WORKFLOW_ENABLED", + False, + ), + ): + result = await classifier.classify( + query="public holidays AND weather", + conversation_history=[], + language="en", + request=_make_request("public holidays AND weather"), + ) + + assert result.workflow == WorkflowType.CONTEXT + + @pytest.mark.asyncio + async def test_multi_intent_enabled_triggers_decomposer_on_ambiguous_band( + self, + ) -> None: + """MULTI_INTENT_ENABLED=True + cosine in ambiguous band → IntentDecomposer runs.""" + from tool_classifier.api_semantic_searcher import APIToolSearchResult + from tool_classifier.intent_decomposer import DecompositionResult + + svc = _make_orchestration_service(session_store=None) + classifier = _make_classifier(svc) + + # A result in the ambiguous band (not llm_validated, not multi_intent_hint) + ambiguous_result = MagicMock(spec=APIToolSearchResult) + ambiguous_result.endpoint_id = "ep-holidays" + ambiguous_result.name = "get_public_holidays" + ambiguous_result.description = "Returns public holidays" + ambiguous_result.method = "GET" + ambiguous_result.url = "https://openholidaysapi.org/PublicHolidays" + ambiguous_result.params = [] + ambiguous_result.cosine_score = 0.50 # in [0.40, 0.60) band + ambiguous_result.rrf_score = 0.01 + ambiguous_result.confidence = "medium" + ambiguous_result.llm_validated = False + ambiguous_result.multi_intent_hint = False + ambiguous_result.to_dict.return_value = { + "endpoint_id": "ep-holidays", + "name": "get_public_holidays", + "description": "Returns public holidays", + "method": "GET", + "url": "https://openholidaysapi.org/PublicHolidays", + "params": [], + "cosine_score": 0.50, + "rrf_score": 0.01, + "confidence": "medium", + } + + classifier.api_tool_searcher.search = AsyncMock(return_value=[ambiguous_result]) + + # IntentDecomposer returns single → falls through to single-endpoint path + decomposer_result = DecompositionResult(mode="single", sub_queries=[]) + classifier.intent_decomposer.decompose = AsyncMock( + return_value=decomposer_result + ) + + with ( + patch( + "tool_classifier.classifier.FeatureFlags.API_TOOL_CALLING_WORKFLOW_ENABLED", + True, + ), + patch( + "tool_classifier.classifier.FeatureFlags.MULTI_INTENT_ENABLED", + True, + ), + patch( + "tool_classifier.classifier.FeatureFlags.SERVICE_WORKFLOW_ENABLED", + False, + ), + ): + result = await classifier.classify( + query="public holidays AND weather", + conversation_history=[], + language="en", + request=_make_request("public holidays AND weather"), + ) + + # Decomposer was consulted + classifier.intent_decomposer.decompose.assert_awaited_once() + # Single mode → normal API_TOOL_CALLING result + assert result.workflow == WorkflowType.API_TOOL_CALLING