mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
Ai customization settings (#7069)
# Description of Changes AI settings customisation in settings menu, as part of this also tested and fixed ollama and other 3rd party AI integrations - Adds an admin AI settings UI for customizing AI behaviour, including per-provider model and API-key configuration - Backend pushes AI config changes to the Python engine at runtime via a config-push bridge, so changes apply without a restart - Config-push is gated off in SaaS; engine now drains background tasks on shutdown instead of cancelling them --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have run `task check` to verify linters, typechecks, and tests pass - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing) for more details.
This commit is contained in:
@@ -305,6 +305,102 @@ public class ApplicationProperties {
|
|||||||
* explicitly requests it via {@code AiEngineClient.postWithTimeout}.
|
* explicitly requests it via {@code AiEngineClient.postWithTimeout}.
|
||||||
*/
|
*/
|
||||||
private int longRunningTimeoutSeconds = 600;
|
private int longRunningTimeoutSeconds = 600;
|
||||||
|
|
||||||
|
/** Timeout (seconds) for the SSE stream held open by long-running orchestrator runs. */
|
||||||
|
private int streamTimeoutSeconds = 1800;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the processor pushes settings-derived AI config to the engine on startup/save.
|
||||||
|
* Pin false for env-driven deployments (SaaS) to keep the engine env-controlled.
|
||||||
|
*/
|
||||||
|
private boolean pushConfigToEngine = true;
|
||||||
|
|
||||||
|
/** Model + provider selection, forwarded to the engine per-request. */
|
||||||
|
private Models models = new Models();
|
||||||
|
|
||||||
|
/** Retrieval-augmented-generation (RAG) knobs, forwarded to the engine per-request. */
|
||||||
|
private Rag rag = new Rag();
|
||||||
|
|
||||||
|
/** Request size / cost guardrails. */
|
||||||
|
private Limits limits = new Limits();
|
||||||
|
|
||||||
|
/** Per-capability on/off switches so an admin can disable individual AI tools. */
|
||||||
|
private Features features = new Features();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Models {
|
||||||
|
/** Provider driving the model strings: 'anthropic', 'openai', 'ollama', or 'custom'. */
|
||||||
|
private String provider = "anthropic";
|
||||||
|
|
||||||
|
/** High-quality tier model name (without provider prefix), e.g. 'claude-haiku-4-5'. */
|
||||||
|
private String smartModel = "claude-haiku-4-5";
|
||||||
|
|
||||||
|
/** Cheap/fast tier model name (without provider prefix). */
|
||||||
|
private String fastModel = "claude-haiku-4-5";
|
||||||
|
|
||||||
|
private int smartMaxTokens = 8192;
|
||||||
|
private int fastMaxTokens = 2048;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API key for the selected provider (secret; masked). Empty means the engine uses its
|
||||||
|
* own env credential (e.g. ANTHROPIC_API_KEY).
|
||||||
|
*/
|
||||||
|
private String apiKey = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI-compatible base URL for 'ollama' / 'custom' providers (e.g.
|
||||||
|
* http://ollama:11434/v1). Ignored for anthropic/openai. SSRF-sensitive - admin only.
|
||||||
|
*/
|
||||||
|
private String baseUrl = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Rag {
|
||||||
|
/**
|
||||||
|
* Embedding provider: 'voyageai', 'openai', 'ollama', or 'custom' (OpenAI-compatible).
|
||||||
|
*/
|
||||||
|
private String embeddingProvider = "voyageai";
|
||||||
|
|
||||||
|
/** Embedding model name (without provider prefix), e.g. 'voyage-4'. */
|
||||||
|
private String embeddingModel = "voyage-4";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secret API key for the embedding provider; masked + env-overridable like
|
||||||
|
* models.apiKey.
|
||||||
|
*/
|
||||||
|
private String embeddingApiKey = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI-compatible base URL for 'ollama' / 'custom' embedding providers (e.g.
|
||||||
|
* http://ollama:11434/v1). Ignored for voyageai/openai. SSRF-sensitive - admin only.
|
||||||
|
*/
|
||||||
|
private String embeddingBaseUrl = "";
|
||||||
|
|
||||||
|
/** How many chunks retrieval returns per search. */
|
||||||
|
private int topK = 20;
|
||||||
|
|
||||||
|
/** Per-run cap on knowledge-search tool calls before the agent must answer. */
|
||||||
|
private int maxSearches = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Limits {
|
||||||
|
private int maxPages = 200;
|
||||||
|
private int maxCharacters = 200000;
|
||||||
|
|
||||||
|
/** Process-wide cap on concurrent model API calls (engine restart to apply). */
|
||||||
|
private int modelMaxConcurrency = 32;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Features {
|
||||||
|
private boolean chat = true;
|
||||||
|
private boolean documentQuestions = true;
|
||||||
|
private boolean createPdf = true;
|
||||||
|
private boolean mathAuditor = true;
|
||||||
|
private boolean pdfComment = true;
|
||||||
|
private boolean classify = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+13
-1
@@ -336,7 +336,19 @@ public class ConfigController {
|
|||||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||||
|
|
||||||
// AI Engine settings
|
// AI Engine settings
|
||||||
configData.put("aiEngineEnabled", applicationProperties.getAiEngine().isEnabled());
|
ApplicationProperties.AiEngine aiEngineConfig = applicationProperties.getAiEngine();
|
||||||
|
configData.put("aiEngineEnabled", aiEngineConfig.isEnabled());
|
||||||
|
// Per-capability flags let the UI hide individual AI tools an admin has turned off.
|
||||||
|
ApplicationProperties.AiEngine.Features aiFeatures = aiEngineConfig.getFeatures();
|
||||||
|
configData.put(
|
||||||
|
"aiFeatures",
|
||||||
|
Map.ofEntries(
|
||||||
|
Map.entry("chat", aiFeatures.isChat()),
|
||||||
|
Map.entry("documentQuestions", aiFeatures.isDocumentQuestions()),
|
||||||
|
Map.entry("createPdf", aiFeatures.isCreatePdf()),
|
||||||
|
Map.entry("mathAuditor", aiFeatures.isMathAuditor()),
|
||||||
|
Map.entry("pdfComment", aiFeatures.isPdfComment()),
|
||||||
|
Map.entry("classify", aiFeatures.isClassify())));
|
||||||
|
|
||||||
// Timestamp TSA settings — single source of truth for presets + admin URLs
|
// Timestamp TSA settings — single source of truth for presets + admin URLs
|
||||||
ApplicationProperties.Security.Timestamp tsConfig =
|
ApplicationProperties.Security.Timestamp tsConfig =
|
||||||
|
|||||||
@@ -366,6 +366,35 @@ aiEngine:
|
|||||||
enabled: false # Set to 'true' to enable the AI engine integration
|
enabled: false # Set to 'true' to enable the AI engine integration
|
||||||
url: http://localhost:5001 # URL of the Python AI engine
|
url: http://localhost:5001 # URL of the Python AI engine
|
||||||
timeoutSeconds: 120 # Timeout in seconds for AI engine requests
|
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 AI config to the engine on startup + save; false = engine stays 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)
|
||||||
|
fastModel: claude-haiku-4-5 # Cheap/fast tier model name (no provider prefix)
|
||||||
|
smartMaxTokens: 8192 # Max output tokens for the smart tier
|
||||||
|
fastMaxTokens: 2048 # Max output tokens for the fast tier
|
||||||
|
apiKey: "" # API key for the selected provider (secret). Empty = engine uses its native env credentials (e.g. ANTHROPIC_API_KEY)
|
||||||
|
baseUrl: "" # OpenAI-compatible base URL for 'ollama'/'custom' providers (e.g. http://ollama:11434/v1). Ignored for anthropic/openai
|
||||||
|
rag:
|
||||||
|
embeddingProvider: voyageai # Embedding provider: 'voyageai', 'openai', 'ollama', or 'custom' (OpenAI-compatible)
|
||||||
|
embeddingModel: voyage-4 # Embedding model name (no provider prefix)
|
||||||
|
embeddingApiKey: "" # Secret API key for the embedding provider. Empty = engine uses its native env credentials (e.g. VOYAGE_API_KEY)
|
||||||
|
embeddingBaseUrl: "" # OpenAI-compatible base URL for 'ollama'/'custom' embedding providers (e.g. http://ollama:11434/v1). Ignored for voyageai/openai
|
||||||
|
topK: 20 # Number of chunks retrieval returns per search
|
||||||
|
maxSearches: 5 # Per-run cap on knowledge-search tool calls before the agent must answer
|
||||||
|
limits:
|
||||||
|
maxPages: 200 # Upper bound on PDF pages the engine will process per request
|
||||||
|
maxCharacters: 200000 # Upper bound on characters of extracted text per request
|
||||||
|
modelMaxConcurrency: 32 # Process-wide cap on concurrent model API calls (engine restart to apply)
|
||||||
|
features: # Per-capability switches; turn an individual AI tool off without disabling the whole engine
|
||||||
|
chat: true # Assistant chat
|
||||||
|
documentQuestions: true # Ask-questions-about-a-PDF
|
||||||
|
createPdf: true # Generate a PDF from a natural-language spec
|
||||||
|
mathAuditor: true # Numerical/formula contradiction auditing
|
||||||
|
pdfComment: true # AI-authored PDF comments/annotations
|
||||||
|
classify: true # Automatic document classification/labelling
|
||||||
|
|
||||||
policies:
|
policies:
|
||||||
# Folder automations can read from and write to the directories you allow here, so treat this as a
|
# Folder automations can read from and write to the directories you allow here, so treat this as a
|
||||||
@@ -385,6 +414,8 @@ policies:
|
|||||||
mcp:
|
mcp:
|
||||||
enabled: false # Master switch. 'false' (default) means no /mcp endpoint, no metadata, no beans wired.
|
enabled: false # Master switch. 'false' (default) means no /mcp endpoint, no metadata, no beans wired.
|
||||||
scopesEnabled: true # Enforce mcp.tools.read / mcp.tools.write scopes derived from operation category
|
scopesEnabled: true # Enforce mcp.tools.read / mcp.tools.write scopes derived from operation category
|
||||||
|
maxRequestBytes: 10485760 # Max size (bytes) of an incoming MCP tool request payload (default 10 MB)
|
||||||
|
maxInlineResponseBytes: 10485760 # Max size (bytes) of an MCP tool response returned inline before it is rejected (default 10 MB)
|
||||||
allowedOperations: [] # Tool allow-list (operation ids, e.g. ['compress-pdf']). Empty = all. When set, ONLY these are exposed over MCP.
|
allowedOperations: [] # Tool allow-list (operation ids, e.g. ['compress-pdf']). Empty = all. When set, ONLY these are exposed over MCP.
|
||||||
blockedOperations: [] # Tool deny-list (operation ids). Always removed from MCP even if otherwise allowed.
|
blockedOperations: [] # Tool deny-list (operation ids). Always removed from MCP even if otherwise allowed.
|
||||||
auth:
|
auth:
|
||||||
|
|||||||
+15
-6
@@ -7,7 +7,6 @@ import java.util.concurrent.Executor;
|
|||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -28,6 +27,7 @@ import jakarta.validation.Valid;
|
|||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
import stirling.software.common.model.job.ResultFile;
|
import stirling.software.common.model.job.ResultFile;
|
||||||
import stirling.software.common.service.JobOwnershipService;
|
import stirling.software.common.service.JobOwnershipService;
|
||||||
import stirling.software.common.service.TaskManager;
|
import stirling.software.common.service.TaskManager;
|
||||||
@@ -38,6 +38,7 @@ import stirling.software.proprietary.model.api.ai.AiWorkflowResponse;
|
|||||||
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
|
import stirling.software.proprietary.model.api.ai.AiWorkflowResultFile;
|
||||||
import stirling.software.proprietary.service.AiEngineClient;
|
import stirling.software.proprietary.service.AiEngineClient;
|
||||||
import stirling.software.proprietary.service.AiEngineEndpointResolver;
|
import stirling.software.proprietary.service.AiEngineEndpointResolver;
|
||||||
|
import stirling.software.proprietary.service.AiFeatureGate;
|
||||||
import stirling.software.proprietary.service.AiWorkflowService;
|
import stirling.software.proprietary.service.AiWorkflowService;
|
||||||
|
|
||||||
import tools.jackson.core.JacksonException;
|
import tools.jackson.core.JacksonException;
|
||||||
@@ -60,15 +61,14 @@ public class AiEngineController {
|
|||||||
private final TaskManager taskManager;
|
private final TaskManager taskManager;
|
||||||
private final JobOwnershipService jobOwnershipService;
|
private final JobOwnershipService jobOwnershipService;
|
||||||
private final AiEngineEndpointResolver endpointResolver;
|
private final AiEngineEndpointResolver endpointResolver;
|
||||||
|
private final AiFeatureGate aiFeatureGate;
|
||||||
private final UserServiceInterface userService;
|
private final UserServiceInterface userService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
|
* SSE emitter timeout (ms), long enough for multi-gigabyte PDF workflows without completing out
|
||||||
* 1000-page scan, splitting a huge PDF, etc.) without the emitter completing out from under the
|
* from under the executor. Derived from {@code aiEngine.streamTimeoutSeconds}.
|
||||||
* executor. Configurable via {@code stirling.ai.streamTimeoutMs}.
|
|
||||||
*/
|
*/
|
||||||
@Value("${stirling.ai.streamTimeoutMs:1800000}")
|
private final long streamTimeoutMs;
|
||||||
private long streamTimeoutMs;
|
|
||||||
|
|
||||||
public AiEngineController(
|
public AiEngineController(
|
||||||
AiEngineClient aiEngineClient,
|
AiEngineClient aiEngineClient,
|
||||||
@@ -78,6 +78,8 @@ public class AiEngineController {
|
|||||||
TaskManager taskManager,
|
TaskManager taskManager,
|
||||||
JobOwnershipService jobOwnershipService,
|
JobOwnershipService jobOwnershipService,
|
||||||
AiEngineEndpointResolver endpointResolver,
|
AiEngineEndpointResolver endpointResolver,
|
||||||
|
AiFeatureGate aiFeatureGate,
|
||||||
|
ApplicationProperties applicationProperties,
|
||||||
@Autowired(required = false) UserServiceInterface userService) {
|
@Autowired(required = false) UserServiceInterface userService) {
|
||||||
this.aiEngineClient = aiEngineClient;
|
this.aiEngineClient = aiEngineClient;
|
||||||
this.aiWorkflowService = aiWorkflowService;
|
this.aiWorkflowService = aiWorkflowService;
|
||||||
@@ -86,7 +88,10 @@ public class AiEngineController {
|
|||||||
this.taskManager = taskManager;
|
this.taskManager = taskManager;
|
||||||
this.jobOwnershipService = jobOwnershipService;
|
this.jobOwnershipService = jobOwnershipService;
|
||||||
this.endpointResolver = endpointResolver;
|
this.endpointResolver = endpointResolver;
|
||||||
|
this.aiFeatureGate = aiFeatureGate;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
|
this.streamTimeoutMs =
|
||||||
|
applicationProperties.getAiEngine().getStreamTimeoutSeconds() * 1000L;
|
||||||
}
|
}
|
||||||
|
|
||||||
private String currentUserId() {
|
private String currentUserId() {
|
||||||
@@ -111,6 +116,7 @@ public class AiEngineController {
|
|||||||
+ " system and downloadable via GET /api/v1/general/files/{fileId}.")
|
+ " system and downloadable via GET /api/v1/general/files/{fileId}.")
|
||||||
public AiWorkflowResponse orchestrate(@Valid @ModelAttribute AiWorkflowRequest request)
|
public AiWorkflowResponse orchestrate(@Valid @ModelAttribute AiWorkflowRequest request)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
|
aiFeatureGate.requireConversationalWorkflow();
|
||||||
AiWorkflowResponse result = aiWorkflowService.orchestrate(request);
|
AiWorkflowResponse result = aiWorkflowService.orchestrate(request);
|
||||||
registerFileResultAsJob(result);
|
registerFileResultAsJob(result);
|
||||||
return result;
|
return result;
|
||||||
@@ -123,6 +129,7 @@ public class AiEngineController {
|
|||||||
"Accepts a PDF upload and a user message, returns SSE events with progress"
|
"Accepts a PDF upload and a user message, returns SSE events with progress"
|
||||||
+ " updates followed by the final AI workflow result")
|
+ " updates followed by the final AI workflow result")
|
||||||
public SseEmitter orchestrateStream(@Valid @ModelAttribute AiWorkflowRequest request) {
|
public SseEmitter orchestrateStream(@Valid @ModelAttribute AiWorkflowRequest request) {
|
||||||
|
aiFeatureGate.requireConversationalWorkflow();
|
||||||
SseEmitter emitter = new SseEmitter(streamTimeoutMs);
|
SseEmitter emitter = new SseEmitter(streamTimeoutMs);
|
||||||
|
|
||||||
emitter.onTimeout(
|
emitter.onTimeout(
|
||||||
@@ -246,6 +253,8 @@ public class AiEngineController {
|
|||||||
"Sends a user message to the PDF edit agent which returns a structured plan"
|
"Sends a user message to the PDF edit agent which returns a structured plan"
|
||||||
+ " of tool operations to perform")
|
+ " of tool operations to perform")
|
||||||
public ResponseEntity<String> pdfEdit(@RequestBody String requestBody) throws IOException {
|
public ResponseEntity<String> pdfEdit(@RequestBody String requestBody) throws IOException {
|
||||||
|
// Same gate as /orchestrate: edit agent is a model call on the same conversational surface.
|
||||||
|
aiFeatureGate.requireConversationalWorkflow();
|
||||||
JsonNode parsed = parseJson(requestBody);
|
JsonNode parsed = parseJson(requestBody);
|
||||||
if (!parsed.isObject()) {
|
if (!parsed.isObject()) {
|
||||||
throw new ResponseStatusException(
|
throw new ResponseStatusException(
|
||||||
|
|||||||
+5
@@ -35,6 +35,7 @@ import stirling.software.proprietary.classification.ClassificationLabelProvider;
|
|||||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||||
import stirling.software.proprietary.model.api.ai.AiPageText;
|
import stirling.software.proprietary.model.api.ai.AiPageText;
|
||||||
import stirling.software.proprietary.service.AiEngineClient;
|
import stirling.software.proprietary.service.AiEngineClient;
|
||||||
|
import stirling.software.proprietary.service.AiFeatureGate;
|
||||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||||
|
|
||||||
import tools.jackson.databind.JsonNode;
|
import tools.jackson.databind.JsonNode;
|
||||||
@@ -67,6 +68,7 @@ public class ClassifyLabelController {
|
|||||||
private final PdfContentExtractor pdfContentExtractor;
|
private final PdfContentExtractor pdfContentExtractor;
|
||||||
private final PdfMetadataService pdfMetadataService;
|
private final PdfMetadataService pdfMetadataService;
|
||||||
private final AiEngineClient aiEngineClient;
|
private final AiEngineClient aiEngineClient;
|
||||||
|
private final AiFeatureGate aiFeatureGate;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final UserServiceInterface userService;
|
private final UserServiceInterface userService;
|
||||||
|
|
||||||
@@ -81,6 +83,7 @@ public class ClassifyLabelController {
|
|||||||
PdfContentExtractor pdfContentExtractor,
|
PdfContentExtractor pdfContentExtractor,
|
||||||
PdfMetadataService pdfMetadataService,
|
PdfMetadataService pdfMetadataService,
|
||||||
AiEngineClient aiEngineClient,
|
AiEngineClient aiEngineClient,
|
||||||
|
AiFeatureGate aiFeatureGate,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
ClassificationLabelProvider labelProvider,
|
ClassificationLabelProvider labelProvider,
|
||||||
@Autowired(required = false) UserServiceInterface userService) {
|
@Autowired(required = false) UserServiceInterface userService) {
|
||||||
@@ -89,6 +92,7 @@ public class ClassifyLabelController {
|
|||||||
this.pdfContentExtractor = pdfContentExtractor;
|
this.pdfContentExtractor = pdfContentExtractor;
|
||||||
this.pdfMetadataService = pdfMetadataService;
|
this.pdfMetadataService = pdfMetadataService;
|
||||||
this.aiEngineClient = aiEngineClient;
|
this.aiEngineClient = aiEngineClient;
|
||||||
|
this.aiFeatureGate = aiFeatureGate;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.labelProvider = labelProvider;
|
this.labelProvider = labelProvider;
|
||||||
this.userService = userService;
|
this.userService = userService;
|
||||||
@@ -104,6 +108,7 @@ public class ClassifyLabelController {
|
|||||||
+ " intended for direct client use.")
|
+ " intended for direct client use.")
|
||||||
public ResponseEntity<Resource> classifyAndLabel(
|
public ResponseEntity<Resource> classifyAndLabel(
|
||||||
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
|
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
|
||||||
|
aiFeatureGate.requireClassify();
|
||||||
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
|
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
|
||||||
String fileName = safeFileName(fileInput.getOriginalFilename());
|
String fileName = safeFileName(fileInput.getOriginalFilename());
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -34,6 +34,7 @@ import stirling.software.common.util.TempFileManager;
|
|||||||
import stirling.software.common.util.WebResponseUtils;
|
import stirling.software.common.util.WebResponseUtils;
|
||||||
import stirling.software.proprietary.model.api.ai.create.AiDocument;
|
import stirling.software.proprietary.model.api.ai.create.AiDocument;
|
||||||
import stirling.software.proprietary.service.AiDocumentHtmlRenderer;
|
import stirling.software.proprietary.service.AiDocumentHtmlRenderer;
|
||||||
|
import stirling.software.proprietary.service.AiFeatureGate;
|
||||||
|
|
||||||
import tools.jackson.core.JacksonException;
|
import tools.jackson.core.JacksonException;
|
||||||
import tools.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
@@ -59,6 +60,7 @@ public class CreatePdfAgentController {
|
|||||||
private final ApplicationProperties applicationProperties;
|
private final ApplicationProperties applicationProperties;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final AiDocumentHtmlRenderer htmlRenderer;
|
private final AiDocumentHtmlRenderer htmlRenderer;
|
||||||
|
private final AiFeatureGate aiFeatureGate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
|
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
|
||||||
@@ -93,10 +95,10 @@ public class CreatePdfAgentController {
|
|||||||
public ResponseEntity<Resource> createPdf(
|
public ResponseEntity<Resource> createPdf(
|
||||||
@RequestParam("document") String document, @RequestParam("filename") String filename)
|
@RequestParam("document") String document, @RequestParam("filename") String filename)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
|
|
||||||
if (!applicationProperties.getAiEngine().isEnabled()) {
|
if (!applicationProperties.getAiEngine().isEnabled()) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
aiFeatureGate.requireCreatePdf();
|
||||||
|
|
||||||
AiDocument model;
|
AiDocument model;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+3
@@ -21,6 +21,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import stirling.software.proprietary.model.api.ai.Verdict;
|
import stirling.software.proprietary.model.api.ai.Verdict;
|
||||||
|
import stirling.software.proprietary.service.AiFeatureGate;
|
||||||
import stirling.software.proprietary.service.AiToolInputValidator;
|
import stirling.software.proprietary.service.AiToolInputValidator;
|
||||||
import stirling.software.proprietary.service.MathAuditorOrchestrator;
|
import stirling.software.proprietary.service.MathAuditorOrchestrator;
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ public class MathAuditorAgentController {
|
|||||||
|
|
||||||
private static final Pattern NEWLINE_PATTERN = Pattern.compile("[\\r\\n]");
|
private static final Pattern NEWLINE_PATTERN = Pattern.compile("[\\r\\n]");
|
||||||
private final MathAuditorOrchestrator orchestrator;
|
private final MathAuditorOrchestrator orchestrator;
|
||||||
|
private final AiFeatureGate aiFeatureGate;
|
||||||
|
|
||||||
@PostMapping(value = "/math-auditor-agent", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@PostMapping(value = "/math-auditor-agent", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
@Operation(
|
@Operation(
|
||||||
@@ -79,6 +81,7 @@ public class MathAuditorAgentController {
|
|||||||
+ " ignored (default: 0.01)")
|
+ " ignored (default: 0.01)")
|
||||||
@RequestParam(value = "tolerance", defaultValue = "0.01")
|
@RequestParam(value = "tolerance", defaultValue = "0.01")
|
||||||
BigDecimal tolerance) {
|
BigDecimal tolerance) {
|
||||||
|
aiFeatureGate.requireMathAuditor();
|
||||||
|
|
||||||
AiToolInputValidator.validatePdfUpload(fileInput);
|
AiToolInputValidator.validatePdfUpload(fileInput);
|
||||||
if (tolerance.compareTo(BigDecimal.ZERO) < 0) {
|
if (tolerance.compareTo(BigDecimal.ZERO) < 0) {
|
||||||
|
|||||||
+3
@@ -21,6 +21,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.proprietary.service.AiFeatureGate;
|
||||||
import stirling.software.proprietary.service.AiToolResponseHeaders;
|
import stirling.software.proprietary.service.AiToolResponseHeaders;
|
||||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
|
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
|
||||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
|
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
|
||||||
@@ -49,6 +50,7 @@ public class PdfCommentAgentController {
|
|||||||
private static final Pattern NEWLINE_PATTERN = Pattern.compile("[\\r\\n]");
|
private static final Pattern NEWLINE_PATTERN = Pattern.compile("[\\r\\n]");
|
||||||
private final PdfCommentAgentOrchestrator orchestrator;
|
private final PdfCommentAgentOrchestrator orchestrator;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final AiFeatureGate aiFeatureGate;
|
||||||
|
|
||||||
@PostMapping(
|
@PostMapping(
|
||||||
value = "/pdf-comment-agent",
|
value = "/pdf-comment-agent",
|
||||||
@@ -79,6 +81,7 @@ public class PdfCommentAgentController {
|
|||||||
@RequestParam("prompt")
|
@RequestParam("prompt")
|
||||||
String prompt)
|
String prompt)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
|
aiFeatureGate.requirePdfComment();
|
||||||
|
|
||||||
String originalFilename = fileInput.getOriginalFilename();
|
String originalFilename = fileInput.getOriginalFilename();
|
||||||
String safeName =
|
String safeName =
|
||||||
|
|||||||
+101
-5
@@ -8,6 +8,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -45,6 +46,7 @@ import stirling.software.common.util.RegexPatternUtils;
|
|||||||
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
|
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
|
||||||
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
|
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
|
||||||
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
|
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
|
||||||
|
import stirling.software.proprietary.service.AiEngineConfigSync;
|
||||||
|
|
||||||
import tools.jackson.core.type.TypeReference;
|
import tools.jackson.core.type.TypeReference;
|
||||||
import tools.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
@@ -58,6 +60,7 @@ public class AdminSettingsController {
|
|||||||
private final ApplicationProperties applicationProperties;
|
private final ApplicationProperties applicationProperties;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ApplicationContext applicationContext;
|
private final ApplicationContext applicationContext;
|
||||||
|
private final AiEngineConfigSync aiEngineConfigSync;
|
||||||
|
|
||||||
// Track settings that have been modified but not yet applied (require restart)
|
// Track settings that have been modified but not yet applied (require restart)
|
||||||
private static final ConcurrentHashMap<String, Object> pendingChanges =
|
private static final ConcurrentHashMap<String, Object> pendingChanges =
|
||||||
@@ -172,6 +175,26 @@ public class AdminSettingsController {
|
|||||||
.body(Map.of("error", "No settings provided to update"));
|
.body(Map.of("error", "No settings provided to update"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mutable copy so we can drop masked "********" values: a UI round-trip must not
|
||||||
|
// overwrite a real secret (e.g. an API key) with the placeholder from the GET.
|
||||||
|
settings = new LinkedHashMap<>(settings);
|
||||||
|
settings.entrySet()
|
||||||
|
.removeIf(
|
||||||
|
e -> {
|
||||||
|
if (!"********".equals(e.getValue())) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String key = e.getKey();
|
||||||
|
String leaf =
|
||||||
|
key.contains(".")
|
||||||
|
? key.substring(key.lastIndexOf('.') + 1)
|
||||||
|
: key;
|
||||||
|
return isSensitiveFieldWithPath(leaf, key);
|
||||||
|
});
|
||||||
|
if (settings.isEmpty()) {
|
||||||
|
return ResponseEntity.ok(Map.of("message", "No changed settings to update."));
|
||||||
|
}
|
||||||
|
|
||||||
// Validate all settings first before applying any changes
|
// Validate all settings first before applying any changes
|
||||||
for (Map.Entry<String, Object> entry : settings.entrySet()) {
|
for (Map.Entry<String, Object> entry : settings.entrySet()) {
|
||||||
String key = entry.getKey();
|
String key = entry.getKey();
|
||||||
@@ -188,6 +211,9 @@ public class AdminSettingsController {
|
|||||||
|
|
||||||
// Validate pipeline path settings
|
// Validate pipeline path settings
|
||||||
String validationError = validatePipelinePathSetting(key, value);
|
String validationError = validatePipelinePathSetting(key, value);
|
||||||
|
if (validationError == null) {
|
||||||
|
validationError = validateAiEngineNumericSetting(key, value);
|
||||||
|
}
|
||||||
if (validationError != null) {
|
if (validationError != null) {
|
||||||
return ResponseEntity.badRequest()
|
return ResponseEntity.badRequest()
|
||||||
.body(Map.of("error", HtmlUtils.htmlEscape(validationError)));
|
.body(Map.of("error", HtmlUtils.htmlEscape(validationError)));
|
||||||
@@ -202,10 +228,13 @@ public class AdminSettingsController {
|
|||||||
for (Map.Entry<String, Object> entry : settings.entrySet()) {
|
for (Map.Entry<String, Object> entry : settings.entrySet()) {
|
||||||
String key = entry.getKey();
|
String key = entry.getKey();
|
||||||
Object value = entry.getValue();
|
Object value = entry.getValue();
|
||||||
log.info("Admin updating setting: {} = {}", key, value);
|
log.info("Admin updating setting: {} = {}", key, logSafeValue(key, value));
|
||||||
pendingChanges.put(key, value != null ? value : "");
|
pendingChanges.put(key, value != null ? value : "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push changed AI settings live so model/RAG/limit changes skip the restart.
|
||||||
|
maybePushAiEngineLive(settings);
|
||||||
|
|
||||||
return ResponseEntity.ok(
|
return ResponseEntity.ok(
|
||||||
Map.of(
|
Map.of(
|
||||||
"message",
|
"message",
|
||||||
@@ -350,7 +379,10 @@ public class AdminSettingsController {
|
|||||||
+ HtmlUtils.htmlEscape(fullKey)));
|
+ HtmlUtils.htmlEscape(fullKey)));
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Admin updating section setting: {} = {}", fullKey, value);
|
log.info(
|
||||||
|
"Admin updating section setting: {} = {}",
|
||||||
|
fullKey,
|
||||||
|
logSafeValue(fullKey, value));
|
||||||
GeneralUtils.saveKeyToSettings(fullKey, value);
|
GeneralUtils.saveKeyToSettings(fullKey, value);
|
||||||
|
|
||||||
// Track this as a pending change
|
// Track this as a pending change
|
||||||
@@ -469,7 +501,7 @@ public class AdminSettingsController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Admin updating single setting: {} = {}", key, value);
|
log.info("Admin updating single setting: {} = {}", key, logSafeValue(key, value));
|
||||||
GeneralUtils.saveKeyToSettings(key, value);
|
GeneralUtils.saveKeyToSettings(key, value);
|
||||||
|
|
||||||
// Track this as a pending change
|
// Track this as a pending change
|
||||||
@@ -600,6 +632,27 @@ public class AdminSettingsController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward pending {@code aiEngine.*} changes to the engine after a save. Sends all accumulated
|
||||||
|
* pending changes, not just this save's: the running bean doesn't reflect unrestarted values.
|
||||||
|
*/
|
||||||
|
private void maybePushAiEngineLive(Map<String, Object> changedSettings) {
|
||||||
|
boolean aiChangedNow =
|
||||||
|
changedSettings.keySet().stream().anyMatch(k -> k.startsWith("aiEngine."));
|
||||||
|
if (!aiChangedNow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Object> aiEnginePending = new HashMap<>();
|
||||||
|
for (Map.Entry<String, Object> entry : pendingChanges.entrySet()) {
|
||||||
|
if (entry.getKey().startsWith("aiEngine.")) {
|
||||||
|
aiEnginePending.put(entry.getKey(), entry.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!aiEnginePending.isEmpty()) {
|
||||||
|
aiEngineConfigSync.pushLiveAfterSave(aiEnginePending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private Object getSectionData(String sectionName) {
|
private Object getSectionData(String sectionName) {
|
||||||
if (sectionName == null || sectionName.trim().isEmpty()) {
|
if (sectionName == null || sectionName.trim().isEmpty()) {
|
||||||
return null;
|
return null;
|
||||||
@@ -684,6 +737,38 @@ public class AdminSettingsController {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum accepted value per bounded {@code aiEngine.*} numeric. A saved out-of-range value
|
||||||
|
* would make the engine reject every later push, including the one that fixes it.
|
||||||
|
*/
|
||||||
|
private static final Map<String, Integer> AI_ENGINE_NUMERIC_MINIMUMS =
|
||||||
|
Map.of(
|
||||||
|
"aiEngine.models.smartMaxTokens", 1,
|
||||||
|
"aiEngine.models.fastMaxTokens", 1,
|
||||||
|
"aiEngine.rag.topK", 1,
|
||||||
|
"aiEngine.rag.maxSearches", 0,
|
||||||
|
"aiEngine.limits.maxPages", 1,
|
||||||
|
"aiEngine.limits.maxCharacters", 1,
|
||||||
|
"aiEngine.limits.modelMaxConcurrency", 1);
|
||||||
|
|
||||||
|
private String validateAiEngineNumericSetting(String key, Object value) {
|
||||||
|
Integer min = AI_ENGINE_NUMERIC_MINIMUMS.get(key);
|
||||||
|
if (min == null || value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
long parsed;
|
||||||
|
if (value instanceof Number number) {
|
||||||
|
parsed = number.longValue();
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
parsed = Long.parseLong(value.toString().trim());
|
||||||
|
} catch (NumberFormatException e) {
|
||||||
|
return key + " must be a whole number";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed < min ? key + " must be at least " + min : null;
|
||||||
|
}
|
||||||
|
|
||||||
private String validatePipelinePathSetting(String key, Object value) {
|
private String validatePipelinePathSetting(String key, Object value) {
|
||||||
// Validate pipeline path settings
|
// Validate pipeline path settings
|
||||||
if (key.startsWith("system.customPaths.pipeline.watchedFoldersDirs")
|
if (key.startsWith("system.customPaths.pipeline.watchedFoldersDirs")
|
||||||
@@ -828,6 +913,15 @@ public class AdminSettingsController {
|
|||||||
return masked;
|
return masked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Value to log for a settings key, with secrets redacted: API keys, client secrets and mail
|
||||||
|
* passwords travel this path and must not land in the log in cleartext.
|
||||||
|
*/
|
||||||
|
private Object logSafeValue(String key, Object value) {
|
||||||
|
String leaf = key.contains(".") ? key.substring(key.lastIndexOf('.') + 1) : key;
|
||||||
|
return isSensitiveFieldWithPath(leaf, key) ? "<redacted>" : value;
|
||||||
|
}
|
||||||
|
|
||||||
/** Check if a field name indicates sensitive data with full path context */
|
/** Check if a field name indicates sensitive data with full path context */
|
||||||
private boolean isSensitiveFieldWithPath(String fieldName, String fullPath) {
|
private boolean isSensitiveFieldWithPath(String fieldName, String fullPath) {
|
||||||
String lowerField = fieldName.toLowerCase();
|
String lowerField = fieldName.toLowerCase();
|
||||||
@@ -843,10 +937,12 @@ public class AdminSettingsController {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for fields containing 'password' or 'secret' or 'bottoken'
|
// Match secret-bearing names (apikey covers provider creds). "token" is a suffix
|
||||||
|
// match only, so it doesn't swallow numeric fields like smartMaxTokens.
|
||||||
return lowerField.contains("password")
|
return lowerField.contains("password")
|
||||||
|| lowerField.contains("secret")
|
|| lowerField.contains("secret")
|
||||||
|| lowerField.contains("bottoken");
|
|| lowerField.contains("apikey")
|
||||||
|
|| lowerField.endsWith("token");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a masked representation for sensitive fields */
|
/** Create a masked representation for sensitive fields */
|
||||||
|
|||||||
+282
@@ -0,0 +1,282 @@
|
|||||||
|
package stirling.software.proprietary.service;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
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.node.ObjectNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes admin-configured AI settings to the engine on startup and after each save; non-blocking
|
||||||
|
* and best-effort. Disabled via {@code aiEngine.pushConfigToEngine} for env-driven deployments.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AiEngineConfigSync {
|
||||||
|
|
||||||
|
private static final int MAX_ATTEMPTS = 5;
|
||||||
|
private static final long RETRY_DELAY_MS = 3000L;
|
||||||
|
|
||||||
|
private final ApplicationProperties applicationProperties;
|
||||||
|
private final AiEngineClient aiEngineClient;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
// Single worker keeps pushes strictly ordered; virtual (daemon) thread never blocks shutdown.
|
||||||
|
private final ExecutorService pushExecutor =
|
||||||
|
Executors.newSingleThreadExecutor(
|
||||||
|
Thread.ofVirtual().name("ai-engine-config-sync").factory());
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
void shutdown() {
|
||||||
|
pushExecutor.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
|
public void pushConfigOnStartup() {
|
||||||
|
AiEngine cfg = applicationProperties.getAiEngine();
|
||||||
|
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 off-thread with retries so startup never blocks.
|
||||||
|
submit(() -> pushWithRetries(cfg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push AI settings to the engine after an admin save so changes apply without a restart. No-op
|
||||||
|
* unless AI is enabled and an engine-relevant {@code aiEngine.*} key changed.
|
||||||
|
*/
|
||||||
|
public void pushLiveAfterSave(Map<String, Object> pendingAiEngine) {
|
||||||
|
// Save already persisted; a build/dispatch failure must not fail the save.
|
||||||
|
try {
|
||||||
|
// Gate on the running bean: the client refuses calls while disabled, so a pending
|
||||||
|
// enable would always fail here; the post-restart startup push covers first enablement.
|
||||||
|
AiEngine cfg = applicationProperties.getAiEngine();
|
||||||
|
if (pendingAiEngine == null
|
||||||
|
|| pendingAiEngine.isEmpty()
|
||||||
|
|| !cfg.isPushConfigToEngine()
|
||||||
|
|| !cfg.isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Set<String> engineKeys =
|
||||||
|
pendingAiEngine.keySet().stream()
|
||||||
|
.filter(AiEngineConfigSync::isEngineRelevantKey)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
if (engineKeys.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ObjectNode node = buildConfigNode(cfg);
|
||||||
|
pendingAiEngine.forEach((k, v) -> overlayIfEngineRelevant(node, k, v));
|
||||||
|
keepEnvForUnconfiguredIdentity(node, engineKeys);
|
||||||
|
String body = node.toString();
|
||||||
|
submit(() -> pushOnce(body));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn(
|
||||||
|
"Could not build the live AI engine config push: {} (settings were saved; the"
|
||||||
|
+ " engine will re-sync on the next restart)",
|
||||||
|
e.getMessage(),
|
||||||
|
e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run pushes on the single-threaded executor so they stay serialised: each carries the full
|
||||||
|
* config and the engine keeps whatever lands last, so overlapping pushes could leave it stale.
|
||||||
|
*/
|
||||||
|
private void submit(Runnable task) {
|
||||||
|
pushExecutor.execute(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pushOnce(String body) {
|
||||||
|
try {
|
||||||
|
aiEngineClient.post("/api/v1/config", body, null);
|
||||||
|
log.info("Pushed AI engine configuration after settings change");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error(
|
||||||
|
"Live AI engine config push failed: {}. The engine keeps running its previous"
|
||||||
|
+ " configuration; if the engine is not on localhost, set"
|
||||||
|
+ " STIRLING_ENGINE_SHARED_SECRET on both the engine and the processor"
|
||||||
|
+ " so it accepts the push.",
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only models/rag/limits reach the engine; the rest is processor-side.
|
||||||
|
private static boolean isEngineRelevantKey(String key) {
|
||||||
|
return key.startsWith("aiEngine.models.")
|
||||||
|
|| key.startsWith("aiEngine.rag.")
|
||||||
|
|| key.startsWith("aiEngine.limits.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void overlayIfEngineRelevant(ObjectNode node, String key, Object value) {
|
||||||
|
if (!isEngineRelevantKey(key)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String[] parts = key.substring("aiEngine.".length()).split("\\.");
|
||||||
|
if (parts.length < 2) {
|
||||||
|
// No leaf here; writing at parts[0] would overwrite the whole section with a scalar.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ObjectNode parent = node;
|
||||||
|
for (int i = 0; i < parts.length - 1; i++) {
|
||||||
|
JsonNode child = parent.get(parts[i]);
|
||||||
|
parent = (child instanceof ObjectNode on) ? on : parent.putObject(parts[i]);
|
||||||
|
}
|
||||||
|
parent.set(parts[parts.length - 1], objectMapper.valueToTree(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pushWithRetries(AiEngine cfg) {
|
||||||
|
ObjectNode node = buildConfigNode(cfg);
|
||||||
|
keepEnvForUnconfiguredIdentity(node, Set.of());
|
||||||
|
String body = node.toString();
|
||||||
|
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
aiEngineClient.post("/api/v1/config", body, null);
|
||||||
|
log.info("Pushed AI engine configuration on startup (attempt {})", attempt);
|
||||||
|
return;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn(
|
||||||
|
"AI engine config push failed (attempt {}/{}): {}",
|
||||||
|
attempt,
|
||||||
|
MAX_ATTEMPTS,
|
||||||
|
e.getMessage());
|
||||||
|
if (attempt < MAX_ATTEMPTS) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(RETRY_DELAY_MS);
|
||||||
|
} catch (InterruptedException ie) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.warn(
|
||||||
|
"Giving up pushing AI engine configuration after {} attempts; the engine will use"
|
||||||
|
+ " its own environment configuration until the next restart.",
|
||||||
|
MAX_ATTEMPTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode buildConfigNode(AiEngine cfg) {
|
||||||
|
AiEngine.Models m = cfg.getModels();
|
||||||
|
AiEngine.Rag r = cfg.getRag();
|
||||||
|
AiEngine.Limits l = cfg.getLimits();
|
||||||
|
|
||||||
|
ObjectNode root = objectMapper.createObjectNode();
|
||||||
|
|
||||||
|
ObjectNode models = root.putObject("models");
|
||||||
|
models.put("provider", m.getProvider());
|
||||||
|
models.put("smartModel", m.getSmartModel());
|
||||||
|
models.put("fastModel", m.getFastModel());
|
||||||
|
models.put("smartMaxTokens", m.getSmartMaxTokens());
|
||||||
|
models.put("fastMaxTokens", m.getFastMaxTokens());
|
||||||
|
models.put("apiKey", m.getApiKey());
|
||||||
|
models.put("baseUrl", m.getBaseUrl());
|
||||||
|
|
||||||
|
ObjectNode rag = root.putObject("rag");
|
||||||
|
rag.put("embeddingProvider", r.getEmbeddingProvider());
|
||||||
|
rag.put("embeddingModel", r.getEmbeddingModel());
|
||||||
|
rag.put("embeddingApiKey", r.getEmbeddingApiKey());
|
||||||
|
rag.put("embeddingBaseUrl", r.getEmbeddingBaseUrl());
|
||||||
|
rag.put("topK", r.getTopK());
|
||||||
|
rag.put("maxSearches", r.getMaxSearches());
|
||||||
|
|
||||||
|
ObjectNode limits = root.putObject("limits");
|
||||||
|
limits.put("maxPages", l.getMaxPages());
|
||||||
|
limits.put("maxCharacters", l.getMaxCharacters());
|
||||||
|
limits.put("modelMaxConcurrency", l.getModelMaxConcurrency());
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults used to detect whether a section was configured or left at built-in values.
|
||||||
|
private static final AiEngine.Models DEFAULT_MODELS = new AiEngine.Models();
|
||||||
|
private static final AiEngine.Rag DEFAULT_RAG = new AiEngine.Rag();
|
||||||
|
|
||||||
|
private static boolean isBlank(String s) {
|
||||||
|
return s == null || s.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(JsonNode section, String field) {
|
||||||
|
return section.path(field).asText("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blank the identity (provider/model/credentials) of unconfigured sections so the push keeps
|
||||||
|
* the engine's env values; edited sections are sent as-is so a cleared key really clears.
|
||||||
|
*/
|
||||||
|
private void keepEnvForUnconfiguredIdentity(ObjectNode root, Set<String> touchedKeys) {
|
||||||
|
if (root.get("models") instanceof ObjectNode models) {
|
||||||
|
boolean configured =
|
||||||
|
touchedIdentity(touchedKeys, MODEL_IDENTITY_KEYS)
|
||||||
|
|| !isBlank(text(models, "apiKey"))
|
||||||
|
|| !isBlank(text(models, "baseUrl"))
|
||||||
|
|| !DEFAULT_MODELS.getProvider().equals(text(models, "provider"))
|
||||||
|
|| !DEFAULT_MODELS.getSmartModel().equals(text(models, "smartModel"))
|
||||||
|
|| !DEFAULT_MODELS.getFastModel().equals(text(models, "fastModel"));
|
||||||
|
if (!configured) {
|
||||||
|
models.put("provider", "");
|
||||||
|
models.put("smartModel", "");
|
||||||
|
models.put("fastModel", "");
|
||||||
|
models.put("apiKey", "");
|
||||||
|
models.put("baseUrl", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (root.get("rag") instanceof ObjectNode rag) {
|
||||||
|
boolean configured =
|
||||||
|
touchedIdentity(touchedKeys, RAG_IDENTITY_KEYS)
|
||||||
|
|| !isBlank(text(rag, "embeddingApiKey"))
|
||||||
|
|| !isBlank(text(rag, "embeddingBaseUrl"))
|
||||||
|
|| !DEFAULT_RAG
|
||||||
|
.getEmbeddingProvider()
|
||||||
|
.equals(text(rag, "embeddingProvider"))
|
||||||
|
|| !DEFAULT_RAG.getEmbeddingModel().equals(text(rag, "embeddingModel"));
|
||||||
|
if (!configured) {
|
||||||
|
rag.put("embeddingProvider", "");
|
||||||
|
rag.put("embeddingModel", "");
|
||||||
|
rag.put("embeddingApiKey", "");
|
||||||
|
rag.put("embeddingBaseUrl", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Set<String> MODEL_IDENTITY_KEYS =
|
||||||
|
Set.of(
|
||||||
|
"aiEngine.models.provider",
|
||||||
|
"aiEngine.models.smartModel",
|
||||||
|
"aiEngine.models.fastModel",
|
||||||
|
"aiEngine.models.apiKey",
|
||||||
|
"aiEngine.models.baseUrl");
|
||||||
|
|
||||||
|
private static final Set<String> RAG_IDENTITY_KEYS =
|
||||||
|
Set.of(
|
||||||
|
"aiEngine.rag.embeddingProvider",
|
||||||
|
"aiEngine.rag.embeddingModel",
|
||||||
|
"aiEngine.rag.embeddingApiKey",
|
||||||
|
"aiEngine.rag.embeddingBaseUrl");
|
||||||
|
|
||||||
|
private static boolean touchedIdentity(Set<String> touchedKeys, Set<String> identityKeys) {
|
||||||
|
return touchedKeys.stream().anyMatch(identityKeys::contains);
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
package stirling.software.proprietary.service;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
import stirling.software.common.model.ApplicationProperties.AiEngine.Features;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central gate for the AI feature switches ({@code aiEngine.features.*}); each {@code require*}
|
||||||
|
* throws 503 when the engine is disabled or the capability is off.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AiFeatureGate {
|
||||||
|
|
||||||
|
private final ApplicationProperties applicationProperties;
|
||||||
|
|
||||||
|
private Features features() {
|
||||||
|
return applicationProperties.getAiEngine().getFeatures();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void require(boolean featureEnabled, String feature) {
|
||||||
|
if (!applicationProperties.getAiEngine().isEnabled() || !featureEnabled) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE, "AI feature '" + feature + "' is disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared entry point for chat and document questions; open while either is enabled, since a
|
||||||
|
* request can't be attributed to just one. No per-capability gate exists for the same reason.
|
||||||
|
*/
|
||||||
|
public void requireConversationalWorkflow() {
|
||||||
|
require(features().isChat() || features().isDocumentQuestions(), "conversation");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireCreatePdf() {
|
||||||
|
require(features().isCreatePdf(), "createPdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireMathAuditor() {
|
||||||
|
require(features().isMathAuditor(), "mathAuditor");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requirePdfComment() {
|
||||||
|
require(features().isPdfComment(), "pdfComment");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void requireClassify() {
|
||||||
|
require(features().isClassify(), "classify");
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -29,6 +29,7 @@ import stirling.software.common.util.TempFileManager;
|
|||||||
import stirling.software.proprietary.classification.ClassificationLabelProvider;
|
import stirling.software.proprietary.classification.ClassificationLabelProvider;
|
||||||
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
import stirling.software.proprietary.classification.model.ClassificationLabel;
|
||||||
import stirling.software.proprietary.service.AiEngineClient;
|
import stirling.software.proprietary.service.AiEngineClient;
|
||||||
|
import stirling.software.proprietary.service.AiFeatureGate;
|
||||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||||
|
|
||||||
import tools.jackson.databind.JsonNode;
|
import tools.jackson.databind.JsonNode;
|
||||||
@@ -44,6 +45,7 @@ class ClassifyLabelControllerTest {
|
|||||||
@Mock private PdfContentExtractor pdfContentExtractor;
|
@Mock private PdfContentExtractor pdfContentExtractor;
|
||||||
@Mock private PdfMetadataService pdfMetadataService;
|
@Mock private PdfMetadataService pdfMetadataService;
|
||||||
@Mock private AiEngineClient aiEngineClient;
|
@Mock private AiEngineClient aiEngineClient;
|
||||||
|
@Mock private AiFeatureGate aiFeatureGate;
|
||||||
|
|
||||||
private final ObjectMapper objectMapper = JsonMapper.builder().build();
|
private final ObjectMapper objectMapper = JsonMapper.builder().build();
|
||||||
private ClassifyLabelController controller;
|
private ClassifyLabelController controller;
|
||||||
@@ -56,6 +58,7 @@ class ClassifyLabelControllerTest {
|
|||||||
pdfContentExtractor,
|
pdfContentExtractor,
|
||||||
pdfMetadataService,
|
pdfMetadataService,
|
||||||
aiEngineClient,
|
aiEngineClient,
|
||||||
|
aiFeatureGate,
|
||||||
objectMapper,
|
objectMapper,
|
||||||
ClassificationLabelProvider.withLabels(labels),
|
ClassificationLabelProvider.withLabels(labels),
|
||||||
null);
|
null);
|
||||||
|
|||||||
+26
-1
@@ -3,6 +3,7 @@ package stirling.software.proprietary.controller.api;
|
|||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -26,6 +27,7 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
import org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver;
|
import org.springframework.web.servlet.mvc.annotation.ResponseStatusExceptionResolver;
|
||||||
import org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver;
|
import org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver;
|
||||||
|
|
||||||
|
import stirling.software.proprietary.service.AiFeatureGate;
|
||||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
|
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
|
||||||
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
|
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
|
||||||
|
|
||||||
@@ -39,13 +41,15 @@ import tools.jackson.databind.json.JsonMapper;
|
|||||||
class PdfCommentAgentControllerTest {
|
class PdfCommentAgentControllerTest {
|
||||||
|
|
||||||
@Mock private PdfCommentAgentOrchestrator orchestrator;
|
@Mock private PdfCommentAgentOrchestrator orchestrator;
|
||||||
|
@Mock private AiFeatureGate aiFeatureGate;
|
||||||
|
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
PdfCommentAgentController controller =
|
PdfCommentAgentController controller =
|
||||||
new PdfCommentAgentController(orchestrator, JsonMapper.builder().build());
|
new PdfCommentAgentController(
|
||||||
|
orchestrator, JsonMapper.builder().build(), aiFeatureGate);
|
||||||
mockMvc =
|
mockMvc =
|
||||||
MockMvcBuilders.standaloneSetup(controller)
|
MockMvcBuilders.standaloneSetup(controller)
|
||||||
// standaloneSetup's defaults don't handle ResponseStatusException; wire up
|
// standaloneSetup's defaults don't handle ResponseStatusException; wire up
|
||||||
@@ -117,6 +121,27 @@ class PdfCommentAgentControllerTest {
|
|||||||
verify(orchestrator, never()).applyComments(any(), anyString());
|
verify(orchestrator, never()).applyComments(any(), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsServiceUnavailableWhenPdfCommentFeatureDisabled() throws Exception {
|
||||||
|
doThrow(new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE))
|
||||||
|
.when(aiFeatureGate)
|
||||||
|
.requirePdfComment();
|
||||||
|
MockMultipartFile pdfFile =
|
||||||
|
new MockMultipartFile(
|
||||||
|
"fileInput",
|
||||||
|
"input.pdf",
|
||||||
|
MediaType.APPLICATION_PDF_VALUE,
|
||||||
|
"%PDF-1.4\n%%EOF".getBytes());
|
||||||
|
|
||||||
|
mockMvc.perform(
|
||||||
|
multipart("/api/v1/ai/tools/pdf-comment-agent")
|
||||||
|
.file(pdfFile)
|
||||||
|
.param("prompt", "flag dates"))
|
||||||
|
.andExpect(status().isServiceUnavailable());
|
||||||
|
|
||||||
|
verify(orchestrator, never()).applyComments(any(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void rejectsMissingPromptParameter() throws Exception {
|
void rejectsMissingPromptParameter() throws Exception {
|
||||||
MockMultipartFile pdfFile =
|
MockMultipartFile pdfFile =
|
||||||
|
|||||||
+101
-1
@@ -1,10 +1,13 @@
|
|||||||
package stirling.software.proprietary.security.controller.api;
|
package stirling.software.proprietary.security.controller.api;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.mockStatic;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.lang.reflect.Field;
|
import java.lang.reflect.Field;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
@@ -26,6 +29,7 @@ import stirling.software.common.util.GeneralUtils;
|
|||||||
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
|
import stirling.software.proprietary.security.model.api.admin.SettingValueResponse;
|
||||||
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
|
import stirling.software.proprietary.security.model.api.admin.UpdateSettingValueRequest;
|
||||||
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
|
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
|
||||||
|
import stirling.software.proprietary.service.AiEngineConfigSync;
|
||||||
|
|
||||||
import tools.jackson.databind.ObjectMapper;
|
import tools.jackson.databind.ObjectMapper;
|
||||||
import tools.jackson.databind.json.JsonMapper;
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
@@ -37,6 +41,7 @@ class AdminSettingsControllerTest {
|
|||||||
private ApplicationProperties applicationProperties;
|
private ApplicationProperties applicationProperties;
|
||||||
private ObjectMapper objectMapper;
|
private ObjectMapper objectMapper;
|
||||||
private ApplicationContext applicationContext;
|
private ApplicationContext applicationContext;
|
||||||
|
private AiEngineConfigSync aiEngineConfigSync;
|
||||||
|
|
||||||
private AdminSettingsController controller;
|
private AdminSettingsController controller;
|
||||||
|
|
||||||
@@ -45,9 +50,13 @@ class AdminSettingsControllerTest {
|
|||||||
applicationProperties = new ApplicationProperties();
|
applicationProperties = new ApplicationProperties();
|
||||||
objectMapper = JsonMapper.builder().build();
|
objectMapper = JsonMapper.builder().build();
|
||||||
applicationContext = org.mockito.Mockito.mock(ApplicationContext.class);
|
applicationContext = org.mockito.Mockito.mock(ApplicationContext.class);
|
||||||
|
aiEngineConfigSync = org.mockito.Mockito.mock(AiEngineConfigSync.class);
|
||||||
controller =
|
controller =
|
||||||
new AdminSettingsController(
|
new AdminSettingsController(
|
||||||
applicationProperties, objectMapper, applicationContext);
|
applicationProperties,
|
||||||
|
objectMapper,
|
||||||
|
applicationContext,
|
||||||
|
aiEngineConfigSync);
|
||||||
clearPendingChanges();
|
clearPendingChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +198,33 @@ class AdminSettingsControllerTest {
|
|||||||
assertThat(response.getBody().get("error").toString()).contains("Invalid setting key");
|
assertThat(response.getBody().get("error").toString()).contains("Invalid setting key");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("rejects an out-of-range aiEngine numeric with 400")
|
||||||
|
void rejectsOutOfRangeAiEngineNumeric() {
|
||||||
|
// An out-of-range value would make the engine reject every later push, including the
|
||||||
|
// one that fixes it.
|
||||||
|
UpdateSettingsRequest request = new UpdateSettingsRequest();
|
||||||
|
request.setSettings(Map.of("aiEngine.limits.modelMaxConcurrency", 0));
|
||||||
|
|
||||||
|
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
assertThat(response.getBody().get("error").toString()).contains("at least 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("accepts zero maxSearches, which legitimately means no retrieval")
|
||||||
|
void acceptsZeroMaxSearches() {
|
||||||
|
UpdateSettingsRequest request = new UpdateSettingsRequest();
|
||||||
|
request.setSettings(Map.of("aiEngine.rag.maxSearches", 0));
|
||||||
|
|
||||||
|
try (MockedStatic<GeneralUtils> mocked = mockStatic(GeneralUtils.class)) {
|
||||||
|
ResponseEntity<Map<String, Object>> response = controller.updateSettings(request);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("rejects unknown section prefix with 400")
|
@DisplayName("rejects unknown section prefix with 400")
|
||||||
void rejectsUnknownSection() {
|
void rejectsUnknownSection() {
|
||||||
@@ -277,6 +313,51 @@ class AdminSettingsControllerTest {
|
|||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
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<String, Object> settings = new HashMap<>();
|
||||||
|
settings.put("aiEngine.models.apiKey", "********");
|
||||||
|
settings.put("ui.appName", "My App");
|
||||||
|
request.setSettings(settings);
|
||||||
|
|
||||||
|
try (MockedStatic<GeneralUtils> mocked = mockStatic(GeneralUtils.class)) {
|
||||||
|
ResponseEntity<Map<String, Object>> 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<String, Object> 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<String, Object> settings = new HashMap<>();
|
||||||
|
settings.put("aiEngine.models.provider", "ollama");
|
||||||
|
settings.put("ui.appName", "My App");
|
||||||
|
request.setSettings(settings);
|
||||||
|
|
||||||
|
try (MockedStatic<GeneralUtils> mocked = mockStatic(GeneralUtils.class)) {
|
||||||
|
controller.updateSettings(request);
|
||||||
|
|
||||||
|
verify(aiEngineConfigSync)
|
||||||
|
.pushLiveAfterSave(
|
||||||
|
argThat(
|
||||||
|
(Map<String, Object> m) ->
|
||||||
|
m.containsKey("aiEngine.models.provider")
|
||||||
|
&& !m.containsKey("ui.appName")));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
@@ -430,6 +511,25 @@ class AdminSettingsControllerTest {
|
|||||||
SettingValueResponse body = (SettingValueResponse) response.getBody();
|
SettingValueResponse body = (SettingValueResponse) response.getBody();
|
||||||
assertThat(body.getValue()).isEqualTo("********");
|
assertThat(body.getValue()).isEqualTo("********");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("masks aiEngine apiKey but NOT the maxTokens numeric fields")
|
||||||
|
void masksApiKeyButNotMaxTokens() {
|
||||||
|
applicationProperties.getAiEngine().getModels().setApiKey("sk-real-key");
|
||||||
|
applicationProperties.getAiEngine().getModels().setSmartMaxTokens(8192);
|
||||||
|
|
||||||
|
// "token" as a substring of maxTokens must not trigger masking (would break the UI
|
||||||
|
// and flip the integer to a "********" string).
|
||||||
|
ResponseEntity<?> tokensResp =
|
||||||
|
controller.getSettingValue("aiEngine.models.smartMaxTokens");
|
||||||
|
SettingValueResponse tokens = (SettingValueResponse) tokensResp.getBody();
|
||||||
|
assertThat(tokens.getValue()).isEqualTo(8192);
|
||||||
|
|
||||||
|
// The real credential is still masked.
|
||||||
|
ResponseEntity<?> keyResp = controller.getSettingValue("aiEngine.models.apiKey");
|
||||||
|
SettingValueResponse key = (SettingValueResponse) keyResp.getBody();
|
||||||
|
assertThat(key.getValue()).isEqualTo("********");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
|
|||||||
+251
@@ -0,0 +1,251 @@
|
|||||||
|
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.assertTrue;
|
||||||
|
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.HashMap;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config-push bridge is self-hosted-only. These lock in that the processor stays silent when {@code
|
||||||
|
* aiEngine.pushConfigToEngine} is off (env-driven/SaaS) and pushes when it is on.
|
||||||
|
*/
|
||||||
|
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. Keep in sync with
|
||||||
|
// engine/tests/fixtures/processor_config_push.json (the engine validates the same shape).
|
||||||
|
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<String> 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startupPushKeepsEnvWhenSectionsUnconfigured() throws Exception {
|
||||||
|
// All defaults, no credentials: the push must NOT override the engine's env-configured
|
||||||
|
// provider/model/embedder, so the identity fields are blanked ("keep env" on the engine).
|
||||||
|
ArgumentCaptor<String> 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());
|
||||||
|
assertEquals("", root.get("models").get("provider").asText());
|
||||||
|
assertEquals("", root.get("models").get("smartModel").asText());
|
||||||
|
assertEquals("", root.get("models").get("fastModel").asText());
|
||||||
|
assertEquals("", root.get("rag").get("embeddingProvider").asText());
|
||||||
|
assertEquals("", root.get("rag").get("embeddingModel").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startupPushSendsProviderWhenChangedFromDefault() throws Exception {
|
||||||
|
// Admin selected a non-default provider (relying on the engine's env key): send it so the
|
||||||
|
// engine actually switches provider, even though no API key was entered in the UI.
|
||||||
|
applicationProperties.getAiEngine().getModels().setProvider("openai");
|
||||||
|
|
||||||
|
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
|
||||||
|
sync.pushConfigOnStartup();
|
||||||
|
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull());
|
||||||
|
|
||||||
|
JsonNode models = JsonMapper.builder().build().readTree(body.getValue()).get("models");
|
||||||
|
assertEquals("openai", models.get("provider").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void startupPushSendsModelsWhenApiKeyConfigured() throws Exception {
|
||||||
|
// A configured key means the admin is driving models from the UI: send the full section.
|
||||||
|
applicationProperties.getAiEngine().getModels().setApiKey("sk-real-key");
|
||||||
|
|
||||||
|
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
|
||||||
|
sync.pushConfigOnStartup();
|
||||||
|
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull());
|
||||||
|
|
||||||
|
JsonNode models = JsonMapper.builder().build().readTree(body.getValue()).get("models");
|
||||||
|
assertEquals("anthropic", models.get("provider").asText());
|
||||||
|
assertEquals("sk-real-key", models.get("apiKey").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void livePushSendsAnExplicitlyClearedApiKeyRatherThanKeepEnv() throws Exception {
|
||||||
|
// Clearing a leaked key must reach the engine as a real clear; blanking it as "keep env"
|
||||||
|
// would leave the revoked key live in the engine's cache indefinitely.
|
||||||
|
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
|
||||||
|
sync.pushLiveAfterSave(mapOf("aiEngine.models.apiKey", ""));
|
||||||
|
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull());
|
||||||
|
|
||||||
|
JsonNode models = JsonMapper.builder().build().readTree(body.getValue()).get("models");
|
||||||
|
assertEquals("", models.get("apiKey").asText());
|
||||||
|
// Identity was NOT blanked wholesale: the provider still travels so the engine applies
|
||||||
|
// the cleared credential against the right provider.
|
||||||
|
assertEquals("anthropic", models.get("provider").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void livePushKeepsEnvWhenOnlyANumericKnobChanged() throws Exception {
|
||||||
|
// The admin touched a limit, not the identity, so the engine's env-configured
|
||||||
|
// provider/model must be preserved.
|
||||||
|
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
|
||||||
|
sync.pushLiveAfterSave(Map.of("aiEngine.limits.maxPages", 42));
|
||||||
|
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull());
|
||||||
|
|
||||||
|
JsonNode root = JsonMapper.builder().build().readTree(body.getValue());
|
||||||
|
assertEquals("", root.get("models").get("provider").asText());
|
||||||
|
assertEquals("", root.get("models").get("apiKey").asText());
|
||||||
|
assertEquals(42, root.get("limits").get("maxPages").asInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void livePushIgnoresAMalformedKeyInsteadOfClobberingTheSection() throws Exception {
|
||||||
|
// "aiEngine.models." has no leaf; writing at the section name would replace the whole
|
||||||
|
// models object with a scalar and produce an unparseable push.
|
||||||
|
ArgumentCaptor<String> body = ArgumentCaptor.forClass(String.class);
|
||||||
|
sync.pushLiveAfterSave(mapOf("aiEngine.models.", "junk"));
|
||||||
|
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), body.capture(), isNull());
|
||||||
|
|
||||||
|
JsonNode models = JsonMapper.builder().build().readTree(body.getValue()).get("models");
|
||||||
|
assertTrue(models.isObject(), "models must still be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void livePushNeverThrowsIntoTheCaller() throws Exception {
|
||||||
|
// The caller has already persisted settings.yml, so a push-building failure must not
|
||||||
|
// surface as a failed save. A null value inside the map is enough to break naive code.
|
||||||
|
Map<String, Object> pending = new HashMap<>();
|
||||||
|
pending.put("aiEngine.models.provider", null);
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> sync.pushLiveAfterSave(pending));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@link Map#of} rejects nulls and we need entries with empty/odd values. */
|
||||||
|
private static Map<String, Object> mapOf(String key, Object value) {
|
||||||
|
Map<String, Object> map = new HashMap<>();
|
||||||
|
map.put(key, value);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lock in fail-closed gating: a 503 when the engine is disabled or the 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.requireConversationalWorkflow());
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.requireConversationalWorkflow());
|
||||||
|
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void conversationalWorkflowAllowedWhileEitherChatOrDocumentQuestionsOn() {
|
||||||
|
// The orchestrate endpoint serves both, so it stays open while either is enabled.
|
||||||
|
props.getAiEngine().getFeatures().setChat(false);
|
||||||
|
props.getAiEngine().getFeatures().setDocumentQuestions(true);
|
||||||
|
assertDoesNotThrow(() -> gate.requireConversationalWorkflow());
|
||||||
|
|
||||||
|
props.getAiEngine().getFeatures().setChat(true);
|
||||||
|
props.getAiEngine().getFeatures().setDocumentQuestions(false);
|
||||||
|
assertDoesNotThrow(() -> gate.requireConversationalWorkflow());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void conversationalWorkflowThrows503WhenBothChatAndDocumentQuestionsOff() {
|
||||||
|
props.getAiEngine().getFeatures().setChat(false);
|
||||||
|
props.getAiEngine().getFeatures().setDocumentQuestions(false);
|
||||||
|
|
||||||
|
ResponseStatusException ex =
|
||||||
|
assertThrows(
|
||||||
|
ResponseStatusException.class, () -> gate.requireConversationalWorkflow());
|
||||||
|
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
# Stirling-PDF SaaS profile. Pure multi-tenant cloud.
|
# Stirling-PDF SaaS profile. Pure multi-tenant cloud.
|
||||||
# Activated when the :saas module is on the classpath.
|
# Activated when the :saas module is on the classpath.
|
||||||
|
|
||||||
|
# ---------- AI engine ----------
|
||||||
|
# SaaS AI backend is env-driven (reached via AiProxyController); never push settings-derived
|
||||||
|
# config to it, so pin the config-push off.
|
||||||
|
aiEngine.pushConfigToEngine=false
|
||||||
|
|
||||||
# ---------- Datasource ----------
|
# ---------- Datasource ----------
|
||||||
system.datasource.enableCustomDatabase=true
|
system.datasource.enableCustomDatabase=true
|
||||||
system.datasource.customDatabaseUrl=${SAAS_DB_URL:}
|
system.datasource.customDatabaseUrl=${SAAS_DB_URL:}
|
||||||
|
|||||||
@@ -88,3 +88,7 @@ STIRLING_LOG_FILE=
|
|||||||
# Use when diagnosing worker stalls: a hung call shows a "Request" line with
|
# Use when diagnosing worker stalls: a hung call shows a "Request" line with
|
||||||
# no matching "Response" line. Noisy; leave off in normal use.
|
# no matching "Response" line. Noisy; leave off in normal use.
|
||||||
STIRLING_HTTP_DEBUG=false
|
STIRLING_HTTP_DEBUG=false
|
||||||
|
|
||||||
|
# Let the Java processor push admin AI settings to POST /api/v1/config at startup.
|
||||||
|
# Set false in env-driven deployments so the environment is the single source of truth.
|
||||||
|
STIRLING_ALLOW_CONFIG_PUSH=true
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ version = "0.1.0"
|
|||||||
description = "AI Document Engine"
|
description = "AI Document Engine"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"cryptography>=44.0.0",
|
||||||
"fastapi>=0.116.0",
|
"fastapi>=0.116.0",
|
||||||
"pgvector>=0.3.6",
|
"pgvector>=0.3.6",
|
||||||
"psycopg[binary,pool]>=3.2",
|
"psycopg[binary,pool]>=3.2",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from __future__ import annotations
|
|||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic_ai import Agent
|
from pydantic_ai import Agent
|
||||||
|
|
||||||
|
from stirling.agents.output_mode import output_retries
|
||||||
from stirling.contracts import (
|
from stirling.contracts import (
|
||||||
MathAuditorToolReportArtifact,
|
MathAuditorToolReportArtifact,
|
||||||
OrchestratorRequest,
|
OrchestratorRequest,
|
||||||
@@ -68,6 +69,9 @@ class MathIntentClassifier:
|
|||||||
self._agent: Agent[None, _MathIntentDecision] = Agent(
|
self._agent: Agent[None, _MathIntentDecision] = Agent(
|
||||||
model=runtime.fast_model,
|
model=runtime.fast_model,
|
||||||
output_type=_MathIntentDecision,
|
output_type=_MathIntentDecision,
|
||||||
|
# Local models emit valid structured output only intermittently; extra
|
||||||
|
# retries make this hot-path classifier reliable. No-op for real providers.
|
||||||
|
retries=output_retries(runtime.settings.chat_provider),
|
||||||
system_prompt=_MATH_INTENT_SYSTEM_PROMPT,
|
system_prompt=_MATH_INTENT_SYSTEM_PROMPT,
|
||||||
model_settings=runtime.fast_model_settings,
|
model_settings=runtime.fast_model_settings,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import assert_never
|
from typing import Literal, assert_never
|
||||||
|
|
||||||
|
from pydantic import ConfigDict, Field
|
||||||
from pydantic_ai import Agent
|
from pydantic_ai import Agent
|
||||||
from pydantic_ai.output import ToolOutput
|
from pydantic_ai.output import NativeOutput, ToolOutput
|
||||||
from pydantic_ai.tools import RunContext
|
from pydantic_ai.tools import RunContext
|
||||||
|
|
||||||
|
from stirling.agents.output_mode import output_retries, uses_tool_output
|
||||||
from stirling.agents.pdf_create import PdfCreateAgent
|
from stirling.agents.pdf_create import PdfCreateAgent
|
||||||
from stirling.agents.pdf_edit import PdfEditAgent
|
from stirling.agents.pdf_edit import PdfEditAgent
|
||||||
from stirling.agents.pdf_questions import PdfQuestionAgent
|
from stirling.agents.pdf_questions import PdfQuestionAgent
|
||||||
@@ -27,6 +29,7 @@ from stirling.contracts import (
|
|||||||
format_file_names,
|
format_file_names,
|
||||||
)
|
)
|
||||||
from stirling.contracts.pdf_create import PdfCreateOrchestrateResponse
|
from stirling.contracts.pdf_create import PdfCreateOrchestrateResponse
|
||||||
|
from stirling.models import ApiModel
|
||||||
from stirling.services import AppRuntime
|
from stirling.services import AppRuntime
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -38,6 +41,34 @@ class OrchestratorDeps:
|
|||||||
request: OrchestratorRequest
|
request: OrchestratorRequest
|
||||||
|
|
||||||
|
|
||||||
|
# Enum routing for Ollama/custom local models: they pass the user message as args to the
|
||||||
|
# zero-arg tool delegates below, which reject it, so pick a capability by name and dispatch in Python.
|
||||||
|
_RouteCapability = Literal["pdf_edit", "pdf_question", "user_spec", "pdf_review", "pdf_create", "unsupported"]
|
||||||
|
|
||||||
|
|
||||||
|
class _RouteDecision(ApiModel):
|
||||||
|
# Local models add stray tool args and send null for optional fields; tolerate both.
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
capability: _RouteCapability
|
||||||
|
message: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Only for capability='unsupported': a short, helpful message to show the user.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_ROUTER_SYSTEM_PROMPT = (
|
||||||
|
"You are the top-level router. Choose exactly one capability that best handles the request:\n"
|
||||||
|
"- pdf_edit: modify or convert one or more attached PDFs.\n"
|
||||||
|
"- pdf_question: answer questions about the contents of the attached PDFs.\n"
|
||||||
|
"- user_spec: create or define an agent spec.\n"
|
||||||
|
"- pdf_review: return the PDF with review comments/annotations attached.\n"
|
||||||
|
"- pdf_create: generate a NEW document from scratch (invoice, report, letter) - no input file.\n"
|
||||||
|
"- unsupported: none of the above fit, or the user asks about the assistant itself; put a "
|
||||||
|
"helpful message in 'message'.\n"
|
||||||
|
"Respond with the capability and (only for unsupported) a message."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OrchestratorAgent:
|
class OrchestratorAgent:
|
||||||
def __init__(self, runtime: AppRuntime) -> None:
|
def __init__(self, runtime: AppRuntime) -> None:
|
||||||
self.runtime = runtime
|
self.runtime = runtime
|
||||||
@@ -86,6 +117,8 @@ class OrchestratorAgent:
|
|||||||
description="Return this when none of the delegate outputs fit the request.",
|
description="Return this when none of the delegate outputs fit the request.",
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
# Local models pick a delegate less reliably; extra retries. No-op for real providers.
|
||||||
|
retries=output_retries(runtime.settings.chat_provider),
|
||||||
deps_type=OrchestratorDeps,
|
deps_type=OrchestratorDeps,
|
||||||
system_prompt=(
|
system_prompt=(
|
||||||
"You are the top-level orchestrator. "
|
"You are the top-level orchestrator. "
|
||||||
@@ -103,6 +136,21 @@ class OrchestratorAgent:
|
|||||||
),
|
),
|
||||||
model_settings=runtime.fast_model_settings,
|
model_settings=runtime.fast_model_settings,
|
||||||
)
|
)
|
||||||
|
# Local models can't drive the zero-arg tool delegates; route by name instead (#6163: unify these paths).
|
||||||
|
self._route_via_enum = uses_tool_output(runtime.settings.chat_provider)
|
||||||
|
# The router has no tools, so NativeOutput works on Ollama here; a lone output tool
|
||||||
|
# would tempt a local model to answer in plain text and never call it.
|
||||||
|
self._router = (
|
||||||
|
Agent(
|
||||||
|
model=runtime.fast_model,
|
||||||
|
output_type=NativeOutput([_RouteDecision]),
|
||||||
|
retries=output_retries(runtime.settings.chat_provider),
|
||||||
|
system_prompt=_ROUTER_SYSTEM_PROMPT,
|
||||||
|
model_settings=runtime.fast_model_settings,
|
||||||
|
)
|
||||||
|
if self._route_via_enum
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
async def handle(self, request: OrchestratorRequest) -> OrchestratorResponse:
|
async def handle(self, request: OrchestratorRequest) -> OrchestratorResponse:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -114,6 +162,8 @@ class OrchestratorAgent:
|
|||||||
)
|
)
|
||||||
if request.resume_with is not None:
|
if request.resume_with is not None:
|
||||||
return await self._resume(request, request.resume_with)
|
return await self._resume(request, request.resume_with)
|
||||||
|
if self._router is not None:
|
||||||
|
return await self._route_and_dispatch(request)
|
||||||
result = await self.agent.run(
|
result = await self.agent.run(
|
||||||
self._build_prompt(request),
|
self._build_prompt(request),
|
||||||
deps=OrchestratorDeps(runtime=self.runtime, request=request),
|
deps=OrchestratorDeps(runtime=self.runtime, request=request),
|
||||||
@@ -121,6 +171,31 @@ class OrchestratorAgent:
|
|||||||
logger.info("[orchestrator] routed -> %s", type(result.output).__name__)
|
logger.info("[orchestrator] routed -> %s", type(result.output).__name__)
|
||||||
return result.output
|
return result.output
|
||||||
|
|
||||||
|
async def _route_and_dispatch(self, request: OrchestratorRequest) -> OrchestratorResponse:
|
||||||
|
"""Local-model routing: pick a capability by name, then dispatch in Python."""
|
||||||
|
assert self._router is not None
|
||||||
|
result = await self._router.run(self._build_prompt(request))
|
||||||
|
decision = result.output
|
||||||
|
logger.info("[orchestrator] enum-routed -> %s", decision.capability)
|
||||||
|
match decision.capability:
|
||||||
|
case "pdf_edit":
|
||||||
|
return await self._run_pdf_edit(request)
|
||||||
|
case "pdf_question":
|
||||||
|
return await self._run_pdf_question(request)
|
||||||
|
case "user_spec":
|
||||||
|
return await self._run_agent_draft(request)
|
||||||
|
case "pdf_review":
|
||||||
|
return await self._run_pdf_review(request)
|
||||||
|
case "pdf_create":
|
||||||
|
return await self._run_pdf_create(request)
|
||||||
|
case "unsupported":
|
||||||
|
return UnsupportedCapabilityResponse(
|
||||||
|
capability="orchestrate",
|
||||||
|
message=decision.message or "I can't help with that request.",
|
||||||
|
)
|
||||||
|
case _ as unreachable:
|
||||||
|
assert_never(unreachable)
|
||||||
|
|
||||||
async def _resume(self, request: OrchestratorRequest, capability: SupportedCapability) -> OrchestratorResponse:
|
async def _resume(self, request: OrchestratorRequest, capability: SupportedCapability) -> OrchestratorResponse:
|
||||||
"""Fast-path to get back to the correct endpoint without having to call AI.
|
"""Fast-path to get back to the correct endpoint without having to call AI.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Provider-aware output: Ollama/custom block tools under native json-schema, so use ToolOutput not NativeOutput."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic_ai.output import NativeOutput, ToolOutput
|
||||||
|
|
||||||
|
# Providers whose OpenAI-compatible endpoint needs tool-delivered structured output.
|
||||||
|
_TOOL_OUTPUT_PROVIDERS = frozenset({"ollama", "custom"})
|
||||||
|
|
||||||
|
|
||||||
|
def uses_tool_output(chat_provider: str) -> bool:
|
||||||
|
return chat_provider in _TOOL_OUTPUT_PROVIDERS
|
||||||
|
|
||||||
|
|
||||||
|
def structured_output(output_types: Sequence[Any], *, chat_provider: str) -> Any:
|
||||||
|
"""Pick a structured-output spec compatible with the active chat provider."""
|
||||||
|
types = list(output_types)
|
||||||
|
if uses_tool_output(chat_provider):
|
||||||
|
return [ToolOutput(t) for t in types]
|
||||||
|
return NativeOutput(types)
|
||||||
|
|
||||||
|
|
||||||
|
def output_retries(chat_provider: str, *, native: int = 1, tool: int = 6) -> int:
|
||||||
|
"""Local models delivering via ToolOutput need more output-validation retries."""
|
||||||
|
return tool if uses_tool_output(chat_provider) else native
|
||||||
@@ -3,10 +3,10 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pydantic_ai import Agent
|
from pydantic_ai import Agent
|
||||||
from pydantic_ai.output import NativeOutput
|
|
||||||
|
|
||||||
from stirling.agents.contradiction import ContradictionCapability, ContradictionDetector
|
from stirling.agents.contradiction import ContradictionCapability, ContradictionDetector
|
||||||
from stirling.agents.math_presentation import MathIntentClassifier, extract_math_verdict
|
from stirling.agents.math_presentation import MathIntentClassifier, extract_math_verdict
|
||||||
|
from stirling.agents.output_mode import output_retries, structured_output
|
||||||
from stirling.agents.shared import ChunkedReasoner, WholeDocReaderCapability
|
from stirling.agents.shared import ChunkedReasoner, WholeDocReaderCapability
|
||||||
from stirling.contracts import (
|
from stirling.contracts import (
|
||||||
AiFile,
|
AiFile,
|
||||||
@@ -196,9 +196,15 @@ class PdfQuestionAgent:
|
|||||||
files=request.files,
|
files=request.files,
|
||||||
principals=principals,
|
principals=principals,
|
||||||
)
|
)
|
||||||
|
# Ollama/custom block tool-calling under native json-schema output, so deliver the
|
||||||
|
# structured result via a tool call or the model answers ungrounded. See agents.output_mode.
|
||||||
|
provider = self.runtime.settings.chat_provider
|
||||||
agent = Agent(
|
agent = Agent(
|
||||||
model=self.runtime.smart_model,
|
model=self.runtime.smart_model,
|
||||||
output_type=NativeOutput([PdfQuestionAnswerResponse, PdfQuestionNotFoundResponse]),
|
output_type=structured_output(
|
||||||
|
[PdfQuestionAnswerResponse, PdfQuestionNotFoundResponse], chat_provider=provider
|
||||||
|
),
|
||||||
|
retries=output_retries(provider),
|
||||||
system_prompt=PDF_QUESTION_SYSTEM_PROMPT,
|
system_prompt=PDF_QUESTION_SYSTEM_PROMPT,
|
||||||
# pydantic-ai accepts a list of (string-or-callable) instruction sources;
|
# pydantic-ai accepts a list of (string-or-callable) instruction sources;
|
||||||
# it resolves each at run time and concatenates them for the model.
|
# it resolves each at run time and concatenates them for the model.
|
||||||
|
|||||||
+135
-47
@@ -3,28 +3,20 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI
|
from fastapi import Depends, FastAPI, Request
|
||||||
from pydantic_ai import Agent
|
from pydantic_ai import Agent
|
||||||
|
from pydantic_ai.models import Model
|
||||||
from pydantic_ai.models.instrumented import InstrumentationSettings
|
from pydantic_ai.models.instrumented import InstrumentationSettings
|
||||||
|
|
||||||
from stirling.agents import (
|
from stirling.api.bootstrap import apply_app_state, build_app_state
|
||||||
DocumentClassifierAgent,
|
|
||||||
ExecutionPlanningAgent,
|
|
||||||
OrchestratorAgent,
|
|
||||||
PdfEditAgent,
|
|
||||||
PdfQuestionAgent,
|
|
||||||
UserSpecAgent,
|
|
||||||
)
|
|
||||||
from stirling.agents.ledger import MathAuditorAgent
|
|
||||||
from stirling.agents.pdf_comment import PdfCommentAgent
|
|
||||||
from stirling.api.dependencies import enforce_required_user_id
|
from stirling.api.dependencies import enforce_required_user_id
|
||||||
from stirling.api.engine_auth import EngineSharedSecretMiddleware
|
from stirling.api.engine_auth import EngineSharedSecretMiddleware
|
||||||
from stirling.api.middleware import UserIdMiddleware
|
from stirling.api.middleware import UserIdMiddleware
|
||||||
from stirling.api.routes import (
|
from stirling.api.routes import (
|
||||||
agent_capabilities_router,
|
agent_capabilities_router,
|
||||||
agent_draft_router,
|
agent_draft_router,
|
||||||
|
config_router,
|
||||||
document_classifier_router,
|
document_classifier_router,
|
||||||
document_router,
|
document_router,
|
||||||
execution_router,
|
execution_router,
|
||||||
@@ -34,38 +26,42 @@ from stirling.api.routes import (
|
|||||||
pdf_edit_router,
|
pdf_edit_router,
|
||||||
pdf_question_router,
|
pdf_question_router,
|
||||||
)
|
)
|
||||||
|
from stirling.api.routes.config import CONFIG_APPLY_ERRORS, apply_to_app, resolve_and_apply
|
||||||
from stirling.config import AppSettings, load_settings
|
from stirling.config import AppSettings, load_settings
|
||||||
|
from stirling.config.config_cache import cache_stamp, load_config
|
||||||
from stirling.contracts import HealthResponse
|
from stirling.contracts import HealthResponse
|
||||||
from stirling.documents import DocumentService
|
from stirling.documents import DocumentService, EmbeddingService
|
||||||
from stirling.services import build_runtime, setup_posthog_tracking
|
from stirling.services import setup_posthog_tracking
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Seconds the lifespan waits for a background task to drain before cancelling it.
|
||||||
|
_BACKGROUND_TASK_DRAIN_SECONDS = 10
|
||||||
|
|
||||||
|
|
||||||
|
async def _sleep_until(stop: asyncio.Event, seconds: float) -> bool:
|
||||||
|
"""Wait up to ``seconds``; True if asked to stop, False if the interval elapsed."""
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(stop.wait(), timeout=seconds)
|
||||||
|
except TimeoutError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def _run_expired_doc_reaper(
|
async def _run_expired_doc_reaper(
|
||||||
documents: DocumentService,
|
documents: DocumentService,
|
||||||
interval_seconds: int,
|
interval_seconds: int,
|
||||||
|
stop: asyncio.Event,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Periodically delete documents whose ``expires_at`` has passed.
|
"""Backstop purge of documents past ``expires_at``; persistent (null expires_at) rows are never touched."""
|
||||||
|
|
||||||
A reaped collection drops everything rooted at that document. Backstop
|
|
||||||
for the explicit logout purge: catches sessions that ended without a
|
|
||||||
clean logout (tab close, JWT expiry, engine restart). Persistent rows
|
|
||||||
(``expires_at`` null, the shape we use for org-shared docs) are never
|
|
||||||
touched. Runs until cancelled by the lifespan teardown.
|
|
||||||
"""
|
|
||||||
await _reap(documents)
|
await _reap(documents)
|
||||||
while True:
|
while not await _sleep_until(stop, interval_seconds):
|
||||||
await asyncio.sleep(interval_seconds)
|
|
||||||
await _reap(documents)
|
await _reap(documents)
|
||||||
|
|
||||||
|
|
||||||
async def _reap(documents: DocumentService) -> None:
|
async def _reap(documents: DocumentService) -> None:
|
||||||
"""One reaper iteration. Logs the deleted count on success and the full
|
"""One reaper iteration; swallows non-cancel errors so a bad iteration doesn't kill the loop."""
|
||||||
exception with traceback on failure; never re-raises non-cancel errors so
|
|
||||||
a bad iteration doesn't kill the loop. ``asyncio.CancelledError`` is
|
|
||||||
re-raised so the lifespan teardown can cancel the task cleanly.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
deleted = await documents.reap_expired()
|
deleted = await documents.reap_expired()
|
||||||
if deleted:
|
if deleted:
|
||||||
@@ -76,6 +72,45 @@ async def _reap(documents: DocumentService) -> None:
|
|||||||
logger.exception("Document reaper iteration failed; will retry on next interval")
|
logger.exception("Document reaper iteration failed; will retry on next interval")
|
||||||
|
|
||||||
|
|
||||||
|
def _adopt_cached_config_if_changed(fast_api: FastAPI) -> None:
|
||||||
|
"""Re-apply the persisted config when the shared cache file changed under us; never raises."""
|
||||||
|
# Read the stamp before the payload: a write landing between the two re-applies next
|
||||||
|
# tick, whereas the reverse order would record the newer stamp and skip forever.
|
||||||
|
stamp = cache_stamp()
|
||||||
|
if stamp is None or stamp == getattr(fast_api.state, "config_cache_stamp", None):
|
||||||
|
return
|
||||||
|
# Claim the stamp up front so a cache we cannot read or apply is not retried every tick.
|
||||||
|
fast_api.state.config_cache_stamp = stamp
|
||||||
|
cached = load_config()
|
||||||
|
if cached is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
effective, _ = apply_to_app(fast_api, cached)
|
||||||
|
except CONFIG_APPLY_ERRORS:
|
||||||
|
logger.warning("Config pushed to another worker could not be applied here", exc_info=True)
|
||||||
|
return
|
||||||
|
logger.info(
|
||||||
|
"Adopted AI config pushed to another worker: smart_model=%s fast_model=%s",
|
||||||
|
effective.smart_model_name,
|
||||||
|
effective.fast_model_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_config_cache_watcher(
|
||||||
|
fast_api: FastAPI,
|
||||||
|
interval_seconds: int,
|
||||||
|
stop: asyncio.Event,
|
||||||
|
) -> None:
|
||||||
|
"""Poll the shared config cache so every worker converges on the last push."""
|
||||||
|
while not await _sleep_until(stop, interval_seconds):
|
||||||
|
try:
|
||||||
|
_adopt_cached_config_if_changed(fast_api)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Config cache watcher iteration failed; will retry on next interval")
|
||||||
|
|
||||||
|
|
||||||
def _load_startup_settings(fast_api: FastAPI) -> AppSettings:
|
def _load_startup_settings(fast_api: FastAPI) -> AppSettings:
|
||||||
override = fast_api.dependency_overrides.get(load_settings)
|
override = fast_api.dependency_overrides.get(load_settings)
|
||||||
if override is not None:
|
if override is not None:
|
||||||
@@ -83,37 +118,84 @@ def _load_startup_settings(fast_api: FastAPI) -> AppSettings:
|
|||||||
return load_settings()
|
return load_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_cached_config(
|
||||||
|
settings: AppSettings,
|
||||||
|
) -> tuple[AppSettings, Model | None, Model | None, EmbeddingService | None]:
|
||||||
|
"""Restore the last-applied pushed config from the encrypted cache, or env settings on any failure."""
|
||||||
|
if not settings.allow_config_push:
|
||||||
|
return settings, None, None, None
|
||||||
|
cached = load_config()
|
||||||
|
if cached is None:
|
||||||
|
return settings, None, None, None
|
||||||
|
try:
|
||||||
|
effective, smart_model, fast_model, embedder, notes = resolve_and_apply(settings, cached)
|
||||||
|
except CONFIG_APPLY_ERRORS:
|
||||||
|
logger.warning("Cached AI config could not be applied; falling back to env settings", exc_info=True)
|
||||||
|
return settings, None, None, None
|
||||||
|
logger.info(
|
||||||
|
"Restored cached AI config: smart_model=%s fast_model=%s%s",
|
||||||
|
effective.smart_model_name,
|
||||||
|
effective.fast_model_name,
|
||||||
|
f"; {'; '.join(notes)}" if notes else "",
|
||||||
|
)
|
||||||
|
return effective, smart_model, fast_model, embedder
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(fast_api: FastAPI):
|
async def lifespan(fast_api: FastAPI):
|
||||||
# Load env vars on startup so we can immediately crash if required env vars aren't set
|
# Load env vars on startup so we can immediately crash if required env vars aren't set
|
||||||
settings = _load_startup_settings(fast_api)
|
settings = _load_startup_settings(fast_api)
|
||||||
runtime = build_runtime(settings)
|
# Precedence: env < persisted cache < live push. Stamp first so a push landing mid-boot
|
||||||
fast_api.state.settings = settings
|
# is re-adopted by the watcher rather than mistaken for the config we just restored.
|
||||||
fast_api.state.runtime = runtime
|
fast_api.state.config_cache_stamp = cache_stamp()
|
||||||
fast_api.state.orchestrator_agent = OrchestratorAgent(runtime)
|
effective, smart_model, fast_model, embedder = _restore_cached_config(settings)
|
||||||
fast_api.state.pdf_edit_agent = PdfEditAgent(runtime)
|
app_state = build_app_state(
|
||||||
fast_api.state.pdf_question_agent = PdfQuestionAgent(runtime)
|
effective,
|
||||||
fast_api.state.user_spec_agent = UserSpecAgent(runtime)
|
fast_model=fast_model,
|
||||||
fast_api.state.execution_planning_agent = ExecutionPlanningAgent(runtime)
|
smart_model=smart_model,
|
||||||
fast_api.state.math_auditor_agent = MathAuditorAgent(runtime)
|
embedder=embedder,
|
||||||
fast_api.state.pdf_comment_agent = PdfCommentAgent(runtime)
|
)
|
||||||
fast_api.state.document_classifier_agent = DocumentClassifierAgent(runtime)
|
fast_api.state.settings = effective
|
||||||
tracer_provider = setup_posthog_tracking(settings)
|
apply_app_state(fast_api.state, app_state)
|
||||||
|
runtime = app_state.runtime
|
||||||
|
tracer_provider = setup_posthog_tracking(effective)
|
||||||
if tracer_provider:
|
if tracer_provider:
|
||||||
Agent.instrument_all(InstrumentationSettings(tracer_provider=tracer_provider))
|
Agent.instrument_all(InstrumentationSettings(tracer_provider=tracer_provider))
|
||||||
|
stop_background = asyncio.Event()
|
||||||
reaper_task = asyncio.create_task(
|
reaper_task = asyncio.create_task(
|
||||||
_run_expired_doc_reaper(
|
_run_expired_doc_reaper(
|
||||||
runtime.documents,
|
runtime.documents,
|
||||||
interval_seconds=settings.documents_reaper_interval_seconds,
|
interval_seconds=settings.documents_reaper_interval_seconds,
|
||||||
|
stop=stop_background,
|
||||||
),
|
),
|
||||||
name="expired-document-reaper",
|
name="expired-document-reaper",
|
||||||
)
|
)
|
||||||
|
background_tasks = [reaper_task]
|
||||||
|
if effective.allow_config_push:
|
||||||
|
# A push reaches only one uvicorn worker; this watcher is how the rest of the
|
||||||
|
# pool picks it up, otherwise most requests keep running the previous models.
|
||||||
|
background_tasks.append(
|
||||||
|
asyncio.create_task(
|
||||||
|
_run_config_cache_watcher(
|
||||||
|
fast_api,
|
||||||
|
interval_seconds=settings.config_cache_poll_interval_seconds,
|
||||||
|
stop=stop_background,
|
||||||
|
),
|
||||||
|
name="config-cache-watcher",
|
||||||
|
)
|
||||||
|
)
|
||||||
yield
|
yield
|
||||||
reaper_task.cancel()
|
# Drain the loops rather than cancel: cancelling a reaper mid `to_thread` sqlite call lets
|
||||||
try:
|
# close() pull the connection out from under it and segfault sqlite-vec. Cancel is a backstop.
|
||||||
await reaper_task
|
stop_background.set()
|
||||||
except asyncio.CancelledError:
|
_, pending = await asyncio.wait(background_tasks, timeout=_BACKGROUND_TASK_DRAIN_SECONDS)
|
||||||
pass
|
for task in pending:
|
||||||
|
logger.warning("Background task %s did not stop in time; cancelling", task.get_name())
|
||||||
|
task.cancel()
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
await runtime.documents.close()
|
await runtime.documents.close()
|
||||||
if tracer_provider:
|
if tracer_provider:
|
||||||
tracer_provider.shutdown()
|
tracer_provider.shutdown()
|
||||||
@@ -135,10 +217,16 @@ app.include_router(ledger_router, dependencies=_user_gate)
|
|||||||
app.include_router(pdf_comments_router, dependencies=_user_gate)
|
app.include_router(pdf_comments_router, dependencies=_user_gate)
|
||||||
app.include_router(agent_capabilities_router, dependencies=_user_gate)
|
app.include_router(agent_capabilities_router, dependencies=_user_gate)
|
||||||
app.include_router(document_classifier_router, dependencies=_user_gate)
|
app.include_router(document_classifier_router, dependencies=_user_gate)
|
||||||
|
# Config push is a system sync with no X-User-Id, so it is guarded by the shared secret
|
||||||
|
# and allow_config_push flag only, deliberately NOT the per-user identity gate.
|
||||||
|
app.include_router(config_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", response_model=HealthResponse)
|
@app.get("/health", response_model=HealthResponse)
|
||||||
async def healthcheck(settings: Annotated[AppSettings, Depends(load_settings)]) -> HealthResponse:
|
async def healthcheck(http_request: Request) -> HealthResponse:
|
||||||
|
# Report the LIVE config on app.state, not the boot-time env cache, so an admin
|
||||||
|
# "Test connection" shows the model actually in use after a push. Falls back to env.
|
||||||
|
settings: AppSettings = getattr(http_request.app.state, "settings", None) or load_settings()
|
||||||
return HealthResponse(
|
return HealthResponse(
|
||||||
status="ok",
|
status="ok",
|
||||||
smart_model=settings.smart_model_name,
|
smart_model=settings.smart_model_name,
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Assemble the runtime + agents into one bundle, swapped onto app.state atomically to change models at runtime."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, fields
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic_ai.models import Model
|
||||||
|
|
||||||
|
from stirling.agents import (
|
||||||
|
DocumentClassifierAgent,
|
||||||
|
ExecutionPlanningAgent,
|
||||||
|
OrchestratorAgent,
|
||||||
|
PdfEditAgent,
|
||||||
|
PdfQuestionAgent,
|
||||||
|
UserSpecAgent,
|
||||||
|
)
|
||||||
|
from stirling.agents.ledger import MathAuditorAgent
|
||||||
|
from stirling.agents.pdf_comment import PdfCommentAgent
|
||||||
|
from stirling.config import AppSettings
|
||||||
|
from stirling.documents import DocumentService, EmbeddingService
|
||||||
|
from stirling.services import AppRuntime, build_runtime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AppState:
|
||||||
|
"""Every object the lifespan assigns onto ``fast_api.state``."""
|
||||||
|
|
||||||
|
runtime: AppRuntime
|
||||||
|
orchestrator_agent: OrchestratorAgent
|
||||||
|
pdf_edit_agent: PdfEditAgent
|
||||||
|
pdf_question_agent: PdfQuestionAgent
|
||||||
|
user_spec_agent: UserSpecAgent
|
||||||
|
execution_planning_agent: ExecutionPlanningAgent
|
||||||
|
math_auditor_agent: MathAuditorAgent
|
||||||
|
pdf_comment_agent: PdfCommentAgent
|
||||||
|
document_classifier_agent: DocumentClassifierAgent
|
||||||
|
|
||||||
|
|
||||||
|
def build_app_state(
|
||||||
|
settings: AppSettings,
|
||||||
|
*,
|
||||||
|
documents: DocumentService | None = None,
|
||||||
|
fast_model: Model | None = None,
|
||||||
|
smart_model: Model | None = None,
|
||||||
|
embedder: EmbeddingService | None = None,
|
||||||
|
) -> AppState:
|
||||||
|
"""Build the runtime and every agent from ``settings``."""
|
||||||
|
runtime = build_runtime(
|
||||||
|
settings,
|
||||||
|
documents=documents,
|
||||||
|
fast_model=fast_model,
|
||||||
|
smart_model=smart_model,
|
||||||
|
embedder=embedder,
|
||||||
|
)
|
||||||
|
return AppState(
|
||||||
|
runtime=runtime,
|
||||||
|
orchestrator_agent=OrchestratorAgent(runtime),
|
||||||
|
pdf_edit_agent=PdfEditAgent(runtime),
|
||||||
|
pdf_question_agent=PdfQuestionAgent(runtime),
|
||||||
|
user_spec_agent=UserSpecAgent(runtime),
|
||||||
|
execution_planning_agent=ExecutionPlanningAgent(runtime),
|
||||||
|
math_auditor_agent=MathAuditorAgent(runtime),
|
||||||
|
pdf_comment_agent=PdfCommentAgent(runtime),
|
||||||
|
document_classifier_agent=DocumentClassifierAgent(runtime),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_app_state(state: Any, app_state: AppState) -> None:
|
||||||
|
"""Copy every field of ``app_state`` onto a Starlette ``app.state`` object."""
|
||||||
|
for field in fields(app_state):
|
||||||
|
setattr(state, field.name, getattr(app_state, field.name))
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from .agent_capabilities import router as agent_capabilities_router
|
from .agent_capabilities import router as agent_capabilities_router
|
||||||
from .agent_drafts import router as agent_draft_router
|
from .agent_drafts import router as agent_draft_router
|
||||||
|
from .config import router as config_router
|
||||||
from .document_classifier import router as document_classifier_router
|
from .document_classifier import router as document_classifier_router
|
||||||
from .documents import router as document_router
|
from .documents import router as document_router
|
||||||
from .execution import router as execution_router
|
from .execution import router as execution_router
|
||||||
@@ -12,6 +13,7 @@ from .pdf_questions import router as pdf_question_router
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"agent_capabilities_router",
|
"agent_capabilities_router",
|
||||||
"agent_draft_router",
|
"agent_draft_router",
|
||||||
|
"config_router",
|
||||||
"document_classifier_router",
|
"document_classifier_router",
|
||||||
"document_router",
|
"document_router",
|
||||||
"execution_router",
|
"execution_router",
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, FastAPI, HTTPException, Request, status
|
||||||
|
from openai import OpenAIError
|
||||||
|
from pydantic_ai.exceptions import UserError
|
||||||
|
from pydantic_ai.models import Model
|
||||||
|
|
||||||
|
from stirling.api.bootstrap import apply_app_state, build_app_state
|
||||||
|
from stirling.config import AppSettings
|
||||||
|
from stirling.config.config_cache import cache_stamp, save_config
|
||||||
|
from stirling.contracts import ConfigApplyResponse, ConfigPushRequest
|
||||||
|
from stirling.documents import EmbeddingService
|
||||||
|
from stirling.services import AppRuntime
|
||||||
|
from stirling.services.runtime import _build_model, validate_structured_output_support
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/config", tags=["config"])
|
||||||
|
|
||||||
|
# Model/provider construction + validation failures. The HTTP route maps these to a
|
||||||
|
# 400 (no swap); boot catches them to fall back to env when a cached config is bad.
|
||||||
|
CONFIG_APPLY_ERRORS = (ValueError, UserError, OpenAIError)
|
||||||
|
|
||||||
|
_REINDEX_NOTE = (
|
||||||
|
"Embedding model changed; existing indexed documents were embedded with the previous model and "
|
||||||
|
"must be re-indexed. If the embedding dimensionality changed, re-ingest before searching."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_provider_prefix(model_name: str) -> str:
|
||||||
|
"""Drop a leading ``provider:`` from an env model string ("anthropic:x" -> "x")."""
|
||||||
|
_, sep, rest = model_name.partition(":")
|
||||||
|
return rest if sep else model_name
|
||||||
|
|
||||||
|
|
||||||
|
def _compose_embedding_model(provider: str, model: str) -> str:
|
||||||
|
"""Compose the engine's ``provider:model`` embedding string from pushed parts."""
|
||||||
|
provider = provider.strip()
|
||||||
|
return f"{provider}:{model}" if provider else model
|
||||||
|
|
||||||
|
|
||||||
|
def _split_embedding_ref(ref: str) -> tuple[str, str]:
|
||||||
|
"""Split an env embedding string ("voyageai:voyage-4") into (provider, model)."""
|
||||||
|
provider, sep, model = ref.partition(":")
|
||||||
|
return (provider, model) if sep else ("", ref)
|
||||||
|
|
||||||
|
|
||||||
|
def _keep(pushed: int | None, current: int) -> int:
|
||||||
|
"""Return the pushed value, or the current one when the push omitted it."""
|
||||||
|
return pushed if pushed is not None else current
|
||||||
|
|
||||||
|
|
||||||
|
# Presence of any of these means request.client.host may be proxy-rewritten and cannot be
|
||||||
|
# trusted as the transport peer, so a spoofed X-Forwarded-For could otherwise read as loopback.
|
||||||
|
_FORWARDING_HEADERS = ("x-forwarded-for", "x-forwarded-host", "x-real-ip", "forwarded")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_direct_loopback_client(request: Request) -> bool:
|
||||||
|
"""True only for a direct local connection with no proxy; fails closed if any forwarding header is present."""
|
||||||
|
if any(h in request.headers for h in _FORWARDING_HEADERS):
|
||||||
|
return False
|
||||||
|
client = request.client
|
||||||
|
if client is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return ipaddress.ip_address(client.host).is_loopback
|
||||||
|
except ValueError:
|
||||||
|
return client.host == "localhost"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_and_apply(
|
||||||
|
current: AppSettings,
|
||||||
|
request: ConfigPushRequest,
|
||||||
|
) -> tuple[AppSettings, Model, Model, EmbeddingService | None, list[str]]:
|
||||||
|
"""Resolve a pushed config against the running settings; it never swaps live state (the caller does)."""
|
||||||
|
models = request.models
|
||||||
|
rag = request.rag
|
||||||
|
limits = request.limits
|
||||||
|
notes: list[str] = []
|
||||||
|
|
||||||
|
provider = models.provider.strip()
|
||||||
|
api_key = models.api_key
|
||||||
|
base_url = models.base_url
|
||||||
|
use_explicit_provider = bool(provider or api_key or base_url)
|
||||||
|
|
||||||
|
if use_explicit_provider and not current.chat_provider:
|
||||||
|
# First push over an env engine: running names are still "provider:model", strip the prefix.
|
||||||
|
smart_name = models.smart_model or _strip_provider_prefix(current.smart_model_name)
|
||||||
|
fast_name = models.fast_model or _strip_provider_prefix(current.fast_model_name)
|
||||||
|
elif use_explicit_provider:
|
||||||
|
# A provider was already pushed, so the running names are bare and may legitimately
|
||||||
|
# contain a colon ("llama3.1:8b"). Stripping again would truncate them to "8b".
|
||||||
|
smart_name = models.smart_model or current.smart_model_name
|
||||||
|
fast_name = models.fast_model or current.fast_model_name
|
||||||
|
else:
|
||||||
|
# No provider/credentials pushed: keep the fully env-driven model strings.
|
||||||
|
smart_name = models.smart_model or current.smart_model_name
|
||||||
|
fast_name = models.fast_model or current.fast_model_name
|
||||||
|
|
||||||
|
def _build(bare: str) -> Model:
|
||||||
|
if use_explicit_provider:
|
||||||
|
return _build_model(bare, provider=provider or None, api_key=api_key or None, base_url=base_url or None)
|
||||||
|
return _build_model(bare)
|
||||||
|
|
||||||
|
smart_model = _build(smart_name)
|
||||||
|
fast_model = _build(fast_name)
|
||||||
|
validate_structured_output_support(smart_model, smart_name)
|
||||||
|
validate_structured_output_support(fast_model, fast_name)
|
||||||
|
|
||||||
|
# Scalars: None / empty keep the current value.
|
||||||
|
smart_max_tokens = _keep(models.smart_max_tokens, current.smart_model_max_tokens)
|
||||||
|
fast_max_tokens = _keep(models.fast_max_tokens, current.fast_model_max_tokens)
|
||||||
|
top_k = _keep(rag.top_k, current.rag_default_top_k)
|
||||||
|
max_searches = _keep(rag.max_searches, current.rag_max_searches)
|
||||||
|
max_pages = _keep(limits.max_pages, current.max_pages)
|
||||||
|
max_characters = _keep(limits.max_characters, current.max_characters)
|
||||||
|
model_max_concurrency = _keep(limits.model_max_concurrency, current.model_max_concurrency)
|
||||||
|
|
||||||
|
# Embedding: any non-empty embedding field triggers a rebuild; empty fields fall
|
||||||
|
# back to the running provider/model/creds so a partial push never clobbers env.
|
||||||
|
embedding_changed = bool(
|
||||||
|
rag.embedding_provider.strip() or rag.embedding_model.strip() or rag.embedding_api_key or rag.embedding_base_url
|
||||||
|
)
|
||||||
|
rag_embedding_model = current.rag_embedding_model
|
||||||
|
new_embedder: EmbeddingService | None = None
|
||||||
|
if embedding_changed:
|
||||||
|
current_provider, current_model = _split_embedding_ref(current.rag_embedding_model)
|
||||||
|
embed_provider = rag.embedding_provider.strip() or current_provider
|
||||||
|
embed_model = rag.embedding_model.strip() or current_model
|
||||||
|
rag_embedding_model = _compose_embedding_model(embed_provider, embed_model)
|
||||||
|
new_embedder = EmbeddingService(
|
||||||
|
model_name=embed_model,
|
||||||
|
chunk_size=current.rag_chunk_size,
|
||||||
|
chunk_overlap=current.rag_chunk_overlap,
|
||||||
|
provider=embed_provider or None,
|
||||||
|
api_key=rag.embedding_api_key or None,
|
||||||
|
base_url=rag.embedding_base_url or None,
|
||||||
|
)
|
||||||
|
notes.append(_REINDEX_NOTE)
|
||||||
|
|
||||||
|
effective = current.model_copy(
|
||||||
|
update={
|
||||||
|
"chat_provider": provider,
|
||||||
|
"smart_model_name": smart_name,
|
||||||
|
"fast_model_name": fast_name,
|
||||||
|
"smart_model_max_tokens": smart_max_tokens,
|
||||||
|
"fast_model_max_tokens": fast_max_tokens,
|
||||||
|
"rag_embedding_model": rag_embedding_model,
|
||||||
|
"rag_default_top_k": top_k,
|
||||||
|
"rag_max_searches": max_searches,
|
||||||
|
"max_pages": max_pages,
|
||||||
|
"max_characters": max_characters,
|
||||||
|
"model_max_concurrency": model_max_concurrency,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return effective, smart_model, fast_model, new_embedder, notes
|
||||||
|
|
||||||
|
|
||||||
|
def apply_to_app(app: FastAPI, request: ConfigPushRequest) -> tuple[AppSettings, list[str]]:
|
||||||
|
"""Resolve ``request`` and swap the bundle onto app.state; no await, so the swap is atomic wrt the event loop."""
|
||||||
|
current: AppSettings = app.state.settings
|
||||||
|
runtime: AppRuntime = app.state.runtime
|
||||||
|
effective, smart_model, fast_model, new_embedder, notes = resolve_and_apply(current, request)
|
||||||
|
new_state = build_app_state(
|
||||||
|
effective,
|
||||||
|
documents=runtime.documents,
|
||||||
|
fast_model=fast_model,
|
||||||
|
smart_model=smart_model,
|
||||||
|
)
|
||||||
|
app.state.settings = effective
|
||||||
|
apply_app_state(app.state, new_state)
|
||||||
|
# Retune retrieval breadth on the reused store without rebuilding it.
|
||||||
|
runtime.documents.default_top_k = effective.rag_default_top_k
|
||||||
|
if new_embedder is not None:
|
||||||
|
# Swap the embedder onto the reused DocumentService, never tearing down the live store.
|
||||||
|
runtime.documents.embedder = new_embedder
|
||||||
|
return effective, notes
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ConfigApplyResponse)
|
||||||
|
async def apply_config(request: ConfigPushRequest, http_request: Request) -> ConfigApplyResponse:
|
||||||
|
"""Apply admin-pushed AI settings by rebuilding the runtime + agents, persisting so it survives a restart."""
|
||||||
|
app = http_request.app
|
||||||
|
current: AppSettings = app.state.settings
|
||||||
|
if not current.allow_config_push:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Config push is disabled on this deployment (STIRLING_ALLOW_CONFIG_PUSH is false).",
|
||||||
|
)
|
||||||
|
# Secure-by-default: with no shared secret set, only trust a direct loopback caller, since a
|
||||||
|
# pushed base_url/model could repoint the engine to exfiltrate document content.
|
||||||
|
if not current.engine_shared_secret and not _is_direct_loopback_client(http_request):
|
||||||
|
client_host = http_request.client.host if http_request.client else "unknown"
|
||||||
|
logger.warning(
|
||||||
|
"Rejected config push from non-local/proxied caller %s with no shared secret set",
|
||||||
|
client_host,
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=(
|
||||||
|
"Config push from a non-local or proxied caller requires"
|
||||||
|
" STIRLING_ENGINE_SHARED_SECRET to be set on both the engine and the processor."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
effective, notes = apply_to_app(app, request)
|
||||||
|
except CONFIG_APPLY_ERRORS as exc:
|
||||||
|
# Reject without touching the running config.
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
# Persist (encrypted) so the config survives a restart and sibling workers adopt it.
|
||||||
|
# Best-effort: it is already applied live, so a persist failure must never become a 500.
|
||||||
|
try:
|
||||||
|
save_config(request)
|
||||||
|
# Claim the stamp we just wrote so this worker's watcher does not rebuild for it.
|
||||||
|
app.state.config_cache_stamp = cache_stamp()
|
||||||
|
except Exception: # noqa: BLE001 - best-effort persist, never fail the applied push
|
||||||
|
logger.warning("Applied AI config but failed to persist the encrypted cache", exc_info=True)
|
||||||
|
notes.append(
|
||||||
|
"Config applied on this worker but could not be persisted; it will not survive an"
|
||||||
|
" engine restart and other workers will not pick it up."
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Applied pushed AI config: provider=%s smart_model=%s fast_model=%s top_k=%s",
|
||||||
|
request.models.provider.strip() or "<env>",
|
||||||
|
effective.smart_model_name,
|
||||||
|
effective.fast_model_name,
|
||||||
|
effective.rag_default_top_k,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ConfigApplyResponse(
|
||||||
|
status="applied",
|
||||||
|
provider=request.models.provider.strip(),
|
||||||
|
smart_model=effective.smart_model_name,
|
||||||
|
fast_model=effective.fast_model_name,
|
||||||
|
smart_max_tokens=effective.smart_model_max_tokens,
|
||||||
|
fast_max_tokens=effective.fast_model_max_tokens,
|
||||||
|
rag_embedding_model=effective.rag_embedding_model,
|
||||||
|
rag_top_k=effective.rag_default_top_k,
|
||||||
|
rag_max_searches=effective.rag_max_searches,
|
||||||
|
max_pages=effective.max_pages,
|
||||||
|
max_characters=effective.max_characters,
|
||||||
|
model_max_concurrency=effective.model_max_concurrency,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Persistent, Fernet-encrypted cache of the last-applied config-push. Boot restores it so an
|
||||||
|
engine-only restart keeps admin config; sibling uvicorn workers poll it to adopt a push."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
|
||||||
|
from stirling.config.settings import ENGINE_ROOT, load_settings
|
||||||
|
from stirling.contracts import ConfigPushRequest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_CACHE_FILENAME = "ai_config_cache.enc"
|
||||||
|
_KEY_FILENAME = "ai_config_cache.key"
|
||||||
|
# Constant salt/info so the same shared secret always derives the same Fernet key.
|
||||||
|
_HKDF_SALT = b"stirling-ai-config-cache/v1/salt"
|
||||||
|
_HKDF_INFO = b"stirling-ai-config-cache/v1/fernet-key"
|
||||||
|
|
||||||
|
_keyfile_warned = False
|
||||||
|
|
||||||
|
|
||||||
|
def _default_data_dir() -> Path:
|
||||||
|
"""The engine data dir (where the sqlite store lives by default)."""
|
||||||
|
return ENGINE_ROOT / "data"
|
||||||
|
|
||||||
|
|
||||||
|
def _shared_secret() -> str:
|
||||||
|
return load_settings().engine_shared_secret
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_key_from_secret(secret: str) -> bytes:
|
||||||
|
hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=_HKDF_SALT, info=_HKDF_INFO)
|
||||||
|
return base64.urlsafe_b64encode(hkdf.derive(secret.encode("utf-8")))
|
||||||
|
|
||||||
|
|
||||||
|
def _write_private_bytes(path: Path, payload: bytes) -> None:
|
||||||
|
"""Write ``payload`` to ``path`` atomically (temp file + rename) and owner-only (0600) from the moment it exists."""
|
||||||
|
tmp_path = path.with_name(f"{path.name}.tmp")
|
||||||
|
fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "wb") as handle:
|
||||||
|
handle.write(payload)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(tmp_path, path)
|
||||||
|
except BaseException:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _load_or_create_keyfile(data_dir: Path) -> bytes:
|
||||||
|
global _keyfile_warned
|
||||||
|
key_path = data_dir / _KEY_FILENAME
|
||||||
|
if key_path.exists():
|
||||||
|
return key_path.read_bytes().strip()
|
||||||
|
key = Fernet.generate_key()
|
||||||
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
_write_private_bytes(key_path, key)
|
||||||
|
if not _keyfile_warned:
|
||||||
|
logger.warning(
|
||||||
|
"STIRLING_ENGINE_SHARED_SECRET is not set; encrypting the AI config cache with a local"
|
||||||
|
" keyfile at %s (0600, best-effort). This is modest protection only - set a shared secret"
|
||||||
|
" for HKDF key derivation in any deployment where the cache must be strongly protected.",
|
||||||
|
key_path,
|
||||||
|
)
|
||||||
|
_keyfile_warned = True
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _fernet(data_dir: Path) -> Fernet:
|
||||||
|
secret = _shared_secret()
|
||||||
|
if secret:
|
||||||
|
return Fernet(_derive_key_from_secret(secret))
|
||||||
|
return Fernet(_load_or_create_keyfile(data_dir))
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(request: ConfigPushRequest, *, data_dir: Path | None = None) -> None:
|
||||||
|
"""Encrypt and persist the last-applied pushed config, overwriting any prior file."""
|
||||||
|
data_dir = data_dir or _default_data_dir()
|
||||||
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = request.model_dump_json(by_alias=True).encode("utf-8")
|
||||||
|
token = _fernet(data_dir).encrypt(payload)
|
||||||
|
_write_private_bytes(data_dir / _CACHE_FILENAME, token)
|
||||||
|
|
||||||
|
|
||||||
|
def cache_stamp(*, data_dir: Path | None = None) -> tuple[int, int] | None:
|
||||||
|
"""Identify the current cache file as (mtime_ns, size), or None when absent; cheap enough to poll."""
|
||||||
|
cache_path = (data_dir or _default_data_dir()) / _CACHE_FILENAME
|
||||||
|
try:
|
||||||
|
info = cache_path.stat()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return (info.st_mtime_ns, info.st_size)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(*, data_dir: Path | None = None) -> ConfigPushRequest | None:
|
||||||
|
"""Load + decrypt the persisted config; returns None (never raises) when absent, corrupt, or wrong key."""
|
||||||
|
data_dir = data_dir or _default_data_dir()
|
||||||
|
cache_path = data_dir / _CACHE_FILENAME
|
||||||
|
if not cache_path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
token = cache_path.read_bytes()
|
||||||
|
payload = _fernet(data_dir).decrypt(token)
|
||||||
|
return ConfigPushRequest.model_validate_json(payload)
|
||||||
|
except (InvalidToken, ValueError, OSError) as exc:
|
||||||
|
logger.warning("Ignoring unreadable AI config cache at %s: %s", cache_path, exc)
|
||||||
|
return None
|
||||||
@@ -25,6 +25,9 @@ class AppSettings(BaseSettings):
|
|||||||
|
|
||||||
smart_model_name: str = Field(validation_alias="STIRLING_SMART_MODEL")
|
smart_model_name: str = Field(validation_alias="STIRLING_SMART_MODEL")
|
||||||
fast_model_name: str = Field(validation_alias="STIRLING_FAST_MODEL")
|
fast_model_name: str = Field(validation_alias="STIRLING_FAST_MODEL")
|
||||||
|
# Provider backing the active chat models; empty for env/native, 'ollama'/'custom' by push.
|
||||||
|
# Agents read it to pick a tool-compatible output strategy since local models block tools under native json-schema.
|
||||||
|
chat_provider: str = Field(default="")
|
||||||
smart_model_max_tokens: int = Field(validation_alias="STIRLING_SMART_MODEL_MAX_TOKENS")
|
smart_model_max_tokens: int = Field(validation_alias="STIRLING_SMART_MODEL_MAX_TOKENS")
|
||||||
fast_model_max_tokens: int = Field(validation_alias="STIRLING_FAST_MODEL_MAX_TOKENS")
|
fast_model_max_tokens: int = Field(validation_alias="STIRLING_FAST_MODEL_MAX_TOKENS")
|
||||||
# Process-wide ceiling on concurrent model API calls, shared by both model
|
# Process-wide ceiling on concurrent model API calls, shared by both model
|
||||||
@@ -123,6 +126,15 @@ class AppSettings(BaseSettings):
|
|||||||
engine_shared_secret: str = Field(default="", validation_alias="STIRLING_ENGINE_SHARED_SECRET")
|
engine_shared_secret: str = Field(default="", validation_alias="STIRLING_ENGINE_SHARED_SECRET")
|
||||||
engine_require_auth: bool = Field(default=False, validation_alias="STIRLING_ENGINE_REQUIRE_AUTH")
|
engine_require_auth: bool = Field(default=False, validation_alias="STIRLING_ENGINE_REQUIRE_AUTH")
|
||||||
|
|
||||||
|
# When true, the Java processor may push admin AI settings to POST /api/v1/config at startup.
|
||||||
|
# Turn off in env-driven deployments so the environment is the single source of truth.
|
||||||
|
allow_config_push: bool = Field(default=True, validation_alias="STIRLING_ALLOW_CONFIG_PUSH")
|
||||||
|
# How often each worker polls the shared config cache; bounds how long the pool can disagree on the active model.
|
||||||
|
config_cache_poll_interval_seconds: int = Field(
|
||||||
|
default=15,
|
||||||
|
validation_alias="STIRLING_CONFIG_CACHE_POLL_INTERVAL_SECONDS",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _configure_logging(level_name: str, log_file: str, http_debug: bool) -> None:
|
def _configure_logging(level_name: str, log_file: str, http_debug: bool) -> None:
|
||||||
"""Configure the ``stirling`` logger hierarchy."""
|
"""Configure the ``stirling`` logger hierarchy."""
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ from .common import (
|
|||||||
format_conversation_history,
|
format_conversation_history,
|
||||||
format_file_names,
|
format_file_names,
|
||||||
)
|
)
|
||||||
|
from .config import (
|
||||||
|
ConfigApplyResponse,
|
||||||
|
ConfigLimitsSection,
|
||||||
|
ConfigModelsSection,
|
||||||
|
ConfigPushRequest,
|
||||||
|
ConfigRagSection,
|
||||||
|
)
|
||||||
from .contradiction import (
|
from .contradiction import (
|
||||||
Claim,
|
Claim,
|
||||||
Contradiction,
|
Contradiction,
|
||||||
@@ -139,6 +146,11 @@ __all__ = [
|
|||||||
"Claim",
|
"Claim",
|
||||||
"CommentSpec",
|
"CommentSpec",
|
||||||
"CompletedExecutionAction",
|
"CompletedExecutionAction",
|
||||||
|
"ConfigApplyResponse",
|
||||||
|
"ConfigLimitsSection",
|
||||||
|
"ConfigModelsSection",
|
||||||
|
"ConfigPushRequest",
|
||||||
|
"ConfigRagSection",
|
||||||
"Contradiction",
|
"Contradiction",
|
||||||
"ContradictionReport",
|
"ContradictionReport",
|
||||||
"ContradictionSeverity",
|
"ContradictionSeverity",
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import ConfigDict, Field
|
||||||
|
|
||||||
|
from stirling.models import ApiModel
|
||||||
|
|
||||||
|
|
||||||
|
class TolerantApiModel(ApiModel):
|
||||||
|
"""Push-contract base: unknown fields from a newer processor are ignored rather than
|
||||||
|
rejecting the whole push. Overrides only the extra policy; camelCase aliasing is inherited."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigModelsSection(TolerantApiModel):
|
||||||
|
"""Model provider + credentials pushed by the Java processor; empty fields mean "keep the engine's env value"."""
|
||||||
|
|
||||||
|
provider: str = ""
|
||||||
|
smart_model: str = ""
|
||||||
|
fast_model: str = ""
|
||||||
|
smart_max_tokens: int | None = Field(default=None, ge=1)
|
||||||
|
fast_max_tokens: int | None = Field(default=None, ge=1)
|
||||||
|
api_key: str = ""
|
||||||
|
base_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigRagSection(TolerantApiModel):
|
||||||
|
embedding_provider: str = ""
|
||||||
|
embedding_model: str = ""
|
||||||
|
embedding_api_key: str = ""
|
||||||
|
# OpenAI-compatible endpoint URL for ollama/custom embedding providers; empty keeps the env value.
|
||||||
|
embedding_base_url: str = ""
|
||||||
|
top_k: int | None = Field(default=None, ge=1)
|
||||||
|
# 0 is a legitimate "no retrieval searches" setting, so this floors at 0 not 1.
|
||||||
|
max_searches: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigLimitsSection(TolerantApiModel):
|
||||||
|
max_pages: int | None = Field(default=None, ge=1)
|
||||||
|
max_characters: int | None = Field(default=None, ge=1)
|
||||||
|
# Must be >= 1: it becomes an asyncio.Semaphore bound, and 0 constructs a permanently locked
|
||||||
|
# semaphore that hangs every model call; the push is persisted so a restart won't clear it.
|
||||||
|
model_max_concurrency: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigPushRequest(TolerantApiModel):
|
||||||
|
"""Admin-configured AI settings pushed at processor startup."""
|
||||||
|
|
||||||
|
models: ConfigModelsSection = Field(default_factory=ConfigModelsSection)
|
||||||
|
rag: ConfigRagSection = Field(default_factory=ConfigRagSection)
|
||||||
|
limits: ConfigLimitsSection = Field(default_factory=ConfigLimitsSection)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigApplyResponse(ApiModel):
|
||||||
|
"""Summary of the effective config after a push. Never echoes credentials."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
provider: str
|
||||||
|
smart_model: str
|
||||||
|
fast_model: str
|
||||||
|
smart_max_tokens: int
|
||||||
|
fast_max_tokens: int
|
||||||
|
rag_embedding_model: str
|
||||||
|
rag_top_k: int
|
||||||
|
rag_max_searches: int
|
||||||
|
max_pages: int
|
||||||
|
max_characters: int
|
||||||
|
model_max_concurrency: int
|
||||||
|
notes: list[str] = Field(default_factory=list)
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pydantic_ai import Embedder
|
from pydantic_ai import Embedder
|
||||||
|
from pydantic_ai.embeddings.openai import OpenAIEmbeddingModel
|
||||||
|
from pydantic_ai.providers.openai import OpenAIProvider
|
||||||
|
|
||||||
from stirling.documents.chunker import chunk_text
|
from stirling.documents.chunker import chunk_text
|
||||||
from stirling.documents.store import Document
|
from stirling.documents.store import Document
|
||||||
@@ -12,6 +14,27 @@ from stirling.documents.store import Document
|
|||||||
DEFAULT_EMBED_BATCH_SIZE = 256
|
DEFAULT_EMBED_BATCH_SIZE = 256
|
||||||
|
|
||||||
|
|
||||||
|
def _build_embedder(
|
||||||
|
model_name: str,
|
||||||
|
*,
|
||||||
|
provider: str | None = None,
|
||||||
|
api_key: str | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
|
) -> Embedder:
|
||||||
|
"""Construct an :class:`Embedder`; explicit provider/api_key/base_url is the config-push path, else env form."""
|
||||||
|
if not provider and not api_key and not base_url:
|
||||||
|
return Embedder(model_name)
|
||||||
|
|
||||||
|
provider_name = (provider or "").lower()
|
||||||
|
key = api_key or None
|
||||||
|
if provider_name in ("voyageai", "openai"):
|
||||||
|
return Embedder(f"{provider_name}:{model_name}")
|
||||||
|
if provider_name in ("ollama", "custom"):
|
||||||
|
openai_provider = OpenAIProvider(base_url=base_url or None, api_key=key or "ollama")
|
||||||
|
return Embedder(OpenAIEmbeddingModel(model_name, provider=openai_provider))
|
||||||
|
raise ValueError(f"Unsupported embedding provider {provider!r}.")
|
||||||
|
|
||||||
|
|
||||||
class EmbeddingService:
|
class EmbeddingService:
|
||||||
"""Wraps Pydantic AI's Embedder to provide document chunking and embedding."""
|
"""Wraps Pydantic AI's Embedder to provide document chunking and embedding."""
|
||||||
|
|
||||||
@@ -21,8 +44,12 @@ class EmbeddingService:
|
|||||||
chunk_size: int = 512,
|
chunk_size: int = 512,
|
||||||
chunk_overlap: int = 64,
|
chunk_overlap: int = 64,
|
||||||
embed_batch_size: int = DEFAULT_EMBED_BATCH_SIZE,
|
embed_batch_size: int = DEFAULT_EMBED_BATCH_SIZE,
|
||||||
|
*,
|
||||||
|
provider: str | None = None,
|
||||||
|
api_key: str | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._embedder = Embedder(model_name)
|
self._embedder = _build_embedder(model_name, provider=provider, api_key=api_key, base_url=base_url)
|
||||||
self._chunk_size = chunk_size
|
self._chunk_size = chunk_size
|
||||||
self._chunk_overlap = chunk_overlap
|
self._chunk_overlap = chunk_overlap
|
||||||
self._embed_batch_size = embed_batch_size
|
self._embed_batch_size = embed_batch_size
|
||||||
|
|||||||
@@ -43,6 +43,24 @@ class DocumentService:
|
|||||||
self._store = store
|
self._store = store
|
||||||
self._default_top_k = default_top_k
|
self._default_top_k = default_top_k
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_top_k(self) -> int:
|
||||||
|
return self._default_top_k
|
||||||
|
|
||||||
|
@default_top_k.setter
|
||||||
|
def default_top_k(self, value: int) -> None:
|
||||||
|
# Lets a config-push retune retrieval breadth without rebuilding the store.
|
||||||
|
self._default_top_k = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def embedder(self) -> EmbeddingService:
|
||||||
|
return self._embedder
|
||||||
|
|
||||||
|
@embedder.setter
|
||||||
|
def embedder(self, value: EmbeddingService) -> None:
|
||||||
|
# Lets a config-push swap the embedding model while keeping the live store; existing vectors need re-indexing.
|
||||||
|
self._embedder = value
|
||||||
|
|
||||||
async def ingest(
|
async def ingest(
|
||||||
self,
|
self,
|
||||||
collection: FileId,
|
collection: FileId,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -12,8 +14,10 @@ from pydantic_ai import RunContext
|
|||||||
from pydantic_ai.messages import ModelMessage, ModelResponse
|
from pydantic_ai.messages import ModelMessage, ModelResponse
|
||||||
from pydantic_ai.models import Model, ModelRequestParameters, StreamedResponse, infer_model
|
from pydantic_ai.models import Model, ModelRequestParameters, StreamedResponse, infer_model
|
||||||
from pydantic_ai.models.anthropic import AnthropicModel
|
from pydantic_ai.models.anthropic import AnthropicModel
|
||||||
|
from pydantic_ai.models.openai import OpenAIChatModel
|
||||||
from pydantic_ai.models.wrapper import WrapperModel
|
from pydantic_ai.models.wrapper import WrapperModel
|
||||||
from pydantic_ai.providers.anthropic import AnthropicProvider
|
from pydantic_ai.providers.anthropic import AnthropicProvider
|
||||||
|
from pydantic_ai.providers.openai import OpenAIProvider
|
||||||
from pydantic_ai.settings import ModelSettings
|
from pydantic_ai.settings import ModelSettings
|
||||||
|
|
||||||
from stirling.config import ENGINE_ROOT, AppSettings, DocumentsBackend
|
from stirling.config import ENGINE_ROOT, AppSettings, DocumentsBackend
|
||||||
@@ -49,6 +53,63 @@ def _build_anthropic_http_client() -> httpx.AsyncClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Placeholder when no Anthropic key is set, so a deployment using only another provider
|
||||||
|
# (Ollama etc.) can still boot. Anthropic calls then fail with a clear 401 instead.
|
||||||
|
_UNCONFIGURED_ANTHROPIC_KEY = "unconfigured"
|
||||||
|
_warned_missing_anthropic_key = False
|
||||||
|
|
||||||
|
|
||||||
|
def _anthropic_provider(explicit_key: str | None = None) -> AnthropicProvider:
|
||||||
|
"""Build the Anthropic provider, tolerating a missing key so an Ollama/OpenAI-only deployment can still boot."""
|
||||||
|
http_client = _build_anthropic_http_client()
|
||||||
|
key = explicit_key or os.environ.get("ANTHROPIC_API_KEY")
|
||||||
|
if key:
|
||||||
|
return AnthropicProvider(api_key=key, http_client=http_client)
|
||||||
|
global _warned_missing_anthropic_key
|
||||||
|
if not _warned_missing_anthropic_key:
|
||||||
|
_warned_missing_anthropic_key = True
|
||||||
|
logger.warning(
|
||||||
|
"ANTHROPIC_API_KEY is not set - the engine will start, but Anthropic model "
|
||||||
|
"calls will fail until a key is provided or a different provider is configured "
|
||||||
|
"(admin AI settings / config push)."
|
||||||
|
)
|
||||||
|
return AnthropicProvider(api_key=_UNCONFIGURED_ANTHROPIC_KEY, http_client=http_client)
|
||||||
|
|
||||||
|
|
||||||
|
class _NullContentCoercingTransport(httpx.AsyncHTTPTransport):
|
||||||
|
"""Coerce assistant ``content: null`` to ``""`` in outgoing OpenAI requests, which Ollama otherwise rejects."""
|
||||||
|
|
||||||
|
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.headers.get("content-type", "").startswith("application/json") and request.content:
|
||||||
|
try:
|
||||||
|
body = json.loads(request.content)
|
||||||
|
except ValueError:
|
||||||
|
return await super().handle_async_request(request)
|
||||||
|
messages = body.get("messages")
|
||||||
|
if isinstance(messages, list):
|
||||||
|
changed = False
|
||||||
|
for message in messages:
|
||||||
|
if isinstance(message, dict) and message.get("content", "") is None:
|
||||||
|
message["content"] = ""
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
new_body = json.dumps(body).encode("utf-8")
|
||||||
|
headers = [(k, v) for k, v in request.headers.raw if k.lower() != b"content-length"]
|
||||||
|
request = httpx.Request(
|
||||||
|
method=request.method,
|
||||||
|
url=request.url,
|
||||||
|
headers=headers,
|
||||||
|
content=new_body,
|
||||||
|
extensions=request.extensions,
|
||||||
|
)
|
||||||
|
return await super().handle_async_request(request)
|
||||||
|
|
||||||
|
|
||||||
|
def _openai_compat_http_client() -> httpx.AsyncClient:
|
||||||
|
"""httpx client for Ollama/custom OpenAI-compatible endpoints (null-content fix)."""
|
||||||
|
return httpx.AsyncClient(transport=_NullContentCoercingTransport())
|
||||||
|
|
||||||
|
|
||||||
class ConcurrencyLimitedModel(WrapperModel):
|
class ConcurrencyLimitedModel(WrapperModel):
|
||||||
"""Caps in-flight model API calls with a semaphore shared across the process."""
|
"""Caps in-flight model API calls with a semaphore shared across the process."""
|
||||||
|
|
||||||
@@ -131,45 +192,72 @@ def _build_document_store(settings: AppSettings) -> DocumentStore:
|
|||||||
assert_never(settings.documents_backend)
|
assert_never(settings.documents_backend)
|
||||||
|
|
||||||
|
|
||||||
def _build_documents(settings: AppSettings) -> DocumentService:
|
def _build_documents(settings: AppSettings, embedder: EmbeddingService | None = None) -> DocumentService:
|
||||||
"""Build the document service used by per-request RAG capabilities."""
|
"""Build the document service used for RAG; ``embedder`` lets a cache-restore boot inject a pre-built one."""
|
||||||
logger.info("Documents: embedding_model=%s", settings.rag_embedding_model)
|
if embedder is None:
|
||||||
embedder = EmbeddingService(
|
logger.info("Documents: embedding_model=%s", settings.rag_embedding_model)
|
||||||
model_name=settings.rag_embedding_model,
|
embedder = EmbeddingService(
|
||||||
chunk_size=settings.rag_chunk_size,
|
model_name=settings.rag_embedding_model,
|
||||||
chunk_overlap=settings.rag_chunk_overlap,
|
chunk_size=settings.rag_chunk_size,
|
||||||
)
|
chunk_overlap=settings.rag_chunk_overlap,
|
||||||
|
)
|
||||||
store = _build_document_store(settings)
|
store = _build_document_store(settings)
|
||||||
return DocumentService(embedder=embedder, store=store, default_top_k=settings.rag_default_top_k)
|
return DocumentService(embedder=embedder, store=store, default_top_k=settings.rag_default_top_k)
|
||||||
|
|
||||||
|
|
||||||
def build_runtime(settings: AppSettings) -> AppRuntime:
|
def build_runtime(
|
||||||
fast_model = _build_model(settings.fast_model_name)
|
settings: AppSettings,
|
||||||
smart_model = _build_model(settings.smart_model_name)
|
*,
|
||||||
validate_structured_output_support(fast_model, settings.fast_model_name)
|
documents: DocumentService | None = None,
|
||||||
validate_structured_output_support(smart_model, settings.smart_model_name)
|
fast_model: Model | None = None,
|
||||||
|
smart_model: Model | None = None,
|
||||||
|
embedder: EmbeddingService | None = None,
|
||||||
|
) -> AppRuntime:
|
||||||
|
"""Assemble the shared runtime; the keyword args let a config-push reuse the live store and inject built models."""
|
||||||
|
fast = fast_model if fast_model is not None else _build_model(settings.fast_model_name)
|
||||||
|
smart = smart_model if smart_model is not None else _build_model(settings.smart_model_name)
|
||||||
|
validate_structured_output_support(fast, settings.fast_model_name)
|
||||||
|
validate_structured_output_support(smart, settings.smart_model_name)
|
||||||
|
|
||||||
# One semaphore across both tiers: the cap protects the provider account
|
# One semaphore across both tiers: the cap protects the provider account
|
||||||
# and process resources, which the tiers share.
|
# and process resources, which the tiers share.
|
||||||
model_semaphore = asyncio.Semaphore(settings.model_max_concurrency)
|
model_semaphore = asyncio.Semaphore(settings.model_max_concurrency)
|
||||||
return AppRuntime(
|
return AppRuntime(
|
||||||
settings=settings,
|
settings=settings,
|
||||||
fast_model=ConcurrencyLimitedModel(fast_model, model_semaphore),
|
fast_model=ConcurrencyLimitedModel(fast, model_semaphore),
|
||||||
smart_model=ConcurrencyLimitedModel(smart_model, model_semaphore),
|
smart_model=ConcurrencyLimitedModel(smart, model_semaphore),
|
||||||
documents=_build_documents(settings),
|
documents=documents if documents is not None else _build_documents(settings, embedder),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_model(model_name: str) -> Model:
|
def _build_model(
|
||||||
"""Construct a model, injecting our keepalive-free httpx client for
|
model_name: str,
|
||||||
Anthropic models so workers don't pick up stale pooled connections.
|
*,
|
||||||
|
provider: str | None = None,
|
||||||
|
api_key: str | None = None,
|
||||||
|
base_url: str | None = None,
|
||||||
|
) -> Model:
|
||||||
|
"""Construct a model for ``model_name``; explicit provider/api_key/base_url is the config-push path, else env."""
|
||||||
|
if not provider and not api_key and not base_url:
|
||||||
|
if model_name.startswith("anthropic:"):
|
||||||
|
bare_name = model_name.removeprefix("anthropic:")
|
||||||
|
return AnthropicModel(bare_name, provider=_anthropic_provider())
|
||||||
|
return infer_model(model_name)
|
||||||
|
|
||||||
Other providers fall back to ``infer_model`` defaults; the stale-pool
|
provider_name = (provider or "").lower()
|
||||||
issue is specific to the Cloudflare-fronted Anthropic API in our
|
key = api_key or None
|
||||||
observations and the fix doesn't necessarily apply elsewhere.
|
if provider_name == "anthropic":
|
||||||
"""
|
return AnthropicModel(model_name, provider=_anthropic_provider(key))
|
||||||
if model_name.startswith("anthropic:"):
|
if provider_name == "openai":
|
||||||
bare_name = model_name.removeprefix("anthropic:")
|
openai_provider = OpenAIProvider(api_key=key) if key else OpenAIProvider()
|
||||||
provider = AnthropicProvider(http_client=_build_anthropic_http_client())
|
return OpenAIChatModel(model_name, provider=openai_provider)
|
||||||
return AnthropicModel(bare_name, provider=provider)
|
if provider_name in ("ollama", "custom"):
|
||||||
return infer_model(model_name)
|
# OpenAI-compatible endpoint. Ollama ignores the key but the SDK needs a non-empty
|
||||||
|
# one, so default to a placeholder; the custom client coerces null assistant content.
|
||||||
|
openai_provider = OpenAIProvider(
|
||||||
|
base_url=base_url or None,
|
||||||
|
api_key=key or "ollama",
|
||||||
|
http_client=_openai_compat_http_client(),
|
||||||
|
)
|
||||||
|
return OpenAIChatModel(model_name, provider=openai_provider)
|
||||||
|
raise ValueError(f"Unsupported model provider {provider!r}.")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Tests for the encrypted config cache (stirling.config.config_cache)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from stirling.config import config_cache
|
||||||
|
from stirling.contracts import ConfigPushRequest
|
||||||
|
|
||||||
|
|
||||||
|
def _sample() -> ConfigPushRequest:
|
||||||
|
return ConfigPushRequest.model_validate(
|
||||||
|
{
|
||||||
|
"models": {
|
||||||
|
"provider": "anthropic",
|
||||||
|
"smartModel": "claude-haiku-4-5",
|
||||||
|
"fastModel": "claude-haiku-4-5",
|
||||||
|
"smartMaxTokens": 8192,
|
||||||
|
"fastMaxTokens": 2048,
|
||||||
|
"apiKey": "secret-key-value",
|
||||||
|
"baseUrl": "",
|
||||||
|
},
|
||||||
|
"rag": {
|
||||||
|
"embeddingProvider": "voyageai",
|
||||||
|
"embeddingModel": "voyage-4",
|
||||||
|
"embeddingApiKey": "embed-secret",
|
||||||
|
"embeddingBaseUrl": "",
|
||||||
|
"topK": 20,
|
||||||
|
"maxSearches": 5,
|
||||||
|
},
|
||||||
|
"limits": {"maxPages": 200, "maxCharacters": 200000, "modelMaxConcurrency": 32},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_roundtrip_with_shared_secret(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "the-shared-secret")
|
||||||
|
req = _sample()
|
||||||
|
config_cache.save_config(req, data_dir=tmp_path)
|
||||||
|
# HKDF-from-secret path: no keyfile is written.
|
||||||
|
assert not (tmp_path / "ai_config_cache.key").exists()
|
||||||
|
assert config_cache.load_config(data_dir=tmp_path) == req
|
||||||
|
# Secrets are encrypted at rest.
|
||||||
|
assert b"secret-key-value" not in (tmp_path / "ai_config_cache.enc").read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def test_roundtrip_with_keyfile_when_no_secret(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "")
|
||||||
|
req = _sample()
|
||||||
|
config_cache.save_config(req, data_dir=tmp_path)
|
||||||
|
# No shared secret: a random keyfile is generated and reused for decrypt.
|
||||||
|
assert (tmp_path / "ai_config_cache.key").exists()
|
||||||
|
assert config_cache.load_config(data_dir=tmp_path) == req
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_missing_returns_none(tmp_path: Path) -> None:
|
||||||
|
assert config_cache.load_config(data_dir=tmp_path) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_corrupt_returns_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "a-secret")
|
||||||
|
(tmp_path / "ai_config_cache.enc").write_bytes(b"not-a-valid-fernet-token")
|
||||||
|
assert config_cache.load_config(data_dir=tmp_path) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_key_returns_none(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "secret-a")
|
||||||
|
config_cache.save_config(_sample(), data_dir=tmp_path)
|
||||||
|
# A different secret derives a different key -> decrypt fails, returns None.
|
||||||
|
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "secret-b")
|
||||||
|
assert config_cache.load_config(data_dir=tmp_path) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name == "nt", reason="POSIX file modes are not enforced on Windows")
|
||||||
|
def test_cache_and_keyfile_are_owner_only(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""Both files hold credential material, so neither may land at the umask default."""
|
||||||
|
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "")
|
||||||
|
config_cache.save_config(_sample(), data_dir=tmp_path)
|
||||||
|
|
||||||
|
for name in ("ai_config_cache.enc", "ai_config_cache.key"):
|
||||||
|
mode = stat.S_IMODE((tmp_path / name).stat().st_mode)
|
||||||
|
assert mode == 0o600, f"{name} is {oct(mode)}, expected 0o600"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_leaves_no_temp_file_behind(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""The write goes via a temp file + rename so a reader never sees a partial cache."""
|
||||||
|
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "the-shared-secret")
|
||||||
|
config_cache.save_config(_sample(), data_dir=tmp_path)
|
||||||
|
config_cache.save_config(_sample(), data_dir=tmp_path)
|
||||||
|
|
||||||
|
assert not list(tmp_path.glob("*.tmp"))
|
||||||
|
assert config_cache.load_config(data_dir=tmp_path) == _sample()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_stamp_tracks_writes(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""cache_stamp is None before any write and changes when the file is rewritten."""
|
||||||
|
monkeypatch.setattr(config_cache, "_shared_secret", lambda: "the-shared-secret")
|
||||||
|
assert config_cache.cache_stamp(data_dir=tmp_path) is None
|
||||||
|
|
||||||
|
config_cache.save_config(_sample(), data_dir=tmp_path)
|
||||||
|
first = config_cache.cache_stamp(data_dir=tmp_path)
|
||||||
|
assert first is not None
|
||||||
|
|
||||||
|
bigger = _sample()
|
||||||
|
bigger.models.smart_model = "claude-haiku-4-5-with-a-much-longer-name-so-the-size-differs"
|
||||||
|
config_cache.save_config(bigger, data_dir=tmp_path)
|
||||||
|
assert config_cache.cache_stamp(data_dir=tmp_path) != first
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Wire-contract test pinning the camelCase processor -> engine config push; keep in sync with the Java test."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from stirling.contracts import ConfigPushRequest
|
||||||
|
|
||||||
|
FIXTURE = Path(__file__).parent / "fixtures" / "processor_config_push.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load() -> dict[str, Any]:
|
||||||
|
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"."""
|
||||||
|
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
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"""Tests for the config-push endpoint (POST /api/v1/config), driving the real lifespan so app.state is populated."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import threading
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from conftest import build_app_settings
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from stirling.api import app
|
||||||
|
from stirling.api.app import _adopt_cached_config_if_changed
|
||||||
|
from stirling.config import AppSettings, config_cache, load_settings
|
||||||
|
from stirling.contracts import ConfigPushRequest
|
||||||
|
from stirling.documents import DocumentService
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_config_cache(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""Point the encrypted config cache at a per-test tmp dir so persistence never leaks."""
|
||||||
|
monkeypatch.setattr(config_cache, "_default_data_dir", lambda: tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _client(
|
||||||
|
settings_factory: Callable[[], AppSettings],
|
||||||
|
*,
|
||||||
|
client_addr: tuple[str, int] = ("127.0.0.1", 12345),
|
||||||
|
) -> Iterator[TestClient]:
|
||||||
|
"""Enter a TestClient whose lifespan builds app.state from ``settings_factory``."""
|
||||||
|
previous = app.dependency_overrides.get(load_settings)
|
||||||
|
app.dependency_overrides[load_settings] = settings_factory
|
||||||
|
try:
|
||||||
|
with TestClient(app, client=client_addr) as client:
|
||||||
|
yield client
|
||||||
|
finally:
|
||||||
|
if previous is None:
|
||||||
|
app.dependency_overrides.pop(load_settings, None)
|
||||||
|
else:
|
||||||
|
app.dependency_overrides[load_settings] = previous
|
||||||
|
|
||||||
|
|
||||||
|
def _anthropic_push() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"models": {
|
||||||
|
"provider": "anthropic",
|
||||||
|
"smartModel": "claude-haiku-4-5",
|
||||||
|
"fastModel": "claude-haiku-4-5",
|
||||||
|
"smartMaxTokens": 4096,
|
||||||
|
"fastMaxTokens": 1024,
|
||||||
|
"apiKey": "test-key-not-a-real-secret",
|
||||||
|
"baseUrl": "",
|
||||||
|
},
|
||||||
|
"rag": {
|
||||||
|
"embeddingProvider": "",
|
||||||
|
"embeddingModel": "",
|
||||||
|
"embeddingApiKey": "",
|
||||||
|
"embeddingBaseUrl": "",
|
||||||
|
"topK": 7,
|
||||||
|
"maxSearches": 3,
|
||||||
|
},
|
||||||
|
"limits": {"maxPages": 50, "maxCharacters": 12345, "modelMaxConcurrency": 8},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_push_forbidden_when_disabled() -> None:
|
||||||
|
def factory() -> AppSettings:
|
||||||
|
return build_app_settings().model_copy(update={"allow_config_push": False})
|
||||||
|
|
||||||
|
with _client(factory) as client:
|
||||||
|
response = client.post("/api/v1/config", json=_anthropic_push())
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_push_from_non_local_caller_without_secret_returns_403() -> None:
|
||||||
|
"""Secure-by-default: with no shared secret, a remote caller cannot push a config."""
|
||||||
|
with _client(build_app_settings, client_addr=("203.0.113.9", 4444)) as client:
|
||||||
|
response = client.post("/api/v1/config", json=_anthropic_push())
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "STIRLING_ENGINE_SHARED_SECRET" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_push_from_loopback_with_forwarded_header_returns_403() -> None:
|
||||||
|
"""A forwarding header means the peer address may be proxy-rewritten, so loopback isn't trusted without a secret."""
|
||||||
|
with _client(build_app_settings) as client: # peer is loopback 127.0.0.1
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/config",
|
||||||
|
json=_anthropic_push(),
|
||||||
|
headers={"X-Forwarded-For": "127.0.0.1"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert "STIRLING_ENGINE_SHARED_SECRET" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_push_persist_failure_still_applies_without_500() -> None:
|
||||||
|
"""Persistence is best-effort: a persist failure must not turn an already-applied push into a 500."""
|
||||||
|
with _client(build_app_settings) as client:
|
||||||
|
with patch(
|
||||||
|
"stirling.api.routes.config.save_config",
|
||||||
|
side_effect=ValueError("corrupt keyfile"),
|
||||||
|
):
|
||||||
|
response = client.post("/api/v1/config", json=_anthropic_push())
|
||||||
|
assert response.status_code == 200
|
||||||
|
# The config WAS applied live despite the persist failure.
|
||||||
|
assert app.state.settings.smart_model_name == "claude-haiku-4-5"
|
||||||
|
assert any("could not be persisted" in note for note in response.json()["notes"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_push_applies_model_and_limits() -> None:
|
||||||
|
with _client(build_app_settings) as client:
|
||||||
|
response = client.post("/api/v1/config", json=_anthropic_push())
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
# Wire summary is camelCase and never echoes the api key.
|
||||||
|
assert body["smartModel"] == "claude-haiku-4-5"
|
||||||
|
assert body["fastModel"] == "claude-haiku-4-5"
|
||||||
|
assert body["smartMaxTokens"] == 4096
|
||||||
|
assert body["ragTopK"] == 7
|
||||||
|
assert body["maxPages"] == 50
|
||||||
|
assert body["modelMaxConcurrency"] == 8
|
||||||
|
assert "test-key-not-a-real-secret" not in response.text
|
||||||
|
# State was swapped: the running settings now reflect the push.
|
||||||
|
assert app.state.settings.smart_model_name == "claude-haiku-4-5"
|
||||||
|
assert app.state.settings.max_pages == 50
|
||||||
|
assert app.state.runtime.documents.default_top_k == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_push_unsupported_provider_returns_400_without_swap() -> None:
|
||||||
|
with _client(build_app_settings) as client:
|
||||||
|
before = app.state.runtime
|
||||||
|
payload = _anthropic_push()
|
||||||
|
payload["models"]["provider"] = "nonsense-provider" # type: ignore[index]
|
||||||
|
response = client.post("/api/v1/config", json=payload)
|
||||||
|
assert response.status_code == 400
|
||||||
|
# Running runtime is untouched when the push is rejected.
|
||||||
|
assert app.state.runtime is before
|
||||||
|
assert app.state.settings.smart_model_name == "test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_push_unsupported_model_returns_400() -> None:
|
||||||
|
"""A model that fails structured-output validation is rejected with 400."""
|
||||||
|
with _client(build_app_settings) as client:
|
||||||
|
before = app.state.runtime
|
||||||
|
with patch(
|
||||||
|
"stirling.api.routes.config.validate_structured_output_support",
|
||||||
|
side_effect=ValueError("Unsupported model foo. This model does not support structured outputs."),
|
||||||
|
):
|
||||||
|
response = client.post("/api/v1/config", json=_anthropic_push())
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "does not support structured outputs" in response.json()["detail"]
|
||||||
|
assert app.state.runtime is before
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_push_ollama_embedding_rebuilds_embedder() -> None:
|
||||||
|
"""A pushed ollama/custom embedding provider swaps the embedder onto the reused store, with a re-index note."""
|
||||||
|
with _client(build_app_settings) as client:
|
||||||
|
store_before = app.state.runtime.documents
|
||||||
|
embedder_before = app.state.runtime.documents.embedder
|
||||||
|
payload = _anthropic_push()
|
||||||
|
payload["rag"] = {
|
||||||
|
"embeddingProvider": "ollama",
|
||||||
|
"embeddingModel": "nomic-embed-text",
|
||||||
|
"embeddingApiKey": "",
|
||||||
|
"embeddingBaseUrl": "http://localhost:11434/v1",
|
||||||
|
"topK": 9,
|
||||||
|
"maxSearches": 4,
|
||||||
|
}
|
||||||
|
response = client.post("/api/v1/config", json=payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert body["ragEmbeddingModel"] == "ollama:nomic-embed-text"
|
||||||
|
assert any("re-index" in note for note in body["notes"])
|
||||||
|
docs = app.state.runtime.documents
|
||||||
|
# Same store object reused (connection pool intact), embedder swapped.
|
||||||
|
assert docs is store_before
|
||||||
|
assert docs.embedder is not embedder_before
|
||||||
|
assert docs.default_top_k == 9
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_push_unsupported_embedding_provider_returns_400() -> None:
|
||||||
|
with _client(build_app_settings) as client:
|
||||||
|
embedder_before = app.state.runtime.documents.embedder
|
||||||
|
payload = _anthropic_push()
|
||||||
|
payload["rag"] = {
|
||||||
|
"embeddingProvider": "totally-bogus",
|
||||||
|
"embeddingModel": "x",
|
||||||
|
"embeddingApiKey": "",
|
||||||
|
"embeddingBaseUrl": "",
|
||||||
|
"topK": None,
|
||||||
|
"maxSearches": None,
|
||||||
|
}
|
||||||
|
response = client.post("/api/v1/config", json=payload)
|
||||||
|
assert response.status_code == 400
|
||||||
|
# Embedder untouched when the push is rejected.
|
||||||
|
assert app.state.runtime.documents.embedder is embedder_before
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_push_empty_models_keep_env_value() -> None:
|
||||||
|
"""An empty models block keeps the engine's env models but still applies pushed limits."""
|
||||||
|
with _client(build_app_settings) as client:
|
||||||
|
payload = _anthropic_push()
|
||||||
|
payload["models"] = {
|
||||||
|
"provider": "",
|
||||||
|
"smartModel": "",
|
||||||
|
"fastModel": "",
|
||||||
|
"smartMaxTokens": None,
|
||||||
|
"fastMaxTokens": None,
|
||||||
|
"apiKey": "",
|
||||||
|
"baseUrl": "",
|
||||||
|
}
|
||||||
|
response = client.post("/api/v1/config", json=payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Env "test" model preserved for both tiers; limits still applied.
|
||||||
|
assert app.state.settings.smart_model_name == "test"
|
||||||
|
assert app.state.settings.fast_model_name == "test"
|
||||||
|
assert app.state.settings.max_pages == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_push_ignores_unknown_fields() -> None:
|
||||||
|
"""A newer processor pushing unknown fields must not 422; they are ignored and the rest applies."""
|
||||||
|
with _client(build_app_settings) as client:
|
||||||
|
payload = _anthropic_push()
|
||||||
|
payload["futureTopLevelField"] = {"anything": 1}
|
||||||
|
payload["models"]["experimentalFlag"] = True # type: ignore[index]
|
||||||
|
response = client.post("/api/v1/config", json=payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert app.state.settings.smart_model_name == "claude-haiku-4-5"
|
||||||
|
assert app.state.settings.max_pages == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_boot_restores_cached_config() -> None:
|
||||||
|
"""A persisted config is decrypted and applied on boot, overriding env."""
|
||||||
|
config_cache.save_config(ConfigPushRequest.model_validate(_anthropic_push()))
|
||||||
|
with _client(build_app_settings):
|
||||||
|
# Env model is "test"; the cache pushed claude-haiku-4-5 + limits.
|
||||||
|
assert app.state.settings.smart_model_name == "claude-haiku-4-5"
|
||||||
|
assert app.state.settings.fast_model_name == "claude-haiku-4-5"
|
||||||
|
assert app.state.settings.max_pages == 50
|
||||||
|
assert app.state.settings.smart_model_max_tokens == 4096
|
||||||
|
assert app.state.runtime.documents.default_top_k == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_boot_ignores_cache_when_push_disabled() -> None:
|
||||||
|
"""With allow_config_push false, env wins and the cache is ignored."""
|
||||||
|
config_cache.save_config(ConfigPushRequest.model_validate(_anthropic_push()))
|
||||||
|
|
||||||
|
def factory() -> AppSettings:
|
||||||
|
return build_app_settings().model_copy(update={"allow_config_push": False})
|
||||||
|
|
||||||
|
with _client(factory):
|
||||||
|
assert app.state.settings.smart_model_name == "test"
|
||||||
|
assert app.state.settings.max_pages == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_boot_proceeds_on_corrupt_cache(tmp_path: Path) -> None:
|
||||||
|
"""A corrupt cache file is ignored and boot falls back to env, never crashing."""
|
||||||
|
(tmp_path / "ai_config_cache.enc").write_bytes(b"not-a-valid-fernet-token")
|
||||||
|
with _client(build_app_settings):
|
||||||
|
assert app.state.settings.smart_model_name == "test"
|
||||||
|
assert app.state.settings.max_pages == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_push_from_remote_caller_is_allowed_when_secret_is_set() -> None:
|
||||||
|
"""The loopback gate is a fallback for the no-secret case only; a remote caller may push once a secret is set."""
|
||||||
|
|
||||||
|
def factory() -> AppSettings:
|
||||||
|
return build_app_settings().model_copy(update={"engine_shared_secret": "s3cret"})
|
||||||
|
|
||||||
|
with _client(factory, client_addr=("203.0.113.9", 4444)) as client:
|
||||||
|
response = client.post("/api/v1/config", json=_anthropic_push())
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert app.state.settings.smart_model_name == "claude-haiku-4-5"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("section", "field", "value"),
|
||||||
|
[
|
||||||
|
("limits", "modelMaxConcurrency", 0),
|
||||||
|
("limits", "modelMaxConcurrency", -1),
|
||||||
|
("limits", "maxPages", 0),
|
||||||
|
("limits", "maxCharacters", 0),
|
||||||
|
("rag", "topK", 0),
|
||||||
|
("models", "smartMaxTokens", 0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_config_push_rejects_out_of_range_numbers(section: str, field: str, value: int) -> None:
|
||||||
|
"""Out-of-range numbers are rejected by the contract before anything is applied."""
|
||||||
|
with _client(build_app_settings) as client:
|
||||||
|
payload = _anthropic_push()
|
||||||
|
payload[section][field] = value # type: ignore[index]
|
||||||
|
response = client.post("/api/v1/config", json=payload)
|
||||||
|
assert response.status_code == 422
|
||||||
|
# Nothing was applied: the engine is still on its env config.
|
||||||
|
assert app.state.settings.smart_model_name == "test"
|
||||||
|
assert app.state.settings.max_pages == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_push_allows_zero_max_searches() -> None:
|
||||||
|
"""0 searches is a legitimate "no retrieval" setting, not an out-of-range value."""
|
||||||
|
with _client(build_app_settings) as client:
|
||||||
|
payload = _anthropic_push()
|
||||||
|
payload["rag"]["maxSearches"] = 0 # type: ignore[index]
|
||||||
|
response = client.post("/api/v1/config", json=payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert app.state.settings.rag_max_searches == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_push_keeps_a_colon_bearing_model_name() -> None:
|
||||||
|
"""A pushed model name may contain a colon ("llama3.1:8b") and must survive a follow-up push, not be re-stripped."""
|
||||||
|
ollama_push: dict[str, object] = {
|
||||||
|
"models": {
|
||||||
|
"provider": "ollama",
|
||||||
|
"smartModel": "llama3.1:8b",
|
||||||
|
"fastModel": "llama3.1:8b",
|
||||||
|
"baseUrl": "http://localhost:11434/v1",
|
||||||
|
},
|
||||||
|
"rag": {},
|
||||||
|
"limits": {},
|
||||||
|
}
|
||||||
|
with _client(build_app_settings) as client:
|
||||||
|
assert client.post("/api/v1/config", json=ollama_push).status_code == 200
|
||||||
|
assert app.state.settings.smart_model_name == "llama3.1:8b"
|
||||||
|
|
||||||
|
# Second push: same provider/base URL, model left empty ("keep what you have").
|
||||||
|
followup: dict[str, object] = {
|
||||||
|
"models": {
|
||||||
|
"provider": "ollama",
|
||||||
|
"smartModel": "",
|
||||||
|
"fastModel": "",
|
||||||
|
"baseUrl": "http://localhost:11434/v1",
|
||||||
|
},
|
||||||
|
"rag": {},
|
||||||
|
"limits": {"maxPages": 42},
|
||||||
|
}
|
||||||
|
response = client.post("/api/v1/config", json=followup)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert app.state.settings.smart_model_name == "llama3.1:8b"
|
||||||
|
assert app.state.settings.fast_model_name == "llama3.1:8b"
|
||||||
|
assert app.state.settings.max_pages == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_adopts_a_config_pushed_to_a_sibling_worker() -> None:
|
||||||
|
"""A push reaches one uvicorn worker; the rest adopt it from the shared cache file."""
|
||||||
|
with _client(build_app_settings):
|
||||||
|
assert app.state.settings.smart_model_name == "test"
|
||||||
|
|
||||||
|
config_cache.save_config(ConfigPushRequest.model_validate(_anthropic_push()))
|
||||||
|
_adopt_cached_config_if_changed(app)
|
||||||
|
|
||||||
|
assert app.state.settings.smart_model_name == "claude-haiku-4-5"
|
||||||
|
assert app.state.settings.max_pages == 50
|
||||||
|
assert app.state.runtime.documents.default_top_k == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_does_not_rebuild_when_the_cache_is_unchanged() -> None:
|
||||||
|
"""The watcher is a poll, so an unchanged cache must be a no-op, not a rebuild every tick."""
|
||||||
|
config_cache.save_config(ConfigPushRequest.model_validate(_anthropic_push()))
|
||||||
|
with _client(build_app_settings):
|
||||||
|
before = app.state.orchestrator_agent
|
||||||
|
_adopt_cached_config_if_changed(app)
|
||||||
|
assert app.state.orchestrator_agent is before
|
||||||
|
|
||||||
|
|
||||||
|
def test_shutdown_drains_background_tasks_instead_of_cancelling_them() -> None:
|
||||||
|
"""Shutdown must let the reaper iteration finish before the store closes, else close segfaults sqlite-vec."""
|
||||||
|
reap_finished = threading.Event()
|
||||||
|
|
||||||
|
async def slow_reap(*_args: object, **_kwargs: object) -> int:
|
||||||
|
# Long enough to still be running when the (empty) test body hands back to teardown.
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
reap_finished.set()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with patch.object(DocumentService, "reap_expired", slow_reap):
|
||||||
|
with _client(build_app_settings):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert reap_finished.is_set(), "teardown cancelled the reaper mid-iteration"
|
||||||
Generated
+2
@@ -605,6 +605,7 @@ name = "engine"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "cryptography" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "opentelemetry-sdk" },
|
{ name = "opentelemetry-sdk" },
|
||||||
{ name = "pgvector" },
|
{ name = "pgvector" },
|
||||||
@@ -631,6 +632,7 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "cryptography", specifier = ">=44.0.0" },
|
||||||
{ name = "fastapi", specifier = ">=0.116.0" },
|
{ name = "fastapi", specifier = ">=0.116.0" },
|
||||||
{ name = "opentelemetry-sdk", specifier = ">=1.39.0" },
|
{ name = "opentelemetry-sdk", specifier = ">=1.39.0" },
|
||||||
{ name = "pgvector", specifier = ">=0.3.6" },
|
{ name = "pgvector", specifier = ">=0.3.6" },
|
||||||
|
|||||||
@@ -692,6 +692,166 @@ manualLinks = "Manual downloads: click the links and place the files into the te
|
|||||||
noLanguages = "No tessdata languages found in the configured directory."
|
noLanguages = "No tessdata languages found in the configured directory."
|
||||||
permissionNotice = "The tessdata path is not writable. Downloads will be opened in the browser; please save the .traineddata files manually into the tessdata folder."
|
permissionNotice = "The tessdata path is not writable. Downloads will be opened in the browser; please save the .traineddata files manually into the tessdata folder."
|
||||||
|
|
||||||
|
# AI engine admin settings (AI nav group)
|
||||||
|
[admin.settings.ai.documents]
|
||||||
|
description = "Configure the embedding model and retrieval settings used to answer questions over documents. Applied to the AI engine when saved."
|
||||||
|
title = "Documents & RAG"
|
||||||
|
|
||||||
|
[admin.settings.ai.documents.embeddingApiKey]
|
||||||
|
description = "Leave blank to use the engine's own environment credential. Applies to self-hosted single-engine deployments."
|
||||||
|
generic = "Embedding API key"
|
||||||
|
openai = "OpenAI API key"
|
||||||
|
setPlaceholder = "Saved - leave blank to keep the current key"
|
||||||
|
voyageai = "VoyageAI API key"
|
||||||
|
|
||||||
|
[admin.settings.ai.documents.embeddingBaseUrl]
|
||||||
|
description = "Base URL of the OpenAI-compatible / Ollama embeddings endpoint, e.g. http://ollama:11434/v1. Must point at a trusted internal endpoint (SSRF-sensitive)."
|
||||||
|
label = "Embedding base URL"
|
||||||
|
|
||||||
|
[admin.settings.ai.documents.embeddingModel]
|
||||||
|
description = "Embedding model name. Free text; suggestions are hints only."
|
||||||
|
label = "Embedding model"
|
||||||
|
|
||||||
|
[admin.settings.ai.documents.embeddingProvider]
|
||||||
|
description = "Provider used to turn document text into vector embeddings."
|
||||||
|
label = "Embedding provider"
|
||||||
|
|
||||||
|
[admin.settings.ai.documents.maxSearches]
|
||||||
|
description = "Maximum number of retrieval searches the agent may run per request."
|
||||||
|
label = "Max searches"
|
||||||
|
|
||||||
|
[admin.settings.ai.documents.reindexNote]
|
||||||
|
body = "Changing the embedding model takes effect immediately, but documents indexed with the previous model must be re-indexed for search to return correct results."
|
||||||
|
title = "Re-index required"
|
||||||
|
|
||||||
|
[admin.settings.ai.documents.saved]
|
||||||
|
reindexNote = "If you changed the embedding model, re-index existing documents so search uses the new model."
|
||||||
|
|
||||||
|
[admin.settings.ai.documents.topK]
|
||||||
|
description = "Number of most-relevant chunks retrieved per search."
|
||||||
|
label = "Top K"
|
||||||
|
|
||||||
|
[admin.settings.ai.general]
|
||||||
|
description = "Connect Stirling to the Python AI engine and choose which AI capabilities are exposed. Changes apply on restart."
|
||||||
|
title = "AI Engine"
|
||||||
|
|
||||||
|
[admin.settings.ai.general.capabilities]
|
||||||
|
description = "Turn individual AI features on or off. Disabled features are hidden in the app."
|
||||||
|
title = "Capabilities"
|
||||||
|
|
||||||
|
[admin.settings.ai.general.enabled]
|
||||||
|
description = "Master switch. When off, no AI tools, agents, or engine calls are available."
|
||||||
|
label = "Enable AI"
|
||||||
|
|
||||||
|
[admin.settings.ai.general.features.chat]
|
||||||
|
description = "Conversational assistant for working with PDFs."
|
||||||
|
label = "Chat assistant"
|
||||||
|
|
||||||
|
[admin.settings.ai.general.features.classify]
|
||||||
|
description = "Automatically categorise documents by type or content."
|
||||||
|
label = "Document classification"
|
||||||
|
|
||||||
|
[admin.settings.ai.general.features.createPdf]
|
||||||
|
description = "Generate a new PDF (e.g. from HTML) via an AI agent."
|
||||||
|
label = "Create PDF from prompt"
|
||||||
|
|
||||||
|
[admin.settings.ai.general.features.documentQuestions]
|
||||||
|
description = "Ask questions and get answers grounded in an uploaded document."
|
||||||
|
label = "Document questions"
|
||||||
|
|
||||||
|
[admin.settings.ai.general.features.mathAuditor]
|
||||||
|
description = "Review documents for mathematical and numerical errors."
|
||||||
|
label = "Math auditor"
|
||||||
|
|
||||||
|
[admin.settings.ai.general.features.pdfComment]
|
||||||
|
description = "Add AI-authored review comments and annotations to a PDF."
|
||||||
|
label = "PDF comment agent"
|
||||||
|
|
||||||
|
[admin.settings.ai.general.longRunningTimeoutSeconds]
|
||||||
|
description = "Timeout for heavier agent operations such as document generation."
|
||||||
|
label = "Long-running timeout (seconds)"
|
||||||
|
|
||||||
|
[admin.settings.ai.general.note]
|
||||||
|
body = "The AI engine runs as a separate service. Its shared secret"
|
||||||
|
body2 = "is set via a container environment variable. Provider API keys can be entered on these pages or supplied as engine environment variables; keys entered here are pushed to the engine when saved."
|
||||||
|
title = "About the AI engine"
|
||||||
|
|
||||||
|
[admin.settings.ai.general.streamTimeoutSeconds]
|
||||||
|
description = "Timeout for streamed (token-by-token) chat responses."
|
||||||
|
label = "Stream timeout (seconds)"
|
||||||
|
|
||||||
|
[admin.settings.ai.general.test]
|
||||||
|
button = "Test connection"
|
||||||
|
failBody = "The AI engine did not respond. Check the URL, that the engine container is running, and that AI is enabled (a restart is needed after enabling)."
|
||||||
|
failTitle = "AI engine unreachable"
|
||||||
|
okBody = "The AI engine responded to a health check."
|
||||||
|
okTitle = "AI engine reachable"
|
||||||
|
|
||||||
|
[admin.settings.ai.general.timeoutSeconds]
|
||||||
|
description = "Timeout for standard AI requests to the engine."
|
||||||
|
label = "Request timeout (seconds)"
|
||||||
|
|
||||||
|
[admin.settings.ai.general.url]
|
||||||
|
description = "Internal URL of the Python AI engine, e.g. http://stirling-pdf-engine:5001."
|
||||||
|
label = "AI engine URL"
|
||||||
|
|
||||||
|
[admin.settings.ai.limits]
|
||||||
|
description = "Guardrails for how much work AI requests may do and how many run concurrently. Applied to the AI engine when saved."
|
||||||
|
title = "Limits & Performance"
|
||||||
|
|
||||||
|
[admin.settings.ai.limits.maxCharacters]
|
||||||
|
description = "Guardrail: reject AI requests whose extracted text exceeds this length."
|
||||||
|
label = "Max characters per request"
|
||||||
|
|
||||||
|
[admin.settings.ai.limits.maxPages]
|
||||||
|
description = "Guardrail: reject AI requests over this many PDF pages."
|
||||||
|
label = "Max pages per request"
|
||||||
|
|
||||||
|
[admin.settings.ai.limits.modelMaxConcurrency]
|
||||||
|
description = "Maximum simultaneous in-flight model calls across the whole engine."
|
||||||
|
label = "Model max concurrency"
|
||||||
|
|
||||||
|
[admin.settings.ai.models]
|
||||||
|
description = "Choose the LLM provider and the smart/fast models the AI engine uses. Applied to the AI engine when saved."
|
||||||
|
title = "Models & Providers"
|
||||||
|
|
||||||
|
[admin.settings.ai.models.apiKey]
|
||||||
|
anthropic = "Anthropic API key"
|
||||||
|
description = "Leave blank to use the engine's own environment credential. Applies to self-hosted single-engine deployments."
|
||||||
|
generic = "API key"
|
||||||
|
openai = "OpenAI API key"
|
||||||
|
setPlaceholder = "Saved - leave blank to keep the current key"
|
||||||
|
|
||||||
|
[admin.settings.ai.models.baseUrl]
|
||||||
|
description = "Base URL of the OpenAI-compatible / Ollama endpoint, e.g. http://ollama:11434/v1."
|
||||||
|
label = "Provider base URL"
|
||||||
|
warning = "The base URL must point at a trusted internal endpoint. The engine will make server-side requests to it, so an untrusted value is SSRF-sensitive."
|
||||||
|
|
||||||
|
[admin.settings.ai.models.fastMaxTokens]
|
||||||
|
description = "Maximum output tokens for the fast model."
|
||||||
|
label = "Fast model max tokens"
|
||||||
|
|
||||||
|
[admin.settings.ai.models.fastModel]
|
||||||
|
description = "Cheaper, faster model for lightweight tasks. Free text; suggestions are hints only."
|
||||||
|
label = "Fast model"
|
||||||
|
|
||||||
|
[admin.settings.ai.models.provider]
|
||||||
|
description = "Which LLM provider the engine talks to."
|
||||||
|
label = "Provider"
|
||||||
|
|
||||||
|
[admin.settings.ai.models.smartMaxTokens]
|
||||||
|
description = "Maximum output tokens for the smart model."
|
||||||
|
label = "Smart model max tokens"
|
||||||
|
|
||||||
|
[admin.settings.ai.models.smartModel]
|
||||||
|
description = "High-capability model for complex reasoning. Free text; suggestions are hints only."
|
||||||
|
label = "Smart model"
|
||||||
|
|
||||||
|
[admin.settings.ai.saved]
|
||||||
|
body = "Changes are pushed to the AI engine automatically."
|
||||||
|
bodyNoPush = "Settings saved. They will apply the next time the AI engine picks up its configuration."
|
||||||
|
title = "AI settings saved"
|
||||||
|
|
||||||
[admin.settings.badge]
|
[admin.settings.badge]
|
||||||
clickToUpgrade = "Click to view plan details"
|
clickToUpgrade = "Click to view plan details"
|
||||||
|
|
||||||
@@ -8816,6 +8976,13 @@ title = "Signature Order"
|
|||||||
[settings]
|
[settings]
|
||||||
close = "Close"
|
close = "Close"
|
||||||
|
|
||||||
|
[settings.ai]
|
||||||
|
documents = "Documents & RAG"
|
||||||
|
general = "General"
|
||||||
|
limits = "Limits & Performance"
|
||||||
|
models = "Models & Providers"
|
||||||
|
title = "AI"
|
||||||
|
|
||||||
[settings.configuration]
|
[settings.configuration]
|
||||||
advanced = "Advanced"
|
advanced = "Advanced"
|
||||||
database = "Database"
|
database = "Database"
|
||||||
|
|||||||
@@ -475,6 +475,26 @@
|
|||||||
"title": "Admin Mcp Settings - Stirling PDF",
|
"title": "Admin Mcp Settings - Stirling PDF",
|
||||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||||
},
|
},
|
||||||
|
"/settings/adminAiGeneral": {
|
||||||
|
"image": "/og_images/home.png",
|
||||||
|
"title": "Admin Ai General Settings - Stirling PDF",
|
||||||
|
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||||
|
},
|
||||||
|
"/settings/adminAiModels": {
|
||||||
|
"image": "/og_images/home.png",
|
||||||
|
"title": "Admin Ai Models Settings - Stirling PDF",
|
||||||
|
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||||
|
},
|
||||||
|
"/settings/adminAiDocuments": {
|
||||||
|
"image": "/og_images/home.png",
|
||||||
|
"title": "Admin Ai Documents Settings - Stirling PDF",
|
||||||
|
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||||
|
},
|
||||||
|
"/settings/adminAiLimits": {
|
||||||
|
"image": "/og_images/home.png",
|
||||||
|
"title": "Admin Ai Limits Settings - Stirling PDF",
|
||||||
|
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||||
|
},
|
||||||
"/settings/help": {
|
"/settings/help": {
|
||||||
"image": "/og_images/home.png",
|
"image": "/og_images/home.png",
|
||||||
"title": "Help Settings - Stirling PDF",
|
"title": "Help Settings - Stirling PDF",
|
||||||
@@ -654,6 +674,10 @@
|
|||||||
"/settings/adminEndpoints": "/settings/adminEndpoints",
|
"/settings/adminEndpoints": "/settings/adminEndpoints",
|
||||||
"/settings/adminStorageSharing": "/settings/adminStorageSharing",
|
"/settings/adminStorageSharing": "/settings/adminStorageSharing",
|
||||||
"/settings/adminMcp": "/settings/adminMcp",
|
"/settings/adminMcp": "/settings/adminMcp",
|
||||||
|
"/settings/adminAiGeneral": "/settings/adminAiGeneral",
|
||||||
|
"/settings/adminAiModels": "/settings/adminAiModels",
|
||||||
|
"/settings/adminAiDocuments": "/settings/adminAiDocuments",
|
||||||
|
"/settings/adminAiLimits": "/settings/adminAiLimits",
|
||||||
"/settings/help": "/settings/help",
|
"/settings/help": "/settings/help",
|
||||||
"/settings/legal": "/settings/legal",
|
"/settings/legal": "/settings/legal",
|
||||||
"/settings/backendThirdPartyLicenses": "/settings/backendThirdPartyLicenses",
|
"/settings/backendThirdPartyLicenses": "/settings/backendThirdPartyLicenses",
|
||||||
|
|||||||
@@ -476,6 +476,26 @@
|
|||||||
"title": "Admin Mcp Settings - Stirling PDF",
|
"title": "Admin Mcp Settings - Stirling PDF",
|
||||||
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||||
},
|
},
|
||||||
|
"/settings/adminAiGeneral": {
|
||||||
|
"image": "/og_images/home.png",
|
||||||
|
"title": "Admin Ai General Settings - Stirling PDF",
|
||||||
|
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||||
|
},
|
||||||
|
"/settings/adminAiModels": {
|
||||||
|
"image": "/og_images/home.png",
|
||||||
|
"title": "Admin Ai Models Settings - Stirling PDF",
|
||||||
|
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||||
|
},
|
||||||
|
"/settings/adminAiDocuments": {
|
||||||
|
"image": "/og_images/home.png",
|
||||||
|
"title": "Admin Ai Documents Settings - Stirling PDF",
|
||||||
|
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||||
|
},
|
||||||
|
"/settings/adminAiLimits": {
|
||||||
|
"image": "/og_images/home.png",
|
||||||
|
"title": "Admin Ai Limits Settings - Stirling PDF",
|
||||||
|
"description": "The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||||
|
},
|
||||||
"/settings/help": {
|
"/settings/help": {
|
||||||
"image": "/og_images/home.png",
|
"image": "/og_images/home.png",
|
||||||
"title": "Help Settings - Stirling PDF",
|
"title": "Help Settings - Stirling PDF",
|
||||||
@@ -667,6 +687,10 @@
|
|||||||
"/settings/adminEndpoints": "/settings/adminEndpoints",
|
"/settings/adminEndpoints": "/settings/adminEndpoints",
|
||||||
"/settings/adminStorageSharing": "/settings/adminStorageSharing",
|
"/settings/adminStorageSharing": "/settings/adminStorageSharing",
|
||||||
"/settings/adminMcp": "/settings/adminMcp",
|
"/settings/adminMcp": "/settings/adminMcp",
|
||||||
|
"/settings/adminAiGeneral": "/settings/adminAiGeneral",
|
||||||
|
"/settings/adminAiModels": "/settings/adminAiModels",
|
||||||
|
"/settings/adminAiDocuments": "/settings/adminAiDocuments",
|
||||||
|
"/settings/adminAiLimits": "/settings/adminAiLimits",
|
||||||
"/settings/help": "/settings/help",
|
"/settings/help": "/settings/help",
|
||||||
"/settings/legal": "/settings/legal",
|
"/settings/legal": "/settings/legal",
|
||||||
"/settings/backendThirdPartyLicenses": "/settings/backendThirdPartyLicenses",
|
"/settings/backendThirdPartyLicenses": "/settings/backendThirdPartyLicenses",
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export const VALID_NAV_KEYS = [
|
|||||||
"adminEndpoints",
|
"adminEndpoints",
|
||||||
"adminStorageSharing",
|
"adminStorageSharing",
|
||||||
"adminMcp",
|
"adminMcp",
|
||||||
|
"adminAiGeneral",
|
||||||
|
"adminAiModels",
|
||||||
|
"adminAiDocuments",
|
||||||
|
"adminAiLimits",
|
||||||
"help",
|
"help",
|
||||||
"legal",
|
"legal",
|
||||||
"backendThirdPartyLicenses",
|
"backendThirdPartyLicenses",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import AdminPlanSection from "@app/components/shared/config/configSections/Admin
|
|||||||
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
|
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
|
||||||
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
|
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
|
||||||
import AdminMcpSection from "@app/components/shared/config/configSections/AdminMcpSection";
|
import AdminMcpSection from "@app/components/shared/config/configSections/AdminMcpSection";
|
||||||
|
import AdminAiGeneralSection from "@app/components/shared/config/configSections/AdminAiGeneralSection";
|
||||||
|
import AdminAiModelsSection from "@app/components/shared/config/configSections/AdminAiModelsSection";
|
||||||
|
import AdminAiDocumentsSection from "@app/components/shared/config/configSections/AdminAiDocumentsSection";
|
||||||
|
import AdminAiLimitsSection from "@app/components/shared/config/configSections/AdminAiLimitsSection";
|
||||||
import AdminAuditSection from "@app/components/shared/config/configSections/AdminAuditSection";
|
import AdminAuditSection from "@app/components/shared/config/configSections/AdminAuditSection";
|
||||||
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
|
import AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
|
||||||
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
|
import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection";
|
||||||
@@ -166,6 +170,45 @@ export const useConfigNavSections = (
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// AI
|
||||||
|
sections.push({
|
||||||
|
title: t("settings.ai.title", "AI"),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "adminAiGeneral",
|
||||||
|
label: t("settings.ai.general", "General"),
|
||||||
|
icon: "smart-toy-rounded",
|
||||||
|
component: <AdminAiGeneralSection />,
|
||||||
|
disabled: requiresLogin,
|
||||||
|
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "adminAiModels",
|
||||||
|
label: t("settings.ai.models", "Models & Providers"),
|
||||||
|
icon: "psychology",
|
||||||
|
component: <AdminAiModelsSection />,
|
||||||
|
disabled: requiresLogin,
|
||||||
|
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "adminAiDocuments",
|
||||||
|
label: t("settings.ai.documents", "Documents & RAG"),
|
||||||
|
icon: "description",
|
||||||
|
component: <AdminAiDocumentsSection />,
|
||||||
|
disabled: requiresLogin,
|
||||||
|
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "adminAiLimits",
|
||||||
|
label: t("settings.ai.limits", "Limits & Performance"),
|
||||||
|
icon: "speed",
|
||||||
|
component: <AdminAiLimitsSection />,
|
||||||
|
disabled: requiresLogin,
|
||||||
|
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
// Security & Authentication
|
// Security & Authentication
|
||||||
sections.push({
|
sections.push({
|
||||||
title: t("settings.securityAuth.title", "Security & Authentication"),
|
title: t("settings.securityAuth.title", "Security & Authentication"),
|
||||||
|
|||||||
+383
@@ -0,0 +1,383 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
NumberInput,
|
||||||
|
PasswordInput,
|
||||||
|
Autocomplete,
|
||||||
|
Select,
|
||||||
|
TextInput,
|
||||||
|
Stack,
|
||||||
|
Paper,
|
||||||
|
Text,
|
||||||
|
Loader,
|
||||||
|
Group,
|
||||||
|
Alert,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { alert } from "@app/components/toast";
|
||||||
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
|
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||||
|
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||||
|
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||||
|
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||||
|
import apiClient from "@app/services/apiClient";
|
||||||
|
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||||
|
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||||
|
import {
|
||||||
|
AiEngineSettingsData,
|
||||||
|
AiEngineRag,
|
||||||
|
AiEngineApiResponse,
|
||||||
|
EMBEDDING_MODEL_SUGGESTIONS,
|
||||||
|
MASKED_SECRET,
|
||||||
|
clampMin,
|
||||||
|
savedToastBody,
|
||||||
|
} from "@app/components/shared/config/configSections/aiEngineSettings";
|
||||||
|
|
||||||
|
export default function AdminAiDocumentsSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { loginEnabled } = useLoginRequired();
|
||||||
|
|
||||||
|
// Track edits to the masked secret so we never send "********" back.
|
||||||
|
const [embeddingApiKeyDirty, setEmbeddingApiKeyDirty] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
settings,
|
||||||
|
setSettings,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
fetchSettings,
|
||||||
|
saveSettings,
|
||||||
|
isFieldPending,
|
||||||
|
} = useAdminSettings<AiEngineSettingsData>({
|
||||||
|
sectionName: "aiEngine",
|
||||||
|
fetchTransformer: async (): Promise<
|
||||||
|
AiEngineSettingsData & { _pending?: Partial<AiEngineSettingsData> }
|
||||||
|
> => {
|
||||||
|
const response = await apiClient.get<AiEngineApiResponse>(
|
||||||
|
"/api/v1/admin/settings/section/aiEngine",
|
||||||
|
);
|
||||||
|
return response.data || {};
|
||||||
|
},
|
||||||
|
// Save ONLY this page's keys as dot-notation so sibling AI keys are preserved.
|
||||||
|
saveTransformer: (s: AiEngineSettingsData) => {
|
||||||
|
const prov = s.rag?.embeddingProvider || "voyageai";
|
||||||
|
const usesBaseUrl = prov === "ollama" || prov === "custom";
|
||||||
|
const usesApiKey = prov !== "ollama";
|
||||||
|
const deltaSettings: Record<string, unknown> = {
|
||||||
|
"aiEngine.rag.embeddingProvider": prov,
|
||||||
|
"aiEngine.rag.embeddingModel": s.rag?.embeddingModel ?? "",
|
||||||
|
// Never send a base URL for a provider that doesn't use one.
|
||||||
|
"aiEngine.rag.embeddingBaseUrl": usesBaseUrl
|
||||||
|
? (s.rag?.embeddingBaseUrl ?? "")
|
||||||
|
: "",
|
||||||
|
"aiEngine.rag.topK": clampMin(s.rag?.topK, 1),
|
||||||
|
"aiEngine.rag.maxSearches": clampMin(s.rag?.maxSearches, 0),
|
||||||
|
};
|
||||||
|
// Send the key when the user typed one, or a provider switch cleared it; the explicit
|
||||||
|
// "" wipes the previous provider's stored key.
|
||||||
|
if (embeddingApiKeyDirty) {
|
||||||
|
deltaSettings["aiEngine.rag.embeddingApiKey"] = usesApiKey
|
||||||
|
? (s.rag?.embeddingApiKey ?? "")
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
return { sectionData: {}, deltaSettings };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||||
|
settings,
|
||||||
|
loading,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await saveSettings();
|
||||||
|
setEmbeddingApiKeyDirty(false);
|
||||||
|
markSaved();
|
||||||
|
// Pushed to the engine live (the embedder is hot-swapped), but documents embedded with the
|
||||||
|
// previous model must be re-indexed, so the toast flags that caveat.
|
||||||
|
alert({
|
||||||
|
alertType: "success",
|
||||||
|
title: t("admin.settings.ai.saved.title", "AI settings saved"),
|
||||||
|
body: `${savedToastBody(settings, t)} ${t(
|
||||||
|
"admin.settings.ai.documents.saved.reindexNote",
|
||||||
|
"If you changed the embedding model, re-index existing documents so search uses the new model.",
|
||||||
|
)}`,
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
alert({
|
||||||
|
alertType: "error",
|
||||||
|
title: t("admin.error", "Error"),
|
||||||
|
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscard = useCallback(() => {
|
||||||
|
setEmbeddingApiKeyDirty(false);
|
||||||
|
setSettings(resetToSnapshot());
|
||||||
|
}, [resetToSnapshot, setSettings]);
|
||||||
|
|
||||||
|
const setRag = (patch: Partial<AiEngineRag>) =>
|
||||||
|
setSettings({ ...settings, rag: { ...(settings.rag || {}), ...patch } });
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" justify="center" h={200}>
|
||||||
|
<Loader size="lg" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const embeddingProvider = settings.rag?.embeddingProvider || "voyageai";
|
||||||
|
const embeddingSuggestions =
|
||||||
|
EMBEDDING_MODEL_SUGGESTIONS[embeddingProvider] || [];
|
||||||
|
const showEmbeddingBaseUrl =
|
||||||
|
embeddingProvider === "ollama" || embeddingProvider === "custom";
|
||||||
|
// Ollama's embeddings endpoint needs no API key, mirroring the Models page.
|
||||||
|
const showEmbeddingApiKey = embeddingProvider !== "ollama";
|
||||||
|
const embeddingApiKeyLabel =
|
||||||
|
embeddingProvider === "voyageai"
|
||||||
|
? t(
|
||||||
|
"admin.settings.ai.documents.embeddingApiKey.voyageai",
|
||||||
|
"VoyageAI API key",
|
||||||
|
)
|
||||||
|
: embeddingProvider === "openai"
|
||||||
|
? t(
|
||||||
|
"admin.settings.ai.documents.embeddingApiKey.openai",
|
||||||
|
"OpenAI API key",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"admin.settings.ai.documents.embeddingApiKey.generic",
|
||||||
|
"Embedding API key",
|
||||||
|
);
|
||||||
|
const embeddingApiKeyPlaceholder =
|
||||||
|
embeddingProvider === "voyageai"
|
||||||
|
? "pa-..."
|
||||||
|
: embeddingProvider === "openai"
|
||||||
|
? "sk-..."
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section-container">
|
||||||
|
<Stack gap="lg" className="settings-section-content">
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="lg">
|
||||||
|
{t("admin.settings.ai.documents.title", "Documents & RAG")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.documents.description",
|
||||||
|
"Configure the embedding model and retrieval settings used to answer questions over documents. Applied to the AI engine when saved.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.documents.embeddingProvider.label",
|
||||||
|
"Embedding provider",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<PendingBadge
|
||||||
|
show={isFieldPending("rag.embeddingProvider")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.documents.embeddingProvider.description",
|
||||||
|
"Provider used to turn document text into vector embeddings.",
|
||||||
|
)}
|
||||||
|
data={[
|
||||||
|
{ value: "voyageai", label: "VoyageAI" },
|
||||||
|
{ value: "openai", label: "OpenAI" },
|
||||||
|
{ value: "ollama", label: "Ollama" },
|
||||||
|
{ value: "custom", label: "Custom (OpenAI-compatible)" },
|
||||||
|
]}
|
||||||
|
value={embeddingProvider}
|
||||||
|
onChange={(v) => {
|
||||||
|
const next = v || "voyageai";
|
||||||
|
const patch: Partial<AiEngineRag> = { embeddingProvider: next };
|
||||||
|
// Clear fields the new provider doesn't use so a stale hidden value can't
|
||||||
|
// leak into the payload.
|
||||||
|
if (next !== "ollama" && next !== "custom")
|
||||||
|
patch.embeddingBaseUrl = "";
|
||||||
|
if (next !== embeddingProvider) {
|
||||||
|
// One stored key, issued for one provider: carrying it across a switch
|
||||||
|
// would 401 while the field still read "Saved". Require a re-entry.
|
||||||
|
patch.embeddingApiKey = "";
|
||||||
|
setEmbeddingApiKeyDirty(true);
|
||||||
|
}
|
||||||
|
setRag(patch);
|
||||||
|
}}
|
||||||
|
allowDeselect={false}
|
||||||
|
comboboxProps={{
|
||||||
|
withinPortal: true,
|
||||||
|
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Autocomplete
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.documents.embeddingModel.label",
|
||||||
|
"Embedding model",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<PendingBadge show={isFieldPending("rag.embeddingModel")} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.documents.embeddingModel.description",
|
||||||
|
"Embedding model name. Free text; suggestions are hints only.",
|
||||||
|
)}
|
||||||
|
data={embeddingSuggestions}
|
||||||
|
value={settings.rag?.embeddingModel || ""}
|
||||||
|
onChange={(value) => setRag({ embeddingModel: value })}
|
||||||
|
comboboxProps={{
|
||||||
|
withinPortal: true,
|
||||||
|
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showEmbeddingApiKey && (
|
||||||
|
<PasswordInput
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>{embeddingApiKeyLabel}</span>
|
||||||
|
<PendingBadge
|
||||||
|
show={isFieldPending("rag.embeddingApiKey")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.documents.embeddingApiKey.description",
|
||||||
|
"Leave blank to use the engine's own environment credential. Applies to self-hosted single-engine deployments.",
|
||||||
|
)}
|
||||||
|
// Blank when a key is already stored (returned masked as "********") so
|
||||||
|
// appending to the sentinel can't corrupt the saved key.
|
||||||
|
value={
|
||||||
|
embeddingApiKeyDirty
|
||||||
|
? (settings.rag?.embeddingApiKey ?? "")
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEmbeddingApiKeyDirty(true);
|
||||||
|
setRag({ embeddingApiKey: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
!embeddingApiKeyDirty &&
|
||||||
|
settings.rag?.embeddingApiKey === MASKED_SECRET
|
||||||
|
? t(
|
||||||
|
"admin.settings.ai.documents.embeddingApiKey.setPlaceholder",
|
||||||
|
"Saved - leave blank to keep the current key",
|
||||||
|
)
|
||||||
|
: embeddingApiKeyPlaceholder
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEmbeddingBaseUrl && (
|
||||||
|
<TextInput
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.documents.embeddingBaseUrl.label",
|
||||||
|
"Embedding base URL",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<PendingBadge
|
||||||
|
show={isFieldPending("rag.embeddingBaseUrl")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.documents.embeddingBaseUrl.description",
|
||||||
|
"Base URL of the OpenAI-compatible / Ollama embeddings endpoint, e.g. http://ollama:11434/v1. Must point at a trusted internal endpoint (SSRF-sensitive).",
|
||||||
|
)}
|
||||||
|
value={settings.rag?.embeddingBaseUrl || ""}
|
||||||
|
onChange={(e) => setRag({ embeddingBaseUrl: e.target.value })}
|
||||||
|
placeholder="http://ollama:11434/v1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t("admin.settings.ai.documents.topK.label", "Top K")}
|
||||||
|
</span>
|
||||||
|
<PendingBadge show={isFieldPending("rag.topK")} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.documents.topK.description",
|
||||||
|
"Number of most-relevant chunks retrieved per search.",
|
||||||
|
)}
|
||||||
|
value={settings.rag?.topK ?? 0}
|
||||||
|
onChange={(value) => setRag({ topK: Number(value) })}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.documents.maxSearches.label",
|
||||||
|
"Max searches",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<PendingBadge show={isFieldPending("rag.maxSearches")} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.documents.maxSearches.description",
|
||||||
|
"Maximum number of retrieval searches the agent may run per request.",
|
||||||
|
)}
|
||||||
|
value={settings.rag?.maxSearches ?? 0}
|
||||||
|
onChange={(value) => setRag({ maxSearches: Number(value) })}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
title={t(
|
||||||
|
"admin.settings.ai.documents.reindexNote.title",
|
||||||
|
"Re-index required",
|
||||||
|
)}
|
||||||
|
icon={<LocalIcon icon="warning-rounded" width="1rem" height="1rem" />}
|
||||||
|
>
|
||||||
|
<Text size="xs">
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.documents.reindexNote.body",
|
||||||
|
"Changing the embedding model takes effect immediately, but documents indexed with the previous model must be re-indexed for search to return correct results.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<SettingsStickyFooter
|
||||||
|
isDirty={isDirty}
|
||||||
|
saving={saving}
|
||||||
|
loginEnabled={loginEnabled}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+511
@@ -0,0 +1,511 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
TextInput,
|
||||||
|
NumberInput,
|
||||||
|
Switch,
|
||||||
|
Stack,
|
||||||
|
Paper,
|
||||||
|
Text,
|
||||||
|
Loader,
|
||||||
|
Group,
|
||||||
|
Alert,
|
||||||
|
Code,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { alert } from "@app/components/toast";
|
||||||
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
|
import RestartConfirmationModal from "@app/components/shared/config/RestartConfirmationModal";
|
||||||
|
import { useRestartServer } from "@app/components/shared/config/useRestartServer";
|
||||||
|
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||||
|
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||||
|
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||||
|
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||||
|
import apiClient from "@app/services/apiClient";
|
||||||
|
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||||
|
import { Button } from "@app/ui/Button";
|
||||||
|
import {
|
||||||
|
AiEngineSettingsData,
|
||||||
|
AiEngineFeatures,
|
||||||
|
AiEngineApiResponse,
|
||||||
|
clampMin,
|
||||||
|
} from "@app/components/shared/config/configSections/aiEngineSettings";
|
||||||
|
|
||||||
|
export default function AdminAiGeneralSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { loginEnabled } = useLoginRequired();
|
||||||
|
const {
|
||||||
|
restartModalOpened,
|
||||||
|
showRestartModal,
|
||||||
|
closeRestartModal,
|
||||||
|
restartServer,
|
||||||
|
} = useRestartServer();
|
||||||
|
|
||||||
|
const {
|
||||||
|
settings,
|
||||||
|
setSettings,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
fetchSettings,
|
||||||
|
saveSettings,
|
||||||
|
isFieldPending,
|
||||||
|
} = useAdminSettings<AiEngineSettingsData>({
|
||||||
|
sectionName: "aiEngine",
|
||||||
|
fetchTransformer: async (): Promise<
|
||||||
|
AiEngineSettingsData & { _pending?: Partial<AiEngineSettingsData> }
|
||||||
|
> => {
|
||||||
|
const response = await apiClient.get<AiEngineApiResponse>(
|
||||||
|
"/api/v1/admin/settings/section/aiEngine",
|
||||||
|
);
|
||||||
|
return response.data || {};
|
||||||
|
},
|
||||||
|
// Save ONLY this page's keys as dot-notation so sibling AI keys are preserved.
|
||||||
|
saveTransformer: (s: AiEngineSettingsData) => ({
|
||||||
|
sectionData: {},
|
||||||
|
deltaSettings: {
|
||||||
|
"aiEngine.enabled": s.enabled ?? false,
|
||||||
|
"aiEngine.url": s.url ?? "",
|
||||||
|
// Timeouts must be >= 1s; a 0 would make every engine call fail/deadlock.
|
||||||
|
"aiEngine.timeoutSeconds": clampMin(s.timeoutSeconds, 1),
|
||||||
|
"aiEngine.longRunningTimeoutSeconds": clampMin(
|
||||||
|
s.longRunningTimeoutSeconds,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
"aiEngine.streamTimeoutSeconds": clampMin(s.streamTimeoutSeconds, 1),
|
||||||
|
"aiEngine.features.chat": s.features?.chat ?? false,
|
||||||
|
"aiEngine.features.documentQuestions":
|
||||||
|
s.features?.documentQuestions ?? false,
|
||||||
|
"aiEngine.features.createPdf": s.features?.createPdf ?? false,
|
||||||
|
"aiEngine.features.mathAuditor": s.features?.mathAuditor ?? false,
|
||||||
|
"aiEngine.features.pdfComment": s.features?.pdfComment ?? false,
|
||||||
|
"aiEngine.features.classify": s.features?.classify ?? false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||||
|
settings,
|
||||||
|
loading,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await saveSettings();
|
||||||
|
markSaved();
|
||||||
|
showRestartModal();
|
||||||
|
} catch (_error) {
|
||||||
|
alert({
|
||||||
|
alertType: "error",
|
||||||
|
title: t("admin.error", "Error"),
|
||||||
|
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscard = useCallback(() => {
|
||||||
|
setSettings(resetToSnapshot());
|
||||||
|
}, [resetToSnapshot, setSettings]);
|
||||||
|
|
||||||
|
const [testingConnection, setTestingConnection] = useState(false);
|
||||||
|
|
||||||
|
// Probe the RUNNING configuration (the Java bean), not unsaved form values -
|
||||||
|
// after enabling AI or changing the URL, save + restart first, then test.
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
setTestingConnection(true);
|
||||||
|
try {
|
||||||
|
await apiClient.get("/api/v1/ai/health");
|
||||||
|
alert({
|
||||||
|
alertType: "success",
|
||||||
|
title: t(
|
||||||
|
"admin.settings.ai.general.test.okTitle",
|
||||||
|
"AI engine reachable",
|
||||||
|
),
|
||||||
|
body: t(
|
||||||
|
"admin.settings.ai.general.test.okBody",
|
||||||
|
"The AI engine responded to a health check.",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const detail =
|
||||||
|
(error as { response?: { data?: { message?: string } } })?.response
|
||||||
|
?.data?.message ||
|
||||||
|
t(
|
||||||
|
"admin.settings.ai.general.test.failBody",
|
||||||
|
"The AI engine did not respond. Check the URL, that the engine container is running, and that AI is enabled (a restart is needed after enabling).",
|
||||||
|
);
|
||||||
|
alert({
|
||||||
|
alertType: "error",
|
||||||
|
title: t(
|
||||||
|
"admin.settings.ai.general.test.failTitle",
|
||||||
|
"AI engine unreachable",
|
||||||
|
),
|
||||||
|
body: detail,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTestingConnection(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setFeatures = (patch: Partial<AiEngineFeatures>) =>
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
features: { ...(settings.features || {}), ...patch },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" justify="center" h={200}>
|
||||||
|
<Loader size="lg" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = settings.enabled || false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section-container">
|
||||||
|
<Stack gap="lg" className="settings-section-content">
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="lg">
|
||||||
|
{t("admin.settings.ai.general.title", "AI Engine")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.general.description",
|
||||||
|
"Connect Stirling to the Python AI engine and choose which AI capabilities are exposed. Changes apply on restart.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{t("admin.settings.ai.general.enabled.label", "Enable AI")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.general.enabled.description",
|
||||||
|
"Master switch. When off, no AI tools, agents, or engine calls are available.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Switch
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings({ ...settings, enabled: e.target.checked })
|
||||||
|
}
|
||||||
|
aria-label={t(
|
||||||
|
"admin.settings.ai.general.enabled.label",
|
||||||
|
"Enable AI",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PendingBadge show={isFieldPending("enabled")} />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t("admin.settings.ai.general.url.label", "AI engine URL")}
|
||||||
|
</span>
|
||||||
|
<PendingBadge show={isFieldPending("url")} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.general.url.description",
|
||||||
|
"Internal URL of the Python AI engine, e.g. http://stirling-pdf-engine:5001.",
|
||||||
|
)}
|
||||||
|
value={settings.url || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings({ ...settings, url: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="http://stirling-pdf-engine:5001"
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
loading={testingConnection}
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
>
|
||||||
|
{t("admin.settings.ai.general.test.button", "Test connection")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.general.timeoutSeconds.label",
|
||||||
|
"Request timeout (seconds)",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<PendingBadge show={isFieldPending("timeoutSeconds")} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.general.timeoutSeconds.description",
|
||||||
|
"Timeout for standard AI requests to the engine.",
|
||||||
|
)}
|
||||||
|
value={settings.timeoutSeconds ?? 0}
|
||||||
|
onChange={(value) =>
|
||||||
|
setSettings({ ...settings, timeoutSeconds: Number(value) })
|
||||||
|
}
|
||||||
|
min={1}
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.general.longRunningTimeoutSeconds.label",
|
||||||
|
"Long-running timeout (seconds)",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<PendingBadge
|
||||||
|
show={isFieldPending("longRunningTimeoutSeconds")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.general.longRunningTimeoutSeconds.description",
|
||||||
|
"Timeout for heavier agent operations such as document generation.",
|
||||||
|
)}
|
||||||
|
value={settings.longRunningTimeoutSeconds ?? 0}
|
||||||
|
onChange={(value) =>
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
longRunningTimeoutSeconds: Number(value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
min={1}
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.general.streamTimeoutSeconds.label",
|
||||||
|
"Stream timeout (seconds)",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<PendingBadge show={isFieldPending("streamTimeoutSeconds")} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.general.streamTimeoutSeconds.description",
|
||||||
|
"Timeout for streamed (token-by-token) chat responses.",
|
||||||
|
)}
|
||||||
|
value={settings.streamTimeoutSeconds ?? 0}
|
||||||
|
onChange={(value) =>
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
streamTimeoutSeconds: Number(value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
min={1}
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.general.capabilities.title",
|
||||||
|
"Capabilities",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.general.capabilities.description",
|
||||||
|
"Turn individual AI features on or off. Disabled features are hidden in the app.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FeatureSwitch
|
||||||
|
label={t(
|
||||||
|
"admin.settings.ai.general.features.chat.label",
|
||||||
|
"Chat assistant",
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.general.features.chat.description",
|
||||||
|
"Conversational assistant for working with PDFs.",
|
||||||
|
)}
|
||||||
|
checked={settings.features?.chat ?? false}
|
||||||
|
onChange={(checked) => setFeatures({ chat: checked })}
|
||||||
|
pending={isFieldPending("features.chat")}
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
<FeatureSwitch
|
||||||
|
label={t(
|
||||||
|
"admin.settings.ai.general.features.documentQuestions.label",
|
||||||
|
"Document questions",
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.general.features.documentQuestions.description",
|
||||||
|
"Ask questions and get answers grounded in an uploaded document.",
|
||||||
|
)}
|
||||||
|
checked={settings.features?.documentQuestions ?? false}
|
||||||
|
onChange={(checked) =>
|
||||||
|
setFeatures({ documentQuestions: checked })
|
||||||
|
}
|
||||||
|
pending={isFieldPending("features.documentQuestions")}
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
<FeatureSwitch
|
||||||
|
label={t(
|
||||||
|
"admin.settings.ai.general.features.createPdf.label",
|
||||||
|
"Create PDF from prompt",
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.general.features.createPdf.description",
|
||||||
|
"Generate a new PDF (e.g. from HTML) via an AI agent.",
|
||||||
|
)}
|
||||||
|
checked={settings.features?.createPdf ?? false}
|
||||||
|
onChange={(checked) => setFeatures({ createPdf: checked })}
|
||||||
|
pending={isFieldPending("features.createPdf")}
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
<FeatureSwitch
|
||||||
|
label={t(
|
||||||
|
"admin.settings.ai.general.features.mathAuditor.label",
|
||||||
|
"Math auditor",
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.general.features.mathAuditor.description",
|
||||||
|
"Review documents for mathematical and numerical errors.",
|
||||||
|
)}
|
||||||
|
checked={settings.features?.mathAuditor ?? false}
|
||||||
|
onChange={(checked) => setFeatures({ mathAuditor: checked })}
|
||||||
|
pending={isFieldPending("features.mathAuditor")}
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
<FeatureSwitch
|
||||||
|
label={t(
|
||||||
|
"admin.settings.ai.general.features.pdfComment.label",
|
||||||
|
"PDF comment agent",
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.general.features.pdfComment.description",
|
||||||
|
"Add AI-authored review comments and annotations to a PDF.",
|
||||||
|
)}
|
||||||
|
checked={settings.features?.pdfComment ?? false}
|
||||||
|
onChange={(checked) => setFeatures({ pdfComment: checked })}
|
||||||
|
pending={isFieldPending("features.pdfComment")}
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
<FeatureSwitch
|
||||||
|
label={t(
|
||||||
|
"admin.settings.ai.general.features.classify.label",
|
||||||
|
"Document classification",
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.general.features.classify.description",
|
||||||
|
"Automatically categorise documents by type or content.",
|
||||||
|
)}
|
||||||
|
checked={settings.features?.classify ?? false}
|
||||||
|
onChange={(checked) => setFeatures({ classify: checked })}
|
||||||
|
pending={isFieldPending("features.classify")}
|
||||||
|
disabled={!enabled}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
title={t(
|
||||||
|
"admin.settings.ai.general.note.title",
|
||||||
|
"About the AI engine",
|
||||||
|
)}
|
||||||
|
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
|
||||||
|
>
|
||||||
|
<Text size="xs">
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.general.note.body",
|
||||||
|
"The AI engine runs as a separate service. Its shared secret",
|
||||||
|
)}{" "}
|
||||||
|
<Code>STIRLING_ENGINE_SHARED_SECRET</Code>{" "}
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.general.note.body2",
|
||||||
|
"is set via a container environment variable. Provider API keys can be entered on these pages or supplied as engine environment variables; keys entered here are pushed to the engine when saved.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<SettingsStickyFooter
|
||||||
|
isDirty={isDirty}
|
||||||
|
saving={saving}
|
||||||
|
loginEnabled={loginEnabled}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RestartConfirmationModal
|
||||||
|
opened={restartModalOpened}
|
||||||
|
onClose={closeRestartModal}
|
||||||
|
onRestart={restartServer}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeatureSwitchProps {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
pending: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeatureSwitch({
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
pending,
|
||||||
|
disabled,
|
||||||
|
}: FeatureSwitchProps) {
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
|
<div>
|
||||||
|
<Text fw={500} size="sm">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Switch
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
// The visible label is a sibling Text, so the control needs its own name.
|
||||||
|
aria-label={label}
|
||||||
|
/>
|
||||||
|
<PendingBadge show={pending} />
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
+198
@@ -0,0 +1,198 @@
|
|||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { NumberInput, Stack, Paper, Text, Loader, Group } from "@mantine/core";
|
||||||
|
import { alert } from "@app/components/toast";
|
||||||
|
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||||
|
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||||
|
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||||
|
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||||
|
import apiClient from "@app/services/apiClient";
|
||||||
|
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||||
|
import {
|
||||||
|
AiEngineSettingsData,
|
||||||
|
AiEngineLimits,
|
||||||
|
AiEngineApiResponse,
|
||||||
|
clampMin,
|
||||||
|
savedToastBody,
|
||||||
|
} from "@app/components/shared/config/configSections/aiEngineSettings";
|
||||||
|
|
||||||
|
export default function AdminAiLimitsSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { loginEnabled } = useLoginRequired();
|
||||||
|
|
||||||
|
const {
|
||||||
|
settings,
|
||||||
|
setSettings,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
fetchSettings,
|
||||||
|
saveSettings,
|
||||||
|
isFieldPending,
|
||||||
|
} = useAdminSettings<AiEngineSettingsData>({
|
||||||
|
sectionName: "aiEngine",
|
||||||
|
fetchTransformer: async (): Promise<
|
||||||
|
AiEngineSettingsData & { _pending?: Partial<AiEngineSettingsData> }
|
||||||
|
> => {
|
||||||
|
const response = await apiClient.get<AiEngineApiResponse>(
|
||||||
|
"/api/v1/admin/settings/section/aiEngine",
|
||||||
|
);
|
||||||
|
return response.data || {};
|
||||||
|
},
|
||||||
|
// Save ONLY this page's keys as dot-notation so sibling AI keys are preserved.
|
||||||
|
saveTransformer: (s: AiEngineSettingsData) => ({
|
||||||
|
sectionData: {},
|
||||||
|
deltaSettings: {
|
||||||
|
// All must be >= 1; a 0 page/char cap or 0 concurrency breaks or deadlocks the engine.
|
||||||
|
"aiEngine.limits.maxPages": clampMin(s.limits?.maxPages, 1),
|
||||||
|
"aiEngine.limits.maxCharacters": clampMin(s.limits?.maxCharacters, 1),
|
||||||
|
"aiEngine.limits.modelMaxConcurrency": clampMin(
|
||||||
|
s.limits?.modelMaxConcurrency,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||||
|
settings,
|
||||||
|
loading,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await saveSettings();
|
||||||
|
markSaved();
|
||||||
|
// Engine-facing values are pushed to the AI engine live on save; no restart needed.
|
||||||
|
alert({
|
||||||
|
alertType: "success",
|
||||||
|
title: t("admin.settings.ai.saved.title", "AI settings saved"),
|
||||||
|
body: savedToastBody(settings, t),
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
alert({
|
||||||
|
alertType: "error",
|
||||||
|
title: t("admin.error", "Error"),
|
||||||
|
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscard = useCallback(() => {
|
||||||
|
setSettings(resetToSnapshot());
|
||||||
|
}, [resetToSnapshot, setSettings]);
|
||||||
|
|
||||||
|
const setLimits = (patch: Partial<AiEngineLimits>) =>
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
limits: { ...(settings.limits || {}), ...patch },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" justify="center" h={200}>
|
||||||
|
<Loader size="lg" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section-container">
|
||||||
|
<Stack gap="lg" className="settings-section-content">
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="lg">
|
||||||
|
{t("admin.settings.ai.limits.title", "Limits & Performance")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.limits.description",
|
||||||
|
"Guardrails for how much work AI requests may do and how many run concurrently. Applied to the AI engine when saved.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<NumberInput
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.limits.maxPages.label",
|
||||||
|
"Max pages per request",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<PendingBadge show={isFieldPending("limits.maxPages")} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.limits.maxPages.description",
|
||||||
|
"Guardrail: reject AI requests over this many PDF pages.",
|
||||||
|
)}
|
||||||
|
value={settings.limits?.maxPages ?? 0}
|
||||||
|
onChange={(value) => setLimits({ maxPages: Number(value) })}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.limits.maxCharacters.label",
|
||||||
|
"Max characters per request",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<PendingBadge show={isFieldPending("limits.maxCharacters")} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.limits.maxCharacters.description",
|
||||||
|
"Guardrail: reject AI requests whose extracted text exceeds this length.",
|
||||||
|
)}
|
||||||
|
value={settings.limits?.maxCharacters ?? 0}
|
||||||
|
onChange={(value) => setLimits({ maxCharacters: Number(value) })}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.limits.modelMaxConcurrency.label",
|
||||||
|
"Model max concurrency",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<PendingBadge
|
||||||
|
show={isFieldPending("limits.modelMaxConcurrency")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.limits.modelMaxConcurrency.description",
|
||||||
|
"Maximum simultaneous in-flight model calls across the whole engine.",
|
||||||
|
)}
|
||||||
|
value={settings.limits?.modelMaxConcurrency ?? 0}
|
||||||
|
onChange={(value) =>
|
||||||
|
setLimits({ modelMaxConcurrency: Number(value) })
|
||||||
|
}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<SettingsStickyFooter
|
||||||
|
isDirty={isDirty}
|
||||||
|
saving={saving}
|
||||||
|
loginEnabled={loginEnabled}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+391
@@ -0,0 +1,391 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
TextInput,
|
||||||
|
NumberInput,
|
||||||
|
PasswordInput,
|
||||||
|
Autocomplete,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Paper,
|
||||||
|
Text,
|
||||||
|
Loader,
|
||||||
|
Group,
|
||||||
|
Alert,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { alert } from "@app/components/toast";
|
||||||
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
|
import { useAdminSettings } from "@app/hooks/useAdminSettings";
|
||||||
|
import { useSettingsDirty } from "@app/hooks/useSettingsDirty";
|
||||||
|
import PendingBadge from "@app/components/shared/config/PendingBadge";
|
||||||
|
import { SettingsStickyFooter } from "@app/components/shared/config/SettingsStickyFooter";
|
||||||
|
import apiClient from "@app/services/apiClient";
|
||||||
|
import { useLoginRequired } from "@app/hooks/useLoginRequired";
|
||||||
|
import { Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||||
|
import {
|
||||||
|
AiEngineSettingsData,
|
||||||
|
AiEngineModels,
|
||||||
|
AiEngineApiResponse,
|
||||||
|
MODEL_SUGGESTIONS,
|
||||||
|
MASKED_SECRET,
|
||||||
|
clampMin,
|
||||||
|
savedToastBody,
|
||||||
|
} from "@app/components/shared/config/configSections/aiEngineSettings";
|
||||||
|
|
||||||
|
export default function AdminAiModelsSection() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { loginEnabled } = useLoginRequired();
|
||||||
|
|
||||||
|
// Track whether the user actually edited the masked secret. If not, we omit
|
||||||
|
// it from the delta entirely so we never send "********" back to the server.
|
||||||
|
const [apiKeyDirty, setApiKeyDirty] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
settings,
|
||||||
|
setSettings,
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
fetchSettings,
|
||||||
|
saveSettings,
|
||||||
|
isFieldPending,
|
||||||
|
} = useAdminSettings<AiEngineSettingsData>({
|
||||||
|
sectionName: "aiEngine",
|
||||||
|
fetchTransformer: async (): Promise<
|
||||||
|
AiEngineSettingsData & { _pending?: Partial<AiEngineSettingsData> }
|
||||||
|
> => {
|
||||||
|
const response = await apiClient.get<AiEngineApiResponse>(
|
||||||
|
"/api/v1/admin/settings/section/aiEngine",
|
||||||
|
);
|
||||||
|
return response.data || {};
|
||||||
|
},
|
||||||
|
// Save ONLY this page's keys as dot-notation so sibling AI keys are preserved.
|
||||||
|
saveTransformer: (s: AiEngineSettingsData) => {
|
||||||
|
const prov = s.models?.provider || "anthropic";
|
||||||
|
const usesBaseUrl = prov === "ollama" || prov === "custom";
|
||||||
|
const usesApiKey = prov !== "ollama";
|
||||||
|
const deltaSettings: Record<string, unknown> = {
|
||||||
|
"aiEngine.models.provider": prov,
|
||||||
|
"aiEngine.models.smartModel": s.models?.smartModel ?? "",
|
||||||
|
"aiEngine.models.fastModel": s.models?.fastModel ?? "",
|
||||||
|
"aiEngine.models.smartMaxTokens": clampMin(s.models?.smartMaxTokens, 1),
|
||||||
|
"aiEngine.models.fastMaxTokens": clampMin(s.models?.fastMaxTokens, 1),
|
||||||
|
// Never send a base URL for a provider that doesn't use one (avoids leaking a
|
||||||
|
// stale value left over from a previous Ollama/Custom selection).
|
||||||
|
"aiEngine.models.baseUrl": usesBaseUrl ? (s.models?.baseUrl ?? "") : "",
|
||||||
|
};
|
||||||
|
// Send the key when the user typed one or a provider switch cleared it; the explicit
|
||||||
|
// "" wipes the old provider's stored key (and is forced for providers with no key field).
|
||||||
|
if (apiKeyDirty) {
|
||||||
|
deltaSettings["aiEngine.models.apiKey"] = usesApiKey
|
||||||
|
? (s.models?.apiKey ?? "")
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
return { sectionData: {}, deltaSettings };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||||
|
settings,
|
||||||
|
loading,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
await saveSettings();
|
||||||
|
setApiKeyDirty(false);
|
||||||
|
markSaved();
|
||||||
|
// Engine-facing values are pushed to the AI engine live on save; no restart needed.
|
||||||
|
alert({
|
||||||
|
alertType: "success",
|
||||||
|
title: t("admin.settings.ai.saved.title", "AI settings saved"),
|
||||||
|
body: savedToastBody(settings, t),
|
||||||
|
});
|
||||||
|
} catch (_error) {
|
||||||
|
alert({
|
||||||
|
alertType: "error",
|
||||||
|
title: t("admin.error", "Error"),
|
||||||
|
body: t("admin.settings.saveError", "Failed to save settings"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscard = useCallback(() => {
|
||||||
|
setApiKeyDirty(false);
|
||||||
|
setSettings(resetToSnapshot());
|
||||||
|
}, [resetToSnapshot, setSettings]);
|
||||||
|
|
||||||
|
const setModels = (patch: Partial<AiEngineModels>) =>
|
||||||
|
setSettings({
|
||||||
|
...settings,
|
||||||
|
models: { ...(settings.models || {}), ...patch },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" justify="center" h={200}>
|
||||||
|
<Loader size="lg" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = settings.models?.provider || "anthropic";
|
||||||
|
const showApiKey = provider !== "ollama";
|
||||||
|
const showBaseUrl = provider === "ollama" || provider === "custom";
|
||||||
|
const modelSuggestions = MODEL_SUGGESTIONS[provider] || [];
|
||||||
|
|
||||||
|
const apiKeyLabel =
|
||||||
|
provider === "anthropic"
|
||||||
|
? t("admin.settings.ai.models.apiKey.anthropic", "Anthropic API key")
|
||||||
|
: provider === "openai"
|
||||||
|
? t("admin.settings.ai.models.apiKey.openai", "OpenAI API key")
|
||||||
|
: t("admin.settings.ai.models.apiKey.generic", "API key");
|
||||||
|
const apiKeyPlaceholder =
|
||||||
|
provider === "anthropic"
|
||||||
|
? "sk-ant-..."
|
||||||
|
: provider === "openai"
|
||||||
|
? "sk-..."
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="settings-section-container">
|
||||||
|
<Stack gap="lg" className="settings-section-content">
|
||||||
|
<div>
|
||||||
|
<Text fw={600} size="lg">
|
||||||
|
{t("admin.settings.ai.models.title", "Models & Providers")}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.models.description",
|
||||||
|
"Choose the LLM provider and the smart/fast models the AI engine uses. Applied to the AI engine when saved.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Paper withBorder p="md" radius="md">
|
||||||
|
<Stack gap="md">
|
||||||
|
<Select
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t("admin.settings.ai.models.provider.label", "Provider")}
|
||||||
|
</span>
|
||||||
|
<PendingBadge show={isFieldPending("models.provider")} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.models.provider.description",
|
||||||
|
"Which LLM provider the engine talks to.",
|
||||||
|
)}
|
||||||
|
data={[
|
||||||
|
{ value: "anthropic", label: "Anthropic" },
|
||||||
|
{ value: "openai", label: "OpenAI" },
|
||||||
|
{ value: "ollama", label: "Ollama" },
|
||||||
|
{ value: "custom", label: "Custom (OpenAI-compatible)" },
|
||||||
|
]}
|
||||||
|
value={provider}
|
||||||
|
onChange={(v) => {
|
||||||
|
const next = v || "anthropic";
|
||||||
|
const patch: Partial<AiEngineModels> = { provider: next };
|
||||||
|
// Clear fields the new provider doesn't use so a stale hidden value can't
|
||||||
|
// leak into the payload (e.g. an Ollama base URL after switching to Anthropic).
|
||||||
|
if (next !== "ollama" && next !== "custom") patch.baseUrl = "";
|
||||||
|
if (next !== provider) {
|
||||||
|
// Only one key is stored and it belongs to its provider; carrying it across a
|
||||||
|
// switch would 401 every call while the field still read "Saved", so clear it.
|
||||||
|
patch.apiKey = "";
|
||||||
|
setApiKeyDirty(true);
|
||||||
|
}
|
||||||
|
setModels(patch);
|
||||||
|
}}
|
||||||
|
allowDeselect={false}
|
||||||
|
comboboxProps={{
|
||||||
|
withinPortal: true,
|
||||||
|
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Autocomplete
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.models.smartModel.label",
|
||||||
|
"Smart model",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<PendingBadge show={isFieldPending("models.smartModel")} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.models.smartModel.description",
|
||||||
|
"High-capability model for complex reasoning. Free text; suggestions are hints only.",
|
||||||
|
)}
|
||||||
|
data={modelSuggestions}
|
||||||
|
value={settings.models?.smartModel || ""}
|
||||||
|
onChange={(value) => setModels({ smartModel: value })}
|
||||||
|
comboboxProps={{
|
||||||
|
withinPortal: true,
|
||||||
|
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Autocomplete
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.models.fastModel.label",
|
||||||
|
"Fast model",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<PendingBadge show={isFieldPending("models.fastModel")} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.models.fastModel.description",
|
||||||
|
"Cheaper, faster model for lightweight tasks. Free text; suggestions are hints only.",
|
||||||
|
)}
|
||||||
|
data={modelSuggestions}
|
||||||
|
value={settings.models?.fastModel || ""}
|
||||||
|
onChange={(value) => setModels({ fastModel: value })}
|
||||||
|
comboboxProps={{
|
||||||
|
withinPortal: true,
|
||||||
|
zIndex: Z_INDEX_OVER_CONFIG_MODAL,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.models.smartMaxTokens.label",
|
||||||
|
"Smart model max tokens",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<PendingBadge
|
||||||
|
show={isFieldPending("models.smartMaxTokens")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.models.smartMaxTokens.description",
|
||||||
|
"Maximum output tokens for the smart model.",
|
||||||
|
)}
|
||||||
|
value={settings.models?.smartMaxTokens ?? 0}
|
||||||
|
onChange={(value) => setModels({ smartMaxTokens: Number(value) })}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberInput
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.models.fastMaxTokens.label",
|
||||||
|
"Fast model max tokens",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<PendingBadge show={isFieldPending("models.fastMaxTokens")} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.models.fastMaxTokens.description",
|
||||||
|
"Maximum output tokens for the fast model.",
|
||||||
|
)}
|
||||||
|
value={settings.models?.fastMaxTokens ?? 0}
|
||||||
|
onChange={(value) => setModels({ fastMaxTokens: Number(value) })}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showApiKey && (
|
||||||
|
<PasswordInput
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>{apiKeyLabel}</span>
|
||||||
|
<PendingBadge show={isFieldPending("models.apiKey")} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.models.apiKey.description",
|
||||||
|
"Leave blank to use the engine's own environment credential. Applies to self-hosted single-engine deployments.",
|
||||||
|
)}
|
||||||
|
// Keep the field blank when a key is already stored (returned masked as "********");
|
||||||
|
// a pre-filled sentinel would corrupt the key on append, so bind the real value only once typed.
|
||||||
|
value={apiKeyDirty ? (settings.models?.apiKey ?? "") : ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
setApiKeyDirty(true);
|
||||||
|
setModels({ apiKey: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
!apiKeyDirty && settings.models?.apiKey === MASKED_SECRET
|
||||||
|
? t(
|
||||||
|
"admin.settings.ai.models.apiKey.setPlaceholder",
|
||||||
|
"Saved - leave blank to keep the current key",
|
||||||
|
)
|
||||||
|
: apiKeyPlaceholder
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showBaseUrl && (
|
||||||
|
<TextInput
|
||||||
|
label={
|
||||||
|
<Group gap="xs">
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.models.baseUrl.label",
|
||||||
|
"Provider base URL",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<PendingBadge show={isFieldPending("models.baseUrl")} />
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={t(
|
||||||
|
"admin.settings.ai.models.baseUrl.description",
|
||||||
|
"Base URL of the OpenAI-compatible / Ollama endpoint, e.g. http://ollama:11434/v1.",
|
||||||
|
)}
|
||||||
|
value={settings.models?.baseUrl || ""}
|
||||||
|
onChange={(e) => setModels({ baseUrl: e.target.value })}
|
||||||
|
placeholder="http://ollama:11434/v1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showBaseUrl && (
|
||||||
|
<Alert
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
icon={
|
||||||
|
<LocalIcon
|
||||||
|
icon="warning-rounded"
|
||||||
|
width="1rem"
|
||||||
|
height="1rem"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="xs">
|
||||||
|
{t(
|
||||||
|
"admin.settings.ai.models.baseUrl.warning",
|
||||||
|
"The base URL must point at a trusted internal endpoint. The engine will make server-side requests to it, so an untrusted value is SSRF-sensitive.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<SettingsStickyFooter
|
||||||
|
isDirty={isDirty}
|
||||||
|
saving={saving}
|
||||||
|
loginEnabled={loginEnabled}
|
||||||
|
onSave={handleSave}
|
||||||
|
onDiscard={handleDiscard}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+57
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
clampMin,
|
||||||
|
savedToastBody,
|
||||||
|
} from "@app/components/shared/config/configSections/aiEngineSettings";
|
||||||
|
|
||||||
|
describe("clampMin", () => {
|
||||||
|
it("keeps a valid integer unchanged", () => {
|
||||||
|
expect(clampMin(8192, 1)).toBe(8192);
|
||||||
|
expect(clampMin(200, 1)).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors below the minimum for empty / zero / NaN / junk input", () => {
|
||||||
|
// A cleared NumberInput yields "" -> 0; a transient "-" -> NaN; both must clamp to min.
|
||||||
|
expect(clampMin("", 1)).toBe(1);
|
||||||
|
expect(clampMin(0, 1)).toBe(1);
|
||||||
|
expect(clampMin(Number.NaN, 1)).toBe(1);
|
||||||
|
expect(clampMin(undefined, 1)).toBe(1);
|
||||||
|
expect(clampMin("-", 1)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("floors fractional values to an integer", () => {
|
||||||
|
expect(clampMin(5.7, 1)).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows zero when the minimum is zero (e.g. maxSearches)", () => {
|
||||||
|
expect(clampMin(0, 0)).toBe(0);
|
||||||
|
expect(clampMin("", 0)).toBe(0);
|
||||||
|
expect(clampMin(4, 0)).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("savedToastBody", () => {
|
||||||
|
// The helper is passed i18next's t(); echo the key back so assertions read clearly.
|
||||||
|
const t = (key: string) => key;
|
||||||
|
|
||||||
|
it("promises a live push only when AI is on and config push is enabled", () => {
|
||||||
|
expect(savedToastBody({ enabled: true, pushConfigToEngine: true }, t)).toBe(
|
||||||
|
"admin.settings.ai.saved.body",
|
||||||
|
);
|
||||||
|
// pushConfigToEngine defaults to true on the backend, so an absent flag still promises it.
|
||||||
|
expect(savedToastBody({ enabled: true }, t)).toBe(
|
||||||
|
"admin.settings.ai.saved.body",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not promise a push the processor will not make", () => {
|
||||||
|
// AI off: pushLiveAfterSave returns early, so nothing reaches the engine.
|
||||||
|
expect(savedToastBody({ enabled: false }, t)).toBe(
|
||||||
|
"admin.settings.ai.saved.bodyNoPush",
|
||||||
|
);
|
||||||
|
// Env-driven deployment (SaaS pins this false): the engine owns its own config.
|
||||||
|
expect(
|
||||||
|
savedToastBody({ enabled: true, pushConfigToEngine: false }, t),
|
||||||
|
).toBe("admin.settings.ai.saved.bodyNoPush");
|
||||||
|
});
|
||||||
|
});
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
// Shared types + constants for the aiEngine admin settings section.
|
||||||
|
// All four sub-pages share one GET /section/aiEngine payload; each saves only its own keys so siblings survive.
|
||||||
|
|
||||||
|
/** Secret fields come back masked as this literal when a value is set. */
|
||||||
|
export const MASKED_SECRET = "********";
|
||||||
|
|
||||||
|
/** Clamp a numeric setting to a safe integer at submit; a persisted 0 timeout/concurrency would deadlock the engine (Mantine's min only guards on blur). */
|
||||||
|
export const clampMin = (value: unknown, min: number): number =>
|
||||||
|
Math.max(min, Math.floor(Number(value) || min));
|
||||||
|
|
||||||
|
export interface AiEngineModels {
|
||||||
|
provider?: string;
|
||||||
|
smartModel?: string;
|
||||||
|
fastModel?: string;
|
||||||
|
smartMaxTokens?: number;
|
||||||
|
fastMaxTokens?: number;
|
||||||
|
apiKey?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiEngineRag {
|
||||||
|
embeddingProvider?: string;
|
||||||
|
embeddingModel?: string;
|
||||||
|
embeddingApiKey?: string;
|
||||||
|
embeddingBaseUrl?: string;
|
||||||
|
topK?: number;
|
||||||
|
maxSearches?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiEngineLimits {
|
||||||
|
maxPages?: number;
|
||||||
|
maxCharacters?: number;
|
||||||
|
modelMaxConcurrency?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiEngineFeatures {
|
||||||
|
chat?: boolean;
|
||||||
|
documentQuestions?: boolean;
|
||||||
|
createPdf?: boolean;
|
||||||
|
mathAuditor?: boolean;
|
||||||
|
pdfComment?: boolean;
|
||||||
|
classify?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiEngineSettingsData {
|
||||||
|
enabled?: boolean;
|
||||||
|
url?: string;
|
||||||
|
/**
|
||||||
|
* Whether the processor forwards settings to the engine; SaaS pins it false, so a save
|
||||||
|
* persists but never reaches the engine (the toast must say so, not promise a live push).
|
||||||
|
*/
|
||||||
|
pushConfigToEngine?: boolean;
|
||||||
|
timeoutSeconds?: number;
|
||||||
|
longRunningTimeoutSeconds?: number;
|
||||||
|
streamTimeoutSeconds?: number;
|
||||||
|
models?: AiEngineModels;
|
||||||
|
rag?: AiEngineRag;
|
||||||
|
limits?: AiEngineLimits;
|
||||||
|
features?: AiEngineFeatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-save toast body; the processor pushes only when AI is enabled AND config push is on,
|
||||||
|
* so the message must not promise a live push unconditionally.
|
||||||
|
*/
|
||||||
|
export const savedToastBody = (
|
||||||
|
settings: AiEngineSettingsData,
|
||||||
|
t: (key: string, fallback: string) => string,
|
||||||
|
): string =>
|
||||||
|
settings.enabled && settings.pushConfigToEngine !== false
|
||||||
|
? t(
|
||||||
|
"admin.settings.ai.saved.body",
|
||||||
|
"Changes are pushed to the AI engine automatically.",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"admin.settings.ai.saved.bodyNoPush",
|
||||||
|
"Settings saved. They will apply the next time the AI engine picks up its configuration.",
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ApiResponseWithPending<T> {
|
||||||
|
_pending?: Partial<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AiEngineApiResponse = AiEngineSettingsData &
|
||||||
|
ApiResponseWithPending<AiEngineSettingsData>;
|
||||||
|
|
||||||
|
/** Free-text model suggestions per provider (hints only). */
|
||||||
|
export const MODEL_SUGGESTIONS: Record<string, string[]> = {
|
||||||
|
anthropic: ["claude-haiku-4-5", "claude-sonnet-5", "claude-opus-4-8"],
|
||||||
|
openai: ["gpt-4o", "gpt-4o-mini", "o3-mini"],
|
||||||
|
ollama: ["llama3.1", "qwen2.5", "mistral"],
|
||||||
|
custom: ["llama3.1", "qwen2.5", "mistral"],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Free-text embedding-model suggestions per embedding provider. */
|
||||||
|
export const EMBEDDING_MODEL_SUGGESTIONS: Record<string, string[]> = {
|
||||||
|
voyageai: ["voyage-4", "voyage-3.5"],
|
||||||
|
openai: ["text-embedding-3-small", "text-embedding-3-large"],
|
||||||
|
ollama: ["nomic-embed-text", "mxbai-embed-large", "bge-m3"],
|
||||||
|
custom: ["nomic-embed-text", "mxbai-embed-large", "bge-m3"],
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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<typeof createSaasConfigNavSections>;
|
||||||
|
|
||||||
|
function itemKeys(sections: Sections): string[] {
|
||||||
|
return sections.flatMap((s) => s.items.map((i) => i.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin AI settings pages exist only in the self-hosted proprietary flavor; this locks in that
|
||||||
|
// the AI group can never leak into the SaaS nav (fails loudly if wired into the SaaS cascade).
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user