diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 6fbebbbd68..07e22ffd6b 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -312,6 +312,15 @@ public class ApplicationProperties { /** Timeout (seconds) for the SSE stream held open by long-running orchestrator runs. */ private int streamTimeoutSeconds = 1800; + /** + * Whether the processor pushes admin/settings-derived AI config (models, keys, RAG, limits) + * to the engine's {@code POST /api/v1/config} on startup and after a save. True lets a + * self-hosted admin drive the engine from the UI. Environment-driven deployments pin this + * false (SaaS does in application-saas.properties) so the engine stays entirely + * env-controlled and the processor never overrides its config. + */ + private boolean pushConfigToEngine = true; + /** Model + provider selection, forwarded to the engine per-request. */ private Models models = new Models(); diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index a5db905103..125594e659 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -368,6 +368,7 @@ aiEngine: timeoutSeconds: 120 # Timeout in seconds for AI engine requests longRunningTimeoutSeconds: 600 # Timeout (seconds) for heavy operations like RAG ingestion of large documents streamTimeoutSeconds: 1800 # SSE stream timeout (seconds) for long-running orchestrator runs + pushConfigToEngine: true # Push admin/settings AI config (models, keys, RAG, limits) to the engine on startup + save. Set false to leave the engine fully env-controlled models: provider: anthropic # Model provider: 'anthropic', 'openai', 'ollama', or 'custom' (OpenAI-compatible) smartModel: claude-haiku-4-5 # High-quality tier model name (no provider prefix) diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineConfigSync.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineConfigSync.java index 30bb088400..de48250150 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineConfigSync.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/AiEngineConfigSync.java @@ -22,10 +22,12 @@ import tools.jackson.databind.node.ObjectNode; * from the Stirling settings UI. Pushed on processor startup and again live whenever AI settings * are saved (so model/RAG/limit changes apply without a restart). The engine applies it live * (rebuilds its models) and caches it, so it self-restores on its own reboot. Empty - * key/baseUrl/model fields mean "keep the engine's own environment credential", so - * environment-driven deployments (where the engine sets {@code STIRLING_ALLOW_CONFIG_PUSH=false} - * and rejects the push) stay fully env-controlled. Best-effort and non-blocking: a slow or - * unreachable engine never delays or fails Stirling startup. + * key/baseUrl/model fields mean "keep the engine's own environment credential". + * + *

Gated by {@code aiEngine.pushConfigToEngine} (default true). Environment-driven deployments + * pin it false (SaaS does so in application-saas.properties) so the engine stays entirely + * env-controlled and the processor never pushes settings-derived config to it. Best-effort and + * non-blocking: a slow or unreachable engine never delays or fails Stirling startup. */ @Slf4j @Service @@ -45,6 +47,12 @@ public class AiEngineConfigSync { if (!cfg.isEnabled()) { return; } + if (!cfg.isPushConfigToEngine()) { + log.debug( + "Skipping AI engine config push: aiEngine.pushConfigToEngine is disabled" + + " (the engine is configured from its own environment)"); + return; + } // Engine may still be booting; push on a virtual thread with a few retries so we never // block or crash Stirling startup when the engine is slow or briefly unreachable. Thread.ofVirtual().name("ai-engine-config-sync").start(() -> pushWithRetries(cfg)); @@ -60,9 +68,11 @@ public class AiEngineConfigSync { // Gate on the RUNNING bean: AiEngineClient refuses calls while the bean is disabled, so // pushing on a pending-but-not-restarted enable would always fail. The post-restart // startup push covers first-time enablement. + AiEngine cfg = applicationProperties.getAiEngine(); if (pendingAiEngine == null || pendingAiEngine.isEmpty() - || !applicationProperties.getAiEngine().isEnabled()) { + || !cfg.isPushConfigToEngine() + || !cfg.isEnabled()) { return; } boolean engineRelevant = @@ -70,7 +80,7 @@ public class AiEngineConfigSync { if (!engineRelevant) { return; } - ObjectNode node = buildConfigNode(applicationProperties.getAiEngine()); + ObjectNode node = buildConfigNode(cfg); pendingAiEngine.forEach((k, v) -> overlayIfEngineRelevant(node, k, v)); String body = node.toString(); Thread.ofVirtual().name("ai-engine-config-live-push").start(() -> pushOnce(body)); diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java index 92d10bb907..9b214e8724 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/controller/api/AdminSettingsControllerTest.java @@ -1,10 +1,13 @@ package stirling.software.proprietary.security.controller.api; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; import java.io.IOException; import java.lang.reflect.Field; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -283,6 +286,51 @@ class AdminSettingsControllerTest { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); } } + + @Test + @DisplayName("drops a masked ******** secret so a UI round-trip can't overwrite a real key") + void dropsMaskedSecretValue() { + UpdateSettingsRequest request = new UpdateSettingsRequest(); + Map settings = new HashMap<>(); + settings.put("aiEngine.models.apiKey", "********"); + settings.put("ui.appName", "My App"); + request.setSettings(settings); + + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + ResponseEntity> response = controller.updateSettings(request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + // The masked secret is stripped; only the real change is persisted. + mocked.verify( + () -> + GeneralUtils.updateSettingsTransactional( + argThat( + (Map m) -> + !m.containsKey("aiEngine.models.apiKey") + && m.containsKey("ui.appName")))); + } + } + + @Test + @DisplayName("forwards only aiEngine.* pending keys to the engine live-push") + void forwardsOnlyAiEngineKeysToLivePush() { + UpdateSettingsRequest request = new UpdateSettingsRequest(); + Map settings = new HashMap<>(); + settings.put("aiEngine.models.provider", "ollama"); + settings.put("ui.appName", "My App"); + request.setSettings(settings); + + try (MockedStatic mocked = mockStatic(GeneralUtils.class)) { + controller.updateSettings(request); + + verify(aiEngineConfigSync) + .pushLiveAfterSave( + argThat( + (Map m) -> + m.containsKey("aiEngine.models.provider") + && !m.containsKey("ui.appName"))); + } + } } @Nested diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiEngineConfigSyncTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiEngineConfigSyncTest.java new file mode 100644 index 0000000000..0c48741036 --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiEngineConfigSyncTest.java @@ -0,0 +1,150 @@ +package stirling.software.proprietary.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; + +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.ApplicationProperties.AiEngine; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +/** + * The config-push bridge is a self-hosted-only feature: it lets the admin UI drive the engine's + * model/key/RAG/limit config. Environment-driven deployments pin {@code + * aiEngine.pushConfigToEngine} false (SaaS does so in application-saas.properties) so the engine + * stays entirely env-controlled - these tests lock in that the processor stays silent then while + * still pushing when it is enabled. + */ +class AiEngineConfigSyncTest { + + private ApplicationProperties applicationProperties; + private AiEngineClient aiEngineClient; + private AiEngineConfigSync sync; + + @BeforeEach + void setUp() { + applicationProperties = new ApplicationProperties(); + applicationProperties.getAiEngine().setEnabled(true); + applicationProperties.getAiEngine().setPushConfigToEngine(true); + aiEngineClient = mock(AiEngineClient.class); + ObjectMapper objectMapper = JsonMapper.builder().build(); + sync = new AiEngineConfigSync(applicationProperties, aiEngineClient, objectMapper); + } + + @Test + void startupPushSkippedWhenPushDisabled() throws Exception { + applicationProperties.getAiEngine().setPushConfigToEngine(false); + + sync.pushConfigOnStartup(); + + // Returns synchronously before spawning the push thread, so no interaction ever happens. + verify(aiEngineClient, never()).post(anyString(), anyString(), isNull()); + } + + @Test + void startupPushSkippedWhenDisabled() throws Exception { + applicationProperties.getAiEngine().setEnabled(false); + + sync.pushConfigOnStartup(); + + verify(aiEngineClient, never()).post(anyString(), anyString(), isNull()); + } + + @Test + void startupPushSentWhenEnabledAndPushOn() throws Exception { + sync.pushConfigOnStartup(); + + // Push runs on a virtual thread; wait for the single POST to /api/v1/config. + verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), anyString(), isNull()); + } + + @Test + void livePushSkippedWhenPushDisabled() throws Exception { + applicationProperties.getAiEngine().setPushConfigToEngine(false); + + sync.pushLiveAfterSave(Map.of("aiEngine.models.provider", "ollama")); + + verify(aiEngineClient, never()).post(anyString(), anyString(), isNull()); + } + + @Test + void livePushSentForEngineRelevantChangeWhenPushOn() throws Exception { + sync.pushLiveAfterSave(Map.of("aiEngine.models.provider", "ollama")); + + verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), anyString(), isNull()); + } + + @Test + void startupPushSerialisesTheEngineWireContract() throws Exception { + // Distinct values so a dropped/renamed field is detectable. Mirrors + // engine/tests/fixtures/processor_config_push.json - keep the two in sync; the engine's + // test_config_contract.py validates that same shape on the receiving side. + AiEngine ai = applicationProperties.getAiEngine(); + ai.getModels().setProvider("ollama"); + ai.getModels().setSmartModel("smart-model-x"); + ai.getModels().setFastModel("fast-model-x"); + ai.getModels().setSmartMaxTokens(1111); + ai.getModels().setFastMaxTokens(2222); + ai.getModels().setApiKey("provider-key-abc"); + ai.getModels().setBaseUrl("http://engine.example/v1"); + ai.getRag().setEmbeddingProvider("custom"); + ai.getRag().setEmbeddingModel("embed-model-x"); + ai.getRag().setEmbeddingApiKey("embed-key-abc"); + ai.getRag().setEmbeddingBaseUrl("http://embed.example/v1"); + ai.getRag().setTopK(33); + ai.getRag().setMaxSearches(7); + ai.getLimits().setMaxPages(111); + ai.getLimits().setMaxCharacters(222222); + ai.getLimits().setModelMaxConcurrency(9); + + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + sync.pushConfigOnStartup(); + verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull()); + + JsonNode root = JsonMapper.builder().build().readTree(body.getValue()); + + JsonNode models = root.get("models"); + assertEquals("ollama", models.get("provider").asText()); + assertEquals("smart-model-x", models.get("smartModel").asText()); + assertEquals("fast-model-x", models.get("fastModel").asText()); + assertEquals(1111, models.get("smartMaxTokens").asInt()); + assertEquals(2222, models.get("fastMaxTokens").asInt()); + assertEquals("provider-key-abc", models.get("apiKey").asText()); + assertEquals("http://engine.example/v1", models.get("baseUrl").asText()); + + JsonNode rag = root.get("rag"); + assertEquals("custom", rag.get("embeddingProvider").asText()); + assertEquals("embed-model-x", rag.get("embeddingModel").asText()); + assertEquals("embed-key-abc", rag.get("embeddingApiKey").asText()); + assertEquals("http://embed.example/v1", rag.get("embeddingBaseUrl").asText()); + assertEquals(33, rag.get("topK").asInt()); + assertEquals(7, rag.get("maxSearches").asInt()); + + JsonNode limits = root.get("limits"); + assertEquals(111, limits.get("maxPages").asInt()); + assertEquals(222222, limits.get("maxCharacters").asInt()); + assertEquals(9, limits.get("modelMaxConcurrency").asInt()); + } + + @Test + void livePushSkippedForNonEngineRelevantChange() throws Exception { + // features.* is processor-side only; no engine push is warranted. + sync.pushLiveAfterSave(Map.of("aiEngine.features.chat", false)); + + verify(aiEngineClient, never()).post(anyString(), anyString(), isNull()); + } +} diff --git a/app/proprietary/src/test/java/stirling/software/proprietary/service/AiFeatureGateTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiFeatureGateTest.java new file mode 100644 index 0000000000..6e474c80fd --- /dev/null +++ b/app/proprietary/src/test/java/stirling/software/proprietary/service/AiFeatureGateTest.java @@ -0,0 +1,54 @@ +package stirling.software.proprietary.service; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +import stirling.software.common.model.ApplicationProperties; + +/** + * Every AI capability entry point calls the matching {@code require*} before reaching the engine. + * These lock in fail-closed behaviour: a 503 when the engine is disabled OR the individual feature + * flag is off, and a clean pass only when both are on. + */ +class AiFeatureGateTest { + + private ApplicationProperties props; + private AiFeatureGate gate; + + @BeforeEach + void setUp() { + props = new ApplicationProperties(); + props.getAiEngine().setEnabled(true); // features default all-on + gate = new AiFeatureGate(props); + } + + @Test + void passesWhenEngineEnabledAndFeatureOn() { + assertDoesNotThrow(() -> gate.requireClassify()); + assertDoesNotThrow(() -> gate.requireChat()); + } + + @Test + void throws503WhenFeatureFlagOff() { + props.getAiEngine().getFeatures().setClassify(false); + + ResponseStatusException ex = + assertThrows(ResponseStatusException.class, () -> gate.requireClassify()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode()); + } + + @Test + void throws503WhenEngineDisabledEvenIfFeatureOn() { + props.getAiEngine().setEnabled(false); // feature flag still true + + ResponseStatusException ex = + assertThrows(ResponseStatusException.class, () -> gate.requireChat()); + assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode()); + } +} diff --git a/app/saas/src/main/resources/application-saas.properties b/app/saas/src/main/resources/application-saas.properties index 49798d64cd..aa8823f2fc 100644 --- a/app/saas/src/main/resources/application-saas.properties +++ b/app/saas/src/main/resources/application-saas.properties @@ -1,6 +1,12 @@ # Stirling-PDF SaaS profile. Pure multi-tenant cloud. # Activated when the :saas module is on the classpath. +# ---------- AI engine ---------- +# The SaaS AI backend is env-driven and reached through the saas AiProxyController, so the +# processor must never push settings-derived config to it. Pin the config-push off; the engine +# stays entirely environment-controlled. +aiEngine.pushConfigToEngine=false + # ---------- Datasource ---------- system.datasource.enableCustomDatabase=true system.datasource.customDatabaseUrl=${SAAS_DB_URL:} diff --git a/engine/tests/fixtures/processor_config_push.json b/engine/tests/fixtures/processor_config_push.json new file mode 100644 index 0000000000..b0ee37f6d5 --- /dev/null +++ b/engine/tests/fixtures/processor_config_push.json @@ -0,0 +1,24 @@ +{ + "models": { + "provider": "ollama", + "smartModel": "smart-model-x", + "fastModel": "fast-model-x", + "smartMaxTokens": 1111, + "fastMaxTokens": 2222, + "apiKey": "provider-key-abc", + "baseUrl": "http://engine.example/v1" + }, + "rag": { + "embeddingProvider": "custom", + "embeddingModel": "embed-model-x", + "embeddingApiKey": "embed-key-abc", + "embeddingBaseUrl": "http://embed.example/v1", + "topK": 33, + "maxSearches": 7 + }, + "limits": { + "maxPages": 111, + "maxCharacters": 222222, + "modelMaxConcurrency": 9 + } +} diff --git a/engine/tests/test_config_contract.py b/engine/tests/test_config_contract.py new file mode 100644 index 0000000000..2468ae7bdd --- /dev/null +++ b/engine/tests/test_config_contract.py @@ -0,0 +1,80 @@ +"""Wire-contract test for the processor -> engine config push. + +The Java processor (AiEngineConfigSync.buildConfigNode) serialises the admin AI settings as +camelCase JSON and POSTs them to /api/v1/config. Because ConfigPushRequest uses extra="ignore" +(so version skew never 422s), a field the engine can no longer map is silently dropped to its +default instead of erroring. This test pins the exact camelCase contract: every field in the +shared fixture must round-trip onto the model, so a rename on either side fails loudly. + +The same fixture (tests/fixtures/processor_config_push.json) is the contract the Java +AiEngineConfigSyncTest asserts its serialiser produces - keep the two in sync. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from stirling.contracts import ConfigPushRequest + +FIXTURE = Path(__file__).parent / "fixtures" / "processor_config_push.json" + + +def _load() -> dict: + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + +def test_processor_contract_round_trips_every_field() -> None: + payload = _load() + req = ConfigPushRequest.model_validate(payload) + + m = payload["models"] + assert req.models.provider == m["provider"] + assert req.models.smart_model == m["smartModel"] + assert req.models.fast_model == m["fastModel"] + assert req.models.smart_max_tokens == m["smartMaxTokens"] + assert req.models.fast_max_tokens == m["fastMaxTokens"] + assert req.models.api_key == m["apiKey"] + assert req.models.base_url == m["baseUrl"] + + r = payload["rag"] + assert req.rag.embedding_provider == r["embeddingProvider"] + assert req.rag.embedding_model == r["embeddingModel"] + assert req.rag.embedding_api_key == r["embeddingApiKey"] + assert req.rag.embedding_base_url == r["embeddingBaseUrl"] + assert req.rag.top_k == r["topK"] + assert req.rag.max_searches == r["maxSearches"] + + limits = payload["limits"] + assert req.limits.max_pages == limits["maxPages"] + assert req.limits.max_characters == limits["maxCharacters"] + assert req.limits.model_max_concurrency == limits["modelMaxConcurrency"] + + +def test_processor_contract_has_no_unmapped_keys() -> None: + """Guard the fixture itself: every wire key must map to a model field (none silently + absorbed by extra="ignore"). If the processor adds a field, extend the contract here.""" + payload = _load() + expected = { + "models": { + "provider", + "smartModel", + "fastModel", + "smartMaxTokens", + "fastMaxTokens", + "apiKey", + "baseUrl", + }, + "rag": { + "embeddingProvider", + "embeddingModel", + "embeddingApiKey", + "embeddingBaseUrl", + "topK", + "maxSearches", + }, + "limits": {"maxPages", "maxCharacters", "modelMaxConcurrency"}, + } + assert set(payload) == set(expected) + for section, keys in expected.items(): + assert set(payload[section]) == keys diff --git a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.test.tsx b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.test.tsx new file mode 100644 index 0000000000..2cfae0ccba --- /dev/null +++ b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { type TFunction } from "i18next"; +import { allowConsole } from "@app/tests/failOnConsole"; +import { createSaasConfigNavSections } from "@app/components/shared/config/saasConfigNavSections"; + +// Passthrough i18n stub: return the provided fallback (2nd arg) or the key. +const t = ((key: string, fallback?: string) => + fallback ?? key) as unknown as TFunction<"translation", undefined>; + +const Overview = () => null; + +type Sections = ReturnType; + +function itemKeys(sections: Sections): string[] { + return sections.flatMap((s) => s.items.map((i) => i.key)); +} + +// The admin AI settings pages exist only in the self-hosted proprietary flavor. SaaS builds its +// settings nav from scratch here, so this locks in that the AI group can never leak into SaaS - +// if someone later wires the AI sections into the SaaS cascade, this fails loudly. +describe("saasConfigNavSections", () => { + const AI_ITEM_KEYS = [ + "adminAiGeneral", + "adminAiModels", + "adminAiDocuments", + "adminAiLimits", + ]; + + it("never exposes the admin AI settings group or its pages", () => { + // The shared core nav helper warns it is deprecated; incidental to this test. + allowConsole.warn(/createConfigNavSections is deprecated/); + const sections = createSaasConfigNavSections(Overview, () => {}, { t }); + + const keys = itemKeys(sections); + for (const aiKey of AI_ITEM_KEYS) { + expect(keys).not.toContain(aiKey); + } + expect(sections.map((s) => s.title)).not.toContain("AI"); + }); + + it("also hides the AI pages for anonymous users", () => { + allowConsole.warn(/createConfigNavSections is deprecated/); + const sections = createSaasConfigNavSections(Overview, () => {}, { + t, + isAnonymous: true, + }); + + const keys = itemKeys(sections); + for (const aiKey of AI_ITEM_KEYS) { + expect(keys).not.toContain(aiKey); + } + }); +});