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:
Anthony Stirling
2026-07-23 08:29:06 +00:00
committed by GitHub
parent b3875d3149
commit 8de94ff152
51 changed files with 4568 additions and 95 deletions
@@ -305,6 +305,102 @@ public class ApplicationProperties {
* explicitly requests it via {@code AiEngineClient.postWithTimeout}.
*/
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;
}
}
/**
@@ -336,7 +336,19 @@ public class ConfigController {
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
// 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
ApplicationProperties.Security.Timestamp tsConfig =
@@ -366,6 +366,35 @@ aiEngine:
enabled: false # Set to 'true' to enable the AI engine integration
url: http://localhost:5001 # URL of the Python AI engine
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:
# Folder automations can read from and write to the directories you allow here, so treat this as a
@@ -385,6 +414,8 @@ policies:
mcp:
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
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.
blockedOperations: [] # Tool deny-list (operation ids). Always removed from MCP even if otherwise allowed.
auth:
@@ -7,7 +7,6 @@ import java.util.concurrent.Executor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -28,6 +27,7 @@ import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.job.ResultFile;
import stirling.software.common.service.JobOwnershipService;
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.service.AiEngineClient;
import stirling.software.proprietary.service.AiEngineEndpointResolver;
import stirling.software.proprietary.service.AiFeatureGate;
import stirling.software.proprietary.service.AiWorkflowService;
import tools.jackson.core.JacksonException;
@@ -60,15 +61,14 @@ public class AiEngineController {
private final TaskManager taskManager;
private final JobOwnershipService jobOwnershipService;
private final AiEngineEndpointResolver endpointResolver;
private final AiFeatureGate aiFeatureGate;
private final UserServiceInterface userService;
/**
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
* 1000-page scan, splitting a huge PDF, etc.) without the emitter completing out from under the
* executor. Configurable via {@code stirling.ai.streamTimeoutMs}.
* SSE emitter timeout (ms), long enough for multi-gigabyte PDF workflows without completing out
* from under the executor. Derived from {@code aiEngine.streamTimeoutSeconds}.
*/
@Value("${stirling.ai.streamTimeoutMs:1800000}")
private long streamTimeoutMs;
private final long streamTimeoutMs;
public AiEngineController(
AiEngineClient aiEngineClient,
@@ -78,6 +78,8 @@ public class AiEngineController {
TaskManager taskManager,
JobOwnershipService jobOwnershipService,
AiEngineEndpointResolver endpointResolver,
AiFeatureGate aiFeatureGate,
ApplicationProperties applicationProperties,
@Autowired(required = false) UserServiceInterface userService) {
this.aiEngineClient = aiEngineClient;
this.aiWorkflowService = aiWorkflowService;
@@ -86,7 +88,10 @@ public class AiEngineController {
this.taskManager = taskManager;
this.jobOwnershipService = jobOwnershipService;
this.endpointResolver = endpointResolver;
this.aiFeatureGate = aiFeatureGate;
this.userService = userService;
this.streamTimeoutMs =
applicationProperties.getAiEngine().getStreamTimeoutSeconds() * 1000L;
}
private String currentUserId() {
@@ -111,6 +116,7 @@ public class AiEngineController {
+ " system and downloadable via GET /api/v1/general/files/{fileId}.")
public AiWorkflowResponse orchestrate(@Valid @ModelAttribute AiWorkflowRequest request)
throws IOException {
aiFeatureGate.requireConversationalWorkflow();
AiWorkflowResponse result = aiWorkflowService.orchestrate(request);
registerFileResultAsJob(result);
return result;
@@ -123,6 +129,7 @@ public class AiEngineController {
"Accepts a PDF upload and a user message, returns SSE events with progress"
+ " updates followed by the final AI workflow result")
public SseEmitter orchestrateStream(@Valid @ModelAttribute AiWorkflowRequest request) {
aiFeatureGate.requireConversationalWorkflow();
SseEmitter emitter = new SseEmitter(streamTimeoutMs);
emitter.onTimeout(
@@ -246,6 +253,8 @@ public class AiEngineController {
"Sends a user message to the PDF edit agent which returns a structured plan"
+ " of tool operations to perform")
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);
if (!parsed.isObject()) {
throw new ResponseStatusException(
@@ -35,6 +35,7 @@ import stirling.software.proprietary.classification.ClassificationLabelProvider;
import stirling.software.proprietary.classification.model.ClassificationLabel;
import stirling.software.proprietary.model.api.ai.AiPageText;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.AiFeatureGate;
import stirling.software.proprietary.service.PdfContentExtractor;
import tools.jackson.databind.JsonNode;
@@ -67,6 +68,7 @@ public class ClassifyLabelController {
private final PdfContentExtractor pdfContentExtractor;
private final PdfMetadataService pdfMetadataService;
private final AiEngineClient aiEngineClient;
private final AiFeatureGate aiFeatureGate;
private final ObjectMapper objectMapper;
private final UserServiceInterface userService;
@@ -81,6 +83,7 @@ public class ClassifyLabelController {
PdfContentExtractor pdfContentExtractor,
PdfMetadataService pdfMetadataService,
AiEngineClient aiEngineClient,
AiFeatureGate aiFeatureGate,
ObjectMapper objectMapper,
ClassificationLabelProvider labelProvider,
@Autowired(required = false) UserServiceInterface userService) {
@@ -89,6 +92,7 @@ public class ClassifyLabelController {
this.pdfContentExtractor = pdfContentExtractor;
this.pdfMetadataService = pdfMetadataService;
this.aiEngineClient = aiEngineClient;
this.aiFeatureGate = aiFeatureGate;
this.objectMapper = objectMapper;
this.labelProvider = labelProvider;
this.userService = userService;
@@ -104,6 +108,7 @@ public class ClassifyLabelController {
+ " intended for direct client use.")
public ResponseEntity<Resource> classifyAndLabel(
@RequestParam("fileInput") MultipartFile fileInput) throws IOException {
aiFeatureGate.requireClassify();
try (PDDocument document = pdfDocumentFactory.load(fileInput, true)) {
String fileName = safeFileName(fileInput.getOriginalFilename());
@@ -34,6 +34,7 @@ import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.model.api.ai.create.AiDocument;
import stirling.software.proprietary.service.AiDocumentHtmlRenderer;
import stirling.software.proprietary.service.AiFeatureGate;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
@@ -59,6 +60,7 @@ public class CreatePdfAgentController {
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private final AiDocumentHtmlRenderer htmlRenderer;
private final AiFeatureGate aiFeatureGate;
/**
* Returns true only when WeasyPrint is definitively unavailable — either the binary could not
@@ -93,10 +95,10 @@ public class CreatePdfAgentController {
public ResponseEntity<Resource> createPdf(
@RequestParam("document") String document, @RequestParam("filename") String filename)
throws Exception {
if (!applicationProperties.getAiEngine().isEnabled()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
aiFeatureGate.requireCreatePdf();
AiDocument model;
try {
@@ -21,6 +21,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.MathAuditorOrchestrator;
@@ -49,6 +50,7 @@ public class MathAuditorAgentController {
private static final Pattern NEWLINE_PATTERN = Pattern.compile("[\\r\\n]");
private final MathAuditorOrchestrator orchestrator;
private final AiFeatureGate aiFeatureGate;
@PostMapping(value = "/math-auditor-agent", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
@@ -79,6 +81,7 @@ public class MathAuditorAgentController {
+ " ignored (default: 0.01)")
@RequestParam(value = "tolerance", defaultValue = "0.01")
BigDecimal tolerance) {
aiFeatureGate.requireMathAuditor();
AiToolInputValidator.validatePdfUpload(fileInput);
if (tolerance.compareTo(BigDecimal.ZERO) < 0) {
@@ -21,6 +21,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.service.AiFeatureGate;
import stirling.software.proprietary.service.AiToolResponseHeaders;
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
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 final PdfCommentAgentOrchestrator orchestrator;
private final ObjectMapper objectMapper;
private final AiFeatureGate aiFeatureGate;
@PostMapping(
value = "/pdf-comment-agent",
@@ -79,6 +81,7 @@ public class PdfCommentAgentController {
@RequestParam("prompt")
String prompt)
throws IOException {
aiFeatureGate.requirePdfComment();
String originalFilename = fileInput.getOriginalFilename();
String safeName =
@@ -8,6 +8,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.UpdateSettingValueRequest;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
import stirling.software.proprietary.service.AiEngineConfigSync;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
@@ -58,6 +60,7 @@ public class AdminSettingsController {
private final ApplicationProperties applicationProperties;
private final ObjectMapper objectMapper;
private final ApplicationContext applicationContext;
private final AiEngineConfigSync aiEngineConfigSync;
// Track settings that have been modified but not yet applied (require restart)
private static final ConcurrentHashMap<String, Object> pendingChanges =
@@ -172,6 +175,26 @@ public class AdminSettingsController {
.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
for (Map.Entry<String, Object> entry : settings.entrySet()) {
String key = entry.getKey();
@@ -188,6 +211,9 @@ public class AdminSettingsController {
// Validate pipeline path settings
String validationError = validatePipelinePathSetting(key, value);
if (validationError == null) {
validationError = validateAiEngineNumericSetting(key, value);
}
if (validationError != null) {
return ResponseEntity.badRequest()
.body(Map.of("error", HtmlUtils.htmlEscape(validationError)));
@@ -202,10 +228,13 @@ public class AdminSettingsController {
for (Map.Entry<String, Object> entry : settings.entrySet()) {
String key = entry.getKey();
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 : "");
}
// Push changed AI settings live so model/RAG/limit changes skip the restart.
maybePushAiEngineLive(settings);
return ResponseEntity.ok(
Map.of(
"message",
@@ -350,7 +379,10 @@ public class AdminSettingsController {
+ 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);
// 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);
// 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) {
if (sectionName == null || sectionName.trim().isEmpty()) {
return null;
@@ -684,6 +737,38 @@ public class AdminSettingsController {
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) {
// Validate pipeline path settings
if (key.startsWith("system.customPaths.pipeline.watchedFoldersDirs")
@@ -828,6 +913,15 @@ public class AdminSettingsController {
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 */
private boolean isSensitiveFieldWithPath(String fieldName, String fullPath) {
String lowerField = fieldName.toLowerCase();
@@ -843,10 +937,12 @@ public class AdminSettingsController {
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")
|| lowerField.contains("secret")
|| lowerField.contains("bottoken");
|| lowerField.contains("apikey")
|| lowerField.endsWith("token");
}
/** Create a masked representation for sensitive fields */
@@ -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);
}
}
@@ -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");
}
}
@@ -29,6 +29,7 @@ import stirling.software.common.util.TempFileManager;
import stirling.software.proprietary.classification.ClassificationLabelProvider;
import stirling.software.proprietary.classification.model.ClassificationLabel;
import stirling.software.proprietary.service.AiEngineClient;
import stirling.software.proprietary.service.AiFeatureGate;
import stirling.software.proprietary.service.PdfContentExtractor;
import tools.jackson.databind.JsonNode;
@@ -44,6 +45,7 @@ class ClassifyLabelControllerTest {
@Mock private PdfContentExtractor pdfContentExtractor;
@Mock private PdfMetadataService pdfMetadataService;
@Mock private AiEngineClient aiEngineClient;
@Mock private AiFeatureGate aiFeatureGate;
private final ObjectMapper objectMapper = JsonMapper.builder().build();
private ClassifyLabelController controller;
@@ -56,6 +58,7 @@ class ClassifyLabelControllerTest {
pdfContentExtractor,
pdfMetadataService,
aiEngineClient,
aiFeatureGate,
objectMapper,
ClassificationLabelProvider.withLabels(labels),
null);
@@ -3,6 +3,7 @@ package stirling.software.proprietary.controller.api;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
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.support.DefaultHandlerExceptionResolver;
import stirling.software.proprietary.service.AiFeatureGate;
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator;
import stirling.software.proprietary.service.PdfCommentAgentOrchestrator.AnnotatedPdf;
@@ -39,13 +41,15 @@ import tools.jackson.databind.json.JsonMapper;
class PdfCommentAgentControllerTest {
@Mock private PdfCommentAgentOrchestrator orchestrator;
@Mock private AiFeatureGate aiFeatureGate;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
PdfCommentAgentController controller =
new PdfCommentAgentController(orchestrator, JsonMapper.builder().build());
new PdfCommentAgentController(
orchestrator, JsonMapper.builder().build(), aiFeatureGate);
mockMvc =
MockMvcBuilders.standaloneSetup(controller)
// standaloneSetup's defaults don't handle ResponseStatusException; wire up
@@ -117,6 +121,27 @@ class PdfCommentAgentControllerTest {
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
void rejectsMissingPromptParameter() throws Exception {
MockMultipartFile pdfFile =
@@ -1,10 +1,13 @@
package stirling.software.proprietary.security.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import java.io.IOException;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -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.UpdateSettingValueRequest;
import stirling.software.proprietary.security.model.api.admin.UpdateSettingsRequest;
import stirling.software.proprietary.service.AiEngineConfigSync;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
@@ -37,6 +41,7 @@ class AdminSettingsControllerTest {
private ApplicationProperties applicationProperties;
private ObjectMapper objectMapper;
private ApplicationContext applicationContext;
private AiEngineConfigSync aiEngineConfigSync;
private AdminSettingsController controller;
@@ -45,9 +50,13 @@ class AdminSettingsControllerTest {
applicationProperties = new ApplicationProperties();
objectMapper = JsonMapper.builder().build();
applicationContext = org.mockito.Mockito.mock(ApplicationContext.class);
aiEngineConfigSync = org.mockito.Mockito.mock(AiEngineConfigSync.class);
controller =
new AdminSettingsController(
applicationProperties, objectMapper, applicationContext);
applicationProperties,
objectMapper,
applicationContext,
aiEngineConfigSync);
clearPendingChanges();
}
@@ -189,6 +198,33 @@ class AdminSettingsControllerTest {
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
@DisplayName("rejects unknown section prefix with 400")
void rejectsUnknownSection() {
@@ -277,6 +313,51 @@ class AdminSettingsControllerTest {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
}
@Test
@DisplayName("drops a masked ******** secret so a UI round-trip can't overwrite a real key")
void dropsMaskedSecretValue() {
UpdateSettingsRequest request = new UpdateSettingsRequest();
Map<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
@@ -430,6 +511,25 @@ class AdminSettingsControllerTest {
SettingValueResponse body = (SettingValueResponse) response.getBody();
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
@@ -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;
}
}
@@ -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.
# 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 ----------
system.datasource.enableCustomDatabase=true
system.datasource.customDatabaseUrl=${SAAS_DB_URL:}
+4
View File
@@ -88,3 +88,7 @@ STIRLING_LOG_FILE=
# Use when diagnosing worker stalls: a hung call shows a "Request" line with
# no matching "Response" line. Noisy; leave off in normal use.
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
+1
View File
@@ -4,6 +4,7 @@ version = "0.1.0"
description = "AI Document Engine"
requires-python = ">=3.13"
dependencies = [
"cryptography>=44.0.0",
"fastapi>=0.116.0",
"pgvector>=0.3.6",
"psycopg[binary,pool]>=3.2",
@@ -15,6 +15,7 @@ from __future__ import annotations
from pydantic import Field
from pydantic_ai import Agent
from stirling.agents.output_mode import output_retries
from stirling.contracts import (
MathAuditorToolReportArtifact,
OrchestratorRequest,
@@ -68,6 +69,9 @@ class MathIntentClassifier:
self._agent: Agent[None, _MathIntentDecision] = Agent(
model=runtime.fast_model,
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,
model_settings=runtime.fast_model_settings,
)
+77 -2
View File
@@ -2,12 +2,14 @@ from __future__ import annotations
import logging
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.output import ToolOutput
from pydantic_ai.output import NativeOutput, ToolOutput
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_edit import PdfEditAgent
from stirling.agents.pdf_questions import PdfQuestionAgent
@@ -27,6 +29,7 @@ from stirling.contracts import (
format_file_names,
)
from stirling.contracts.pdf_create import PdfCreateOrchestrateResponse
from stirling.models import ApiModel
from stirling.services import AppRuntime
logger = logging.getLogger(__name__)
@@ -38,6 +41,34 @@ class OrchestratorDeps:
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:
def __init__(self, runtime: AppRuntime) -> None:
self.runtime = runtime
@@ -86,6 +117,8 @@ class OrchestratorAgent:
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,
system_prompt=(
"You are the top-level orchestrator. "
@@ -103,6 +136,21 @@ class OrchestratorAgent:
),
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:
logger.info(
@@ -114,6 +162,8 @@ class OrchestratorAgent:
)
if request.resume_with is not None:
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(
self._build_prompt(request),
deps=OrchestratorDeps(runtime=self.runtime, request=request),
@@ -121,6 +171,31 @@ class OrchestratorAgent:
logger.info("[orchestrator] routed -> %s", type(result.output).__name__)
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:
"""Fast-path to get back to the correct endpoint without having to call AI.
+28
View File
@@ -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
+8 -2
View File
@@ -3,10 +3,10 @@ from __future__ import annotations
import logging
from pydantic_ai import Agent
from pydantic_ai.output import NativeOutput
from stirling.agents.contradiction import ContradictionCapability, ContradictionDetector
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.contracts import (
AiFile,
@@ -196,9 +196,15 @@ class PdfQuestionAgent:
files=request.files,
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(
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,
# pydantic-ai accepts a list of (string-or-callable) instruction sources;
# it resolves each at run time and concatenates them for the model.
+135 -47
View File
@@ -3,28 +3,20 @@ from __future__ import annotations
import asyncio
import logging
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.models import Model
from pydantic_ai.models.instrumented import InstrumentationSettings
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.api.bootstrap import apply_app_state, build_app_state
from stirling.api.dependencies import enforce_required_user_id
from stirling.api.engine_auth import EngineSharedSecretMiddleware
from stirling.api.middleware import UserIdMiddleware
from stirling.api.routes import (
agent_capabilities_router,
agent_draft_router,
config_router,
document_classifier_router,
document_router,
execution_router,
@@ -34,38 +26,42 @@ from stirling.api.routes import (
pdf_edit_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.config_cache import cache_stamp, load_config
from stirling.contracts import HealthResponse
from stirling.documents import DocumentService
from stirling.services import build_runtime, setup_posthog_tracking
from stirling.documents import DocumentService, EmbeddingService
from stirling.services import setup_posthog_tracking
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(
documents: DocumentService,
interval_seconds: int,
stop: asyncio.Event,
) -> None:
"""Periodically delete documents whose ``expires_at`` has passed.
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.
"""
"""Backstop purge of documents past ``expires_at``; persistent (null expires_at) rows are never touched."""
await _reap(documents)
while True:
await asyncio.sleep(interval_seconds)
while not await _sleep_until(stop, interval_seconds):
await _reap(documents)
async def _reap(documents: DocumentService) -> None:
"""One reaper iteration. Logs the deleted count on success and the full
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.
"""
"""One reaper iteration; swallows non-cancel errors so a bad iteration doesn't kill the loop."""
try:
deleted = await documents.reap_expired()
if deleted:
@@ -76,6 +72,45 @@ async def _reap(documents: DocumentService) -> None:
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:
override = fast_api.dependency_overrides.get(load_settings)
if override is not None:
@@ -83,37 +118,84 @@ def _load_startup_settings(fast_api: FastAPI) -> AppSettings:
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
async def lifespan(fast_api: FastAPI):
# Load env vars on startup so we can immediately crash if required env vars aren't set
settings = _load_startup_settings(fast_api)
runtime = build_runtime(settings)
fast_api.state.settings = settings
fast_api.state.runtime = runtime
fast_api.state.orchestrator_agent = OrchestratorAgent(runtime)
fast_api.state.pdf_edit_agent = PdfEditAgent(runtime)
fast_api.state.pdf_question_agent = PdfQuestionAgent(runtime)
fast_api.state.user_spec_agent = UserSpecAgent(runtime)
fast_api.state.execution_planning_agent = ExecutionPlanningAgent(runtime)
fast_api.state.math_auditor_agent = MathAuditorAgent(runtime)
fast_api.state.pdf_comment_agent = PdfCommentAgent(runtime)
fast_api.state.document_classifier_agent = DocumentClassifierAgent(runtime)
tracer_provider = setup_posthog_tracking(settings)
# Precedence: env < persisted cache < live push. Stamp first so a push landing mid-boot
# is re-adopted by the watcher rather than mistaken for the config we just restored.
fast_api.state.config_cache_stamp = cache_stamp()
effective, smart_model, fast_model, embedder = _restore_cached_config(settings)
app_state = build_app_state(
effective,
fast_model=fast_model,
smart_model=smart_model,
embedder=embedder,
)
fast_api.state.settings = effective
apply_app_state(fast_api.state, app_state)
runtime = app_state.runtime
tracer_provider = setup_posthog_tracking(effective)
if tracer_provider:
Agent.instrument_all(InstrumentationSettings(tracer_provider=tracer_provider))
stop_background = asyncio.Event()
reaper_task = asyncio.create_task(
_run_expired_doc_reaper(
runtime.documents,
interval_seconds=settings.documents_reaper_interval_seconds,
stop=stop_background,
),
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
reaper_task.cancel()
try:
await reaper_task
except asyncio.CancelledError:
pass
# Drain the loops rather than cancel: cancelling a reaper mid `to_thread` sqlite call lets
# close() pull the connection out from under it and segfault sqlite-vec. Cancel is a backstop.
stop_background.set()
_, pending = await asyncio.wait(background_tasks, timeout=_BACKGROUND_TASK_DRAIN_SECONDS)
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()
if tracer_provider:
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(agent_capabilities_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)
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(
status="ok",
smart_model=settings.smart_model_name,
+72
View File
@@ -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_drafts import router as agent_draft_router
from .config import router as config_router
from .document_classifier import router as document_classifier_router
from .documents import router as document_router
from .execution import router as execution_router
@@ -12,6 +13,7 @@ from .pdf_questions import router as pdf_question_router
__all__ = [
"agent_capabilities_router",
"agent_draft_router",
"config_router",
"document_classifier_router",
"document_router",
"execution_router",
+250
View File
@@ -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,
)
+116
View File
@@ -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
+12
View File
@@ -25,6 +25,9 @@ class AppSettings(BaseSettings):
smart_model_name: str = Field(validation_alias="STIRLING_SMART_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")
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
@@ -123,6 +126,15 @@ class AppSettings(BaseSettings):
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")
# 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:
"""Configure the ``stirling`` logger hierarchy."""
+12
View File
@@ -29,6 +29,13 @@ from .common import (
format_conversation_history,
format_file_names,
)
from .config import (
ConfigApplyResponse,
ConfigLimitsSection,
ConfigModelsSection,
ConfigPushRequest,
ConfigRagSection,
)
from .contradiction import (
Claim,
Contradiction,
@@ -139,6 +146,11 @@ __all__ = [
"Claim",
"CommentSpec",
"CompletedExecutionAction",
"ConfigApplyResponse",
"ConfigLimitsSection",
"ConfigModelsSection",
"ConfigPushRequest",
"ConfigRagSection",
"Contradiction",
"ContradictionReport",
"ContradictionSeverity",
+69
View File
@@ -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)
+28 -1
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
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.store import Document
@@ -12,6 +14,27 @@ from stirling.documents.store import Document
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:
"""Wraps Pydantic AI's Embedder to provide document chunking and embedding."""
@@ -21,8 +44,12 @@ class EmbeddingService:
chunk_size: int = 512,
chunk_overlap: int = 64,
embed_batch_size: int = DEFAULT_EMBED_BATCH_SIZE,
*,
provider: str | None = None,
api_key: str | None = None,
base_url: str | 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_overlap = chunk_overlap
self._embed_batch_size = embed_batch_size
+18
View File
@@ -43,6 +43,24 @@ class DocumentService:
self._store = store
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(
self,
collection: FileId,
+116 -28
View File
@@ -1,7 +1,9 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
@@ -12,8 +14,10 @@ from pydantic_ai import RunContext
from pydantic_ai.messages import ModelMessage, ModelResponse
from pydantic_ai.models import Model, ModelRequestParameters, StreamedResponse, infer_model
from pydantic_ai.models.anthropic import AnthropicModel
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.models.wrapper import WrapperModel
from pydantic_ai.providers.anthropic import AnthropicProvider
from pydantic_ai.providers.openai import OpenAIProvider
from pydantic_ai.settings import ModelSettings
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):
"""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)
def _build_documents(settings: AppSettings) -> DocumentService:
"""Build the document service used by per-request RAG capabilities."""
logger.info("Documents: embedding_model=%s", settings.rag_embedding_model)
embedder = EmbeddingService(
model_name=settings.rag_embedding_model,
chunk_size=settings.rag_chunk_size,
chunk_overlap=settings.rag_chunk_overlap,
)
def _build_documents(settings: AppSettings, embedder: EmbeddingService | None = None) -> DocumentService:
"""Build the document service used for RAG; ``embedder`` lets a cache-restore boot inject a pre-built one."""
if embedder is None:
logger.info("Documents: embedding_model=%s", settings.rag_embedding_model)
embedder = EmbeddingService(
model_name=settings.rag_embedding_model,
chunk_size=settings.rag_chunk_size,
chunk_overlap=settings.rag_chunk_overlap,
)
store = _build_document_store(settings)
return DocumentService(embedder=embedder, store=store, default_top_k=settings.rag_default_top_k)
def build_runtime(settings: AppSettings) -> AppRuntime:
fast_model = _build_model(settings.fast_model_name)
smart_model = _build_model(settings.smart_model_name)
validate_structured_output_support(fast_model, settings.fast_model_name)
validate_structured_output_support(smart_model, settings.smart_model_name)
def build_runtime(
settings: AppSettings,
*,
documents: DocumentService | None = None,
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
# and process resources, which the tiers share.
model_semaphore = asyncio.Semaphore(settings.model_max_concurrency)
return AppRuntime(
settings=settings,
fast_model=ConcurrencyLimitedModel(fast_model, model_semaphore),
smart_model=ConcurrencyLimitedModel(smart_model, model_semaphore),
documents=_build_documents(settings),
fast_model=ConcurrencyLimitedModel(fast, model_semaphore),
smart_model=ConcurrencyLimitedModel(smart, model_semaphore),
documents=documents if documents is not None else _build_documents(settings, embedder),
)
def _build_model(model_name: str) -> Model:
"""Construct a model, injecting our keepalive-free httpx client for
Anthropic models so workers don't pick up stale pooled connections.
def _build_model(
model_name: str,
*,
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
issue is specific to the Cloudflare-fronted Anthropic API in our
observations and the fix doesn't necessarily apply elsewhere.
"""
if model_name.startswith("anthropic:"):
bare_name = model_name.removeprefix("anthropic:")
provider = AnthropicProvider(http_client=_build_anthropic_http_client())
return AnthropicModel(bare_name, provider=provider)
return infer_model(model_name)
provider_name = (provider or "").lower()
key = api_key or None
if provider_name == "anthropic":
return AnthropicModel(model_name, provider=_anthropic_provider(key))
if provider_name == "openai":
openai_provider = OpenAIProvider(api_key=key) if key else OpenAIProvider()
return OpenAIChatModel(model_name, provider=openai_provider)
if provider_name in ("ollama", "custom"):
# 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}.")
+24
View File
@@ -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
}
}
+111
View File
@@ -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
+70
View File
@@ -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
+383
View File
@@ -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"
+2
View File
@@ -605,6 +605,7 @@ name = "engine"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "cryptography" },
{ name = "fastapi" },
{ name = "opentelemetry-sdk" },
{ name = "pgvector" },
@@ -631,6 +632,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "cryptography", specifier = ">=44.0.0" },
{ name = "fastapi", specifier = ">=0.116.0" },
{ name = "opentelemetry-sdk", specifier = ">=1.39.0" },
{ 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."
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]
clickToUpgrade = "Click to view plan details"
@@ -8816,6 +8976,13 @@ title = "Signature Order"
[settings]
close = "Close"
[settings.ai]
documents = "Documents & RAG"
general = "General"
limits = "Limits & Performance"
models = "Models & Providers"
title = "AI"
[settings.configuration]
advanced = "Advanced"
database = "Database"
+24
View File
@@ -475,6 +475,26 @@
"title": "Admin Mcp Settings - Stirling PDF",
"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": {
"image": "/og_images/home.png",
"title": "Help Settings - Stirling PDF",
@@ -654,6 +674,10 @@
"/settings/adminEndpoints": "/settings/adminEndpoints",
"/settings/adminStorageSharing": "/settings/adminStorageSharing",
"/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/legal": "/settings/legal",
"/settings/backendThirdPartyLicenses": "/settings/backendThirdPartyLicenses",
@@ -476,6 +476,26 @@
"title": "Admin Mcp Settings - Stirling PDF",
"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": {
"image": "/og_images/home.png",
"title": "Help Settings - Stirling PDF",
@@ -667,6 +687,10 @@
"/settings/adminEndpoints": "/settings/adminEndpoints",
"/settings/adminStorageSharing": "/settings/adminStorageSharing",
"/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/legal": "/settings/legal",
"/settings/backendThirdPartyLicenses": "/settings/backendThirdPartyLicenses",
@@ -32,6 +32,10 @@ export const VALID_NAV_KEYS = [
"adminEndpoints",
"adminStorageSharing",
"adminMcp",
"adminAiGeneral",
"adminAiModels",
"adminAiDocuments",
"adminAiLimits",
"help",
"legal",
"backendThirdPartyLicenses",
@@ -17,6 +17,10 @@ import AdminPlanSection from "@app/components/shared/config/configSections/Admin
import AdminFeaturesSection from "@app/components/shared/config/configSections/AdminFeaturesSection";
import AdminEndpointsSection from "@app/components/shared/config/configSections/AdminEndpointsSection";
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 AdminUsageSection from "@app/components/shared/config/configSections/AdminUsageSection";
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
sections.push({
title: t("settings.securityAuth.title", "Security & Authentication"),
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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");
});
});
@@ -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);
}
});
});