Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1143a8aba3 | ||
|
|
baf33b6844 |
@@ -308,6 +308,106 @@ 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 admin/settings-derived AI config (models, keys, RAG, limits)
|
||||
* to the engine's {@code POST /api/v1/config} on startup and after a save. True lets a
|
||||
* self-hosted admin drive the engine from the UI. Environment-driven deployments pin this
|
||||
* false (SaaS does in application-saas.properties) so the engine stays entirely
|
||||
* env-controlled and the processor never overrides its config.
|
||||
*/
|
||||
private boolean pushConfigToEngine = true;
|
||||
|
||||
/** Model + provider selection, forwarded to the engine per-request. */
|
||||
private Models models = new Models();
|
||||
|
||||
/** 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 in the admin API and overridable
|
||||
* by the engine's own provider env var (e.g. ANTHROPIC_API_KEY) in environment-driven
|
||||
* deployments. Empty means the engine falls back to its native env credentials.
|
||||
*/
|
||||
private String apiKey = "";
|
||||
|
||||
/**
|
||||
* OpenAI-compatible base URL for 'ollama' / 'custom' providers (e.g.
|
||||
* http://ollama:11434/v1). Ignored for anthropic/openai. SSRF-sensitive - admin only.
|
||||
*/
|
||||
private String baseUrl = "";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Rag {
|
||||
/**
|
||||
* Embedding provider: 'voyageai', 'openai', 'ollama', or 'custom' (OpenAI-compatible).
|
||||
*/
|
||||
private String embeddingProvider = "voyageai";
|
||||
|
||||
/** Embedding model name (without provider prefix), e.g. 'voyage-4'. */
|
||||
private String embeddingModel = "voyage-4";
|
||||
|
||||
/**
|
||||
* Secret API key for the embedding provider; masked + env-overridable like
|
||||
* models.apiKey.
|
||||
*/
|
||||
private String embeddingApiKey = "";
|
||||
|
||||
/**
|
||||
* OpenAI-compatible base URL for 'ollama' / 'custom' embedding providers (e.g.
|
||||
* http://ollama:11434/v1). Ignored for voyageai/openai. SSRF-sensitive - admin only.
|
||||
*/
|
||||
private String embeddingBaseUrl = "";
|
||||
|
||||
/** How many chunks retrieval returns per search. */
|
||||
private int topK = 20;
|
||||
|
||||
/** Per-run cap on knowledge-search tool calls before the agent must answer. */
|
||||
private int maxSearches = 5;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Limits {
|
||||
private int maxPages = 200;
|
||||
private int maxCharacters = 200000;
|
||||
|
||||
/** Process-wide cap on concurrent model API calls (engine restart to apply). */
|
||||
private int modelMaxConcurrency = 32;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Features {
|
||||
private boolean chat = true;
|
||||
private boolean documentQuestions = true;
|
||||
private boolean createPdf = true;
|
||||
private boolean mathAuditor = true;
|
||||
private boolean pdfComment = true;
|
||||
private boolean classify = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+13
-1
@@ -336,7 +336,19 @@ public class ConfigController {
|
||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||
|
||||
// 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/settings AI config (models, keys, RAG, limits) to the engine on startup + save. Set false to leave the engine fully env-controlled
|
||||
models:
|
||||
provider: anthropic # Model provider: 'anthropic', 'openai', 'ollama', or 'custom' (OpenAI-compatible)
|
||||
smartModel: claude-haiku-4-5 # High-quality tier model name (no provider prefix)
|
||||
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:
|
||||
|
||||
+2
-13
@@ -54,21 +54,10 @@ public class CustomAuditEventRepository implements AuditEventRepository {
|
||||
return;
|
||||
}
|
||||
String rid = MDC.get("requestId");
|
||||
String apiKeyLabel =
|
||||
MDC.get(
|
||||
stirling.software.proprietary.security.service
|
||||
.ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY);
|
||||
|
||||
if (rid != null || apiKeyLabel != null) {
|
||||
if (rid != null) {
|
||||
clean = new java.util.HashMap<>(clean);
|
||||
if (rid != null) {
|
||||
clean.put("requestId", rid);
|
||||
}
|
||||
// Named key that made the request; surfaces as the doc source in the processor
|
||||
// feed.
|
||||
if (apiKeyLabel != null) {
|
||||
clean.put("__apiKeyLabel", apiKeyLabel);
|
||||
}
|
||||
clean.put("requestId", rid);
|
||||
}
|
||||
|
||||
String source = MDC.get("auditSource");
|
||||
|
||||
+7
-5
@@ -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;
|
||||
@@ -63,12 +63,11 @@ public class AiEngineController {
|
||||
private final UserServiceInterface userService;
|
||||
|
||||
/**
|
||||
* SSE emitter timeout. Long enough to accommodate multi-gigabyte PDF workflows (OCR on a
|
||||
* SSE emitter timeout in ms. 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}.
|
||||
* executor. Derived from {@code aiEngine.streamTimeoutSeconds}.
|
||||
*/
|
||||
@Value("${stirling.ai.streamTimeoutMs:1800000}")
|
||||
private long streamTimeoutMs;
|
||||
private final long streamTimeoutMs;
|
||||
|
||||
public AiEngineController(
|
||||
AiEngineClient aiEngineClient,
|
||||
@@ -78,6 +77,7 @@ public class AiEngineController {
|
||||
TaskManager taskManager,
|
||||
JobOwnershipService jobOwnershipService,
|
||||
AiEngineEndpointResolver endpointResolver,
|
||||
ApplicationProperties applicationProperties,
|
||||
@Autowired(required = false) UserServiceInterface userService) {
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.aiWorkflowService = aiWorkflowService;
|
||||
@@ -87,6 +87,8 @@ public class AiEngineController {
|
||||
this.jobOwnershipService = jobOwnershipService;
|
||||
this.endpointResolver = endpointResolver;
|
||||
this.userService = userService;
|
||||
this.streamTimeoutMs =
|
||||
applicationProperties.getAiEngine().getStreamTimeoutSeconds() * 1000L;
|
||||
}
|
||||
|
||||
private String currentUserId() {
|
||||
|
||||
+5
@@ -36,6 +36,7 @@ import stirling.software.proprietary.classification.store.ClassificationLabelSto
|
||||
import stirling.software.proprietary.model.api.ai.AiPageText;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.AiFeatureGate;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
@@ -68,6 +69,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;
|
||||
|
||||
@@ -86,6 +88,7 @@ public class ClassifyLabelController {
|
||||
PdfContentExtractor pdfContentExtractor,
|
||||
PdfMetadataService pdfMetadataService,
|
||||
AiEngineClient aiEngineClient,
|
||||
AiFeatureGate aiFeatureGate,
|
||||
ObjectMapper objectMapper,
|
||||
@Autowired(required = false) UserServiceInterface userService,
|
||||
@Autowired(required = false) ClassificationLabelStore labelStore,
|
||||
@@ -95,6 +98,7 @@ public class ClassifyLabelController {
|
||||
this.pdfContentExtractor = pdfContentExtractor;
|
||||
this.pdfMetadataService = pdfMetadataService;
|
||||
this.aiEngineClient = aiEngineClient;
|
||||
this.aiFeatureGate = aiFeatureGate;
|
||||
this.objectMapper = objectMapper;
|
||||
this.userService = userService;
|
||||
this.labelStore = labelStore;
|
||||
@@ -111,6 +115,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());
|
||||
|
||||
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
package stirling.software.proprietary.controller.api;
|
||||
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.annotations.api.ProprietaryUiDataApi;
|
||||
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
|
||||
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
|
||||
import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse;
|
||||
import stirling.software.proprietary.security.service.ApiKeyManagementService;
|
||||
|
||||
/**
|
||||
* Real backing for the portal Infrastructure → API Keys tab: list/create/revoke named, personal API
|
||||
* keys. Replaces the former portal-only mock endpoint. Not gated behind an Enterprise license - API
|
||||
* keys are a core auth feature available on every self-hosted instance.
|
||||
*/
|
||||
@ProprietaryUiDataApi
|
||||
@RequiredArgsConstructor
|
||||
public class PortalApiKeysController {
|
||||
|
||||
private final ApiKeyManagementService apiKeyManagementService;
|
||||
|
||||
// tier accepted for endpoint symmetry with the other infra tabs; ignored here.
|
||||
@GetMapping("/infrastructure/api-keys")
|
||||
@Operation(summary = "List API keys", description = "The caller's personal API keys.")
|
||||
public ResponseEntity<PortalApiKeysResponse> list(
|
||||
@RequestParam(value = "tier", required = false) String tier) {
|
||||
return ResponseEntity.ok(apiKeyManagementService.listVisibleKeys());
|
||||
}
|
||||
|
||||
@PostMapping("/infrastructure/api-keys")
|
||||
@Operation(
|
||||
summary = "Create an API key",
|
||||
description = "Mints a personal key and returns its one-time secret.")
|
||||
public ResponseEntity<CreatedApiKeyDto> create(@RequestBody CreateApiKeyRequest request) {
|
||||
return ResponseEntity.ok(apiKeyManagementService.createKey(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/infrastructure/api-keys/{id}")
|
||||
@Operation(summary = "Revoke an API key", description = "Disables a key the caller owns.")
|
||||
public ResponseEntity<Void> revoke(@PathVariable("id") Long id) {
|
||||
apiKeyManagementService.revokeKey(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -24,8 +24,8 @@ import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
/**
|
||||
* API-key auth for the MCP endpoint: validates a Stirling API key and binds the request to that
|
||||
* user with the MCP scopes.
|
||||
* API-key auth for the MCP endpoint: validates a Stirling per-user API key and binds the request to
|
||||
* that user with the MCP scopes.
|
||||
*/
|
||||
@Slf4j
|
||||
public class McpApiKeyAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
package stirling.software.proprietary.model.api.apikey;
|
||||
|
||||
/** Create-key request body from the portal: just a display name for the new personal key. */
|
||||
public record CreateApiKeyRequest(String name) {}
|
||||
-7
@@ -1,7 +0,0 @@
|
||||
package stirling.software.proprietary.model.api.apikey;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/** Returned once when a key is created: the row plus the plaintext secret, never persisted. */
|
||||
@Builder
|
||||
public record CreatedApiKeyDto(PortalApiKeyDto key, String secret) {}
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
package stirling.software.proprietary.model.api.apikey;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* One API key as shown in the portal Infrastructure → API Keys tab. Never carries the secret; that
|
||||
* is returned once from {@link CreatedApiKeyDto} at creation time.
|
||||
*/
|
||||
@Builder
|
||||
public record PortalApiKeyDto(
|
||||
String id,
|
||||
String name,
|
||||
String prefix,
|
||||
String created,
|
||||
String lastUsed,
|
||||
/** "active" | "revoked". */
|
||||
String status,
|
||||
long usageToday,
|
||||
long usageMonth,
|
||||
/** Lifetime request count for the key. */
|
||||
long usageTotal) {}
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
package stirling.software.proprietary.model.api.apikey;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/** Payload for the API Keys tab: the personal keys the caller owns. */
|
||||
@Builder
|
||||
public record PortalApiKeysResponse(List<PortalApiKeyDto> keys) {}
|
||||
+2
-5
@@ -57,7 +57,6 @@ import stirling.software.proprietary.security.oauth2.TauriAuthorizationRequestRe
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.CustomOAuth2UserService;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
@@ -485,14 +484,12 @@ public class SecurityConfiguration {
|
||||
}
|
||||
|
||||
@Bean
|
||||
public JwtAuthenticationFilter jwtAuthenticationFilter(
|
||||
ApiKeyAuthenticationService apiKeyAuthenticationService) {
|
||||
public JwtAuthenticationFilter jwtAuthenticationFilter() {
|
||||
return new JwtAuthenticationFilter(
|
||||
jwtService,
|
||||
userService,
|
||||
userDetailsService,
|
||||
jwtAuthenticationEntryPoint,
|
||||
securityProperties,
|
||||
apiKeyAuthenticationService);
|
||||
securityProperties);
|
||||
}
|
||||
}
|
||||
|
||||
+53
-2
@@ -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,28 @@ public class AdminSettingsController {
|
||||
.body(Map.of("error", "No settings provided to update"));
|
||||
}
|
||||
|
||||
// Work on a mutable copy so the removal below works even if the caller passed an
|
||||
// immutable map, then drop masked sensitive values so a UI round-trip never overwrites
|
||||
// a real secret (e.g. an API key) with the "********" placeholder from the GET
|
||||
// response.
|
||||
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();
|
||||
@@ -206,6 +231,10 @@ public class AdminSettingsController {
|
||||
pendingChanges.put(key, value != null ? value : "");
|
||||
}
|
||||
|
||||
// If AI settings changed, push them to the engine live so model/RAG/limit changes
|
||||
// apply without waiting for a processor restart.
|
||||
maybePushAiEngineLive();
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"message",
|
||||
@@ -600,6 +629,23 @@ public class AdminSettingsController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward any pending {@code aiEngine.*} changes to the AI engine immediately. The running bean
|
||||
* overlaid with these pending values is the current settings.yml state; {@link
|
||||
* AiEngineConfigSync} no-ops unless AI is enabled and an engine-relevant key changed.
|
||||
*/
|
||||
private void maybePushAiEngineLive() {
|
||||
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;
|
||||
@@ -843,8 +889,13 @@ public class AdminSettingsController {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for fields containing 'password' or 'secret'
|
||||
return lowerField.contains("password") || lowerField.contains("secret");
|
||||
// Substring match for secret-bearing field names. Covers provider credentials such as
|
||||
// aiEngine.models.apiKey and aiEngine.rag.embeddingApiKey (which the exact-name set would
|
||||
// miss) so keys are never returned to the client in cleartext.
|
||||
return lowerField.contains("password")
|
||||
|| lowerField.contains("secret")
|
||||
|| lowerField.contains("apikey")
|
||||
|| lowerField.contains("token");
|
||||
}
|
||||
|
||||
/** Create a masked representation for sensitive fields */
|
||||
|
||||
+4
-20
@@ -11,7 +11,6 @@ import java.sql.SQLException;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
@@ -34,9 +33,8 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
@@ -50,15 +48,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private final CustomUserDetailsService userDetailsService;
|
||||
private final AuthenticationEntryPoint authenticationEntryPoint;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
// Start clean so a pooled thread can't inherit a prior request's key label. This filter
|
||||
// runs before UserAuthenticationFilter, so in JWT mode it owns the API-key label lifecycle.
|
||||
MDC.remove(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY);
|
||||
if (!jwtService.isJwtEnabled()) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
@@ -137,14 +131,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
try {
|
||||
// Resolve through the shared service so the multi-key table (then the legacy
|
||||
// per-user key) is consulted and per-key usage is recorded; the key runs as its
|
||||
// owner. It also yields a per-key label for the processor's document
|
||||
// attribution.
|
||||
Optional<ApiKeyAuthentication> resolved =
|
||||
apiKeyAuthenticationService.authenticate(apiKey);
|
||||
Optional<User> user = userService.getUserByApiKey(apiKey);
|
||||
|
||||
if (resolved.isEmpty()) {
|
||||
if (user.isEmpty()) {
|
||||
handleAuthenticationFailure(
|
||||
request,
|
||||
response,
|
||||
@@ -154,13 +143,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
authentication =
|
||||
new ApiKeyAuthenticationToken(
|
||||
resolved.get().user(), apiKey, resolved.get().authorities());
|
||||
user.get(), apiKey, user.get().getAuthorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
if (resolved.get().auditLabel() != null) {
|
||||
MDC.put(
|
||||
ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY,
|
||||
resolved.get().auditLabel());
|
||||
}
|
||||
return true;
|
||||
} catch (AuthenticationException e) {
|
||||
handleAuthenticationFailure(
|
||||
|
||||
+5
-27
@@ -6,7 +6,6 @@ import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
@@ -34,8 +33,6 @@ import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
|
||||
@@ -44,24 +41,18 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
@Profile("!saas")
|
||||
public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
/** MDC key carrying the resolved key's label into audit events for the processor feed. */
|
||||
public static final String API_KEY_LABEL_MDC = ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY;
|
||||
|
||||
private final ApplicationProperties.Security securityProp;
|
||||
private final UserService userService;
|
||||
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
private final SessionPersistentRegistry sessionPersistentRegistry;
|
||||
private final boolean loginEnabledValue;
|
||||
|
||||
public UserAuthenticationFilter(
|
||||
@Lazy ApplicationProperties.Security securityProp,
|
||||
@Lazy UserService userService,
|
||||
ApiKeyAuthenticationService apiKeyAuthenticationService,
|
||||
SessionPersistentRegistry sessionPersistentRegistry,
|
||||
@Qualifier("loginEnabled") boolean loginEnabledValue) {
|
||||
this.securityProp = securityProp;
|
||||
this.userService = userService;
|
||||
this.apiKeyAuthenticationService = apiKeyAuthenticationService;
|
||||
this.sessionPersistentRegistry = sessionPersistentRegistry;
|
||||
this.loginEnabledValue = loginEnabledValue;
|
||||
}
|
||||
@@ -71,14 +62,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
// Start each request clean so a pooled thread can't inherit a prior request's key label -
|
||||
// but keep a label an upstream filter (JwtAuthenticationFilter) already set for a request
|
||||
// it API-key-authenticated, otherwise per-key attribution is lost on the JWT path.
|
||||
if (!(SecurityContextHolder.getContext().getAuthentication()
|
||||
instanceof ApiKeyAuthenticationToken)) {
|
||||
MDC.remove(API_KEY_LABEL_MDC);
|
||||
}
|
||||
|
||||
if (!loginEnabledValue) {
|
||||
// If login is not enabled, just pass all requests without authentication
|
||||
filterChain.doFilter(request, response);
|
||||
@@ -106,23 +89,18 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
String apiKey = request.getHeader("X-API-KEY");
|
||||
if (apiKey != null && !apiKey.trim().isEmpty()) {
|
||||
try {
|
||||
// Resolves the multi-key table then the legacy key, records usage, and yields a
|
||||
// per-key label for the processor's document-source attribution.
|
||||
Optional<ApiKeyAuthentication> resolved =
|
||||
apiKeyAuthenticationService.authenticate(apiKey);
|
||||
if (resolved.isEmpty()) {
|
||||
// Use API key to authenticate. This requires you to have an authentication
|
||||
// provider for API keys.
|
||||
Optional<User> user = userService.getUserByApiKey(apiKey);
|
||||
if (user.isEmpty()) {
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
response.getWriter().write("Invalid API Key.");
|
||||
return;
|
||||
}
|
||||
User user = resolved.get().user();
|
||||
authentication =
|
||||
new ApiKeyAuthenticationToken(
|
||||
user, apiKey, resolved.get().authorities());
|
||||
user.get(), apiKey, user.get().getAuthorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
if (resolved.get().auditLabel() != null) {
|
||||
MDC.put(API_KEY_LABEL_MDC, resolved.get().auditLabel());
|
||||
}
|
||||
} catch (AuthenticationException e) {
|
||||
// If API key authentication fails, deny the request
|
||||
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||
|
||||
+15
-16
@@ -11,6 +11,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
@@ -57,25 +58,23 @@ public class UserBasedRateLimitingFilter extends OncePerRequestFilter {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
// Bucket by the resolved user (the auth filter runs first and populates the context, even
|
||||
// for X-API-KEY requests), so all of a user's API keys share ONE per-user quota - minting
|
||||
// extra keys can't multiply the daily limit. Fall back to the raw key / IP only when the
|
||||
// request is unauthenticated.
|
||||
String identifier = null;
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null
|
||||
&& authentication.isAuthenticated()
|
||||
&& !"anonymousUser".equals(authentication.getName())) {
|
||||
identifier = authentication.getName();
|
||||
}
|
||||
if (identifier == null) {
|
||||
String apiKey = request.getHeader("X-API-KEY");
|
||||
if (apiKey != null && !apiKey.trim().isEmpty()) {
|
||||
identifier = "API_KEY_" + apiKey;
|
||||
} else {
|
||||
identifier = request.getRemoteAddr();
|
||||
// Check for API key in the request headers
|
||||
String apiKey = request.getHeader("X-API-KEY");
|
||||
if (apiKey != null && !apiKey.trim().isEmpty()) {
|
||||
identifier = // Prefix to distinguish between API keys and usernames
|
||||
"API_KEY_" + apiKey;
|
||||
} else {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && authentication.isAuthenticated()) {
|
||||
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
|
||||
identifier = userDetails.getUsername();
|
||||
}
|
||||
}
|
||||
// If neither API key nor an authenticated user is present, use IP address
|
||||
if (identifier == null) {
|
||||
identifier = request.getRemoteAddr();
|
||||
}
|
||||
Role userRole =
|
||||
getRoleFromAuthentication(SecurityContextHolder.getContext().getAuthentication());
|
||||
if (request.getHeader("X-API-KEY") != null) {
|
||||
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
package stirling.software.proprietary.security.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.time.Instant;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* A named, personal API key belonging to a user. The raw secret is shown once at creation and never
|
||||
* stored; only its SHA-256 hash is persisted, so a leaked database row cannot be replayed. Distinct
|
||||
* from the legacy single {@code users.apiKey} column, which stays a per-user key for backward
|
||||
* compatibility and is lazily represented here.
|
||||
*/
|
||||
@Entity
|
||||
@Table(
|
||||
name = "api_keys",
|
||||
indexes = {
|
||||
@Index(name = "idx_api_key_hash", columnList = "key_hash", unique = true),
|
||||
@Index(name = "idx_api_key_owner", columnList = "owner_user_id")
|
||||
})
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiKey implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "name", nullable = false, length = 100)
|
||||
private String name;
|
||||
|
||||
/** SHA-256 hex of the raw key; the raw value is never persisted. */
|
||||
@Column(name = "key_hash", nullable = false, unique = true, length = 64)
|
||||
private String keyHash;
|
||||
|
||||
/** Non-secret leading fragment of the raw key, shown in listings (e.g. {@code sk_a1b2c3d4}). */
|
||||
@Column(name = "prefix", nullable = false, length = 32)
|
||||
private String prefix;
|
||||
|
||||
/** The user who created and owns the key; the key authenticates as this user. */
|
||||
@Column(name = "owner_user_id", nullable = false)
|
||||
private Long ownerUserId;
|
||||
|
||||
@Column(name = "enabled", nullable = false)
|
||||
private boolean enabled;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "last_used_at")
|
||||
private Instant lastUsedAt;
|
||||
|
||||
@Column(name = "revoked_at")
|
||||
private Instant revokedAt;
|
||||
|
||||
/** Active = enabled and not revoked; only active keys authenticate. */
|
||||
public boolean isActive() {
|
||||
return enabled && revokedAt == null;
|
||||
}
|
||||
}
|
||||
-1
@@ -5,7 +5,6 @@ import java.util.Collection;
|
||||
import org.springframework.security.authentication.AbstractAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
/** Authentication produced from an {@code X-API-KEY} header; runs as the key's owner. */
|
||||
public class ApiKeyAuthenticationToken extends AbstractAuthenticationToken {
|
||||
|
||||
private final Object principal;
|
||||
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
package stirling.software.proprietary.security.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.IdClass;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* One UTC day's request tally for an API key. Rolling "today"/"this month" usage is summed from
|
||||
* these rows, keeping the table at one row per key per active day rather than one per request.
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "api_key_daily_usage")
|
||||
@IdClass(ApiKeyDailyUsageId.class)
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
public class ApiKeyDailyUsage implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Id
|
||||
@Column(name = "api_key_id")
|
||||
private Long apiKeyId;
|
||||
|
||||
@Id
|
||||
@Column(name = "epoch_day")
|
||||
private long epochDay;
|
||||
|
||||
@Column(name = "count")
|
||||
private long count;
|
||||
|
||||
public ApiKeyDailyUsage(Long apiKeyId, long epochDay, long count) {
|
||||
this.apiKeyId = apiKeyId;
|
||||
this.epochDay = epochDay;
|
||||
this.count = count;
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
package stirling.software.proprietary.security.model;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Composite key for {@link ApiKeyDailyUsage}: one row per key per UTC day. */
|
||||
public class ApiKeyDailyUsageId implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private Long apiKeyId;
|
||||
private long epochDay;
|
||||
|
||||
public ApiKeyDailyUsageId() {}
|
||||
|
||||
public ApiKeyDailyUsageId(Long apiKeyId, long epochDay) {
|
||||
this.apiKeyId = apiKeyId;
|
||||
this.epochDay = epochDay;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (!(o instanceof ApiKeyDailyUsageId other)) {
|
||||
return false;
|
||||
}
|
||||
return epochDay == other.epochDay && Objects.equals(apiKeyId, other.apiKeyId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(apiKeyId, epochDay);
|
||||
}
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
package stirling.software.proprietary.security.repository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.security.model.ApiKeyDailyUsage;
|
||||
import stirling.software.proprietary.security.model.ApiKeyDailyUsageId;
|
||||
|
||||
@Repository
|
||||
public interface ApiKeyDailyUsageRepository
|
||||
extends JpaRepository<ApiKeyDailyUsage, ApiKeyDailyUsageId> {
|
||||
|
||||
/** Atomically bump today's tally; returns 0 when no row exists yet (caller then inserts). */
|
||||
@Modifying
|
||||
@Query(
|
||||
"UPDATE ApiKeyDailyUsage u SET u.count = u.count + 1 "
|
||||
+ "WHERE u.apiKeyId = :apiKeyId AND u.epochDay = :epochDay")
|
||||
int incrementIfPresent(@Param("apiKeyId") Long apiKeyId, @Param("epochDay") long epochDay);
|
||||
|
||||
@Query(
|
||||
"SELECT COALESCE(SUM(u.count), 0) FROM ApiKeyDailyUsage u "
|
||||
+ "WHERE u.apiKeyId = :apiKeyId AND u.epochDay >= :fromDayInclusive")
|
||||
long sumSince(
|
||||
@Param("apiKeyId") Long apiKeyId, @Param("fromDayInclusive") long fromDayInclusive);
|
||||
|
||||
@Query(
|
||||
"SELECT u.count FROM ApiKeyDailyUsage u "
|
||||
+ "WHERE u.apiKeyId = :apiKeyId AND u.epochDay = :epochDay")
|
||||
Long countForDay(@Param("apiKeyId") Long apiKeyId, @Param("epochDay") long epochDay);
|
||||
|
||||
/** Batched today-count for many keys in one query (avoids N+1 when listing keys). */
|
||||
@Query(
|
||||
"SELECT u.apiKeyId AS apiKeyId, u.count AS total FROM ApiKeyDailyUsage u "
|
||||
+ "WHERE u.apiKeyId IN :ids AND u.epochDay = :epochDay")
|
||||
List<ApiKeyUsageSum> countForDayByIds(
|
||||
@Param("ids") Collection<Long> ids, @Param("epochDay") long epochDay);
|
||||
|
||||
/** Batched trailing-window sum for many keys in one query. */
|
||||
@Query(
|
||||
"SELECT u.apiKeyId AS apiKeyId, SUM(u.count) AS total FROM ApiKeyDailyUsage u "
|
||||
+ "WHERE u.apiKeyId IN :ids AND u.epochDay >= :fromDayInclusive "
|
||||
+ "GROUP BY u.apiKeyId")
|
||||
List<ApiKeyUsageSum> sumSinceByIds(
|
||||
@Param("ids") Collection<Long> ids, @Param("fromDayInclusive") long fromDayInclusive);
|
||||
|
||||
void deleteByApiKeyId(Long apiKeyId);
|
||||
|
||||
List<ApiKeyDailyUsage> findByApiKeyId(Long apiKeyId);
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package stirling.software.proprietary.security.repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.security.model.ApiKey;
|
||||
|
||||
@Repository
|
||||
public interface ApiKeyRepository extends JpaRepository<ApiKey, Long> {
|
||||
|
||||
Optional<ApiKey> findByKeyHash(String keyHash);
|
||||
|
||||
boolean existsByKeyHash(String keyHash);
|
||||
|
||||
List<ApiKey> findByOwnerUserIdOrderByCreatedAtDesc(Long ownerUserId);
|
||||
}
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
package stirling.software.proprietary.security.repository;
|
||||
|
||||
/** Projection: a key id and a usage total, for batching per-key usage into one query. */
|
||||
public interface ApiKeyUsageSum {
|
||||
Long getApiKeyId();
|
||||
|
||||
Long getTotal();
|
||||
}
|
||||
-107
@@ -1,107 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.ApiKey;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
|
||||
/**
|
||||
* Resolves an incoming {@code X-API-KEY} to its owning user and records per-key usage. Depends only
|
||||
* on repositories (never {@code UserService}) so {@code UserService} can delegate here without a
|
||||
* bean cycle.
|
||||
*
|
||||
* <p>Resolution order: the multi-key {@code api_keys} table first (by hash), then the legacy
|
||||
* per-user {@code users.apiKey} column. Legacy keys therefore keep working unchanged. Every key is
|
||||
* personal and authenticates as its owner with the owner's authorities.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ApiKeyAuthenticationService {
|
||||
|
||||
/**
|
||||
* MDC key that carries the resolved key's label into audit events so the processor's Documents
|
||||
* feed can attribute a document to the specific key. Set by the auth filters (both flavors),
|
||||
* read by {@code CustomAuditEventRepository}.
|
||||
*/
|
||||
public static final String AUDIT_LABEL_MDC_KEY = "apiKeyLabel";
|
||||
|
||||
private final ApiKeyRepository apiKeyRepository;
|
||||
private final ApiKeyUsageRecorder usageRecorder;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/** The user a raw key authenticates as, or empty if it matches no active key. */
|
||||
public Optional<User> resolveUser(String rawKey) {
|
||||
return authenticate(rawKey).map(ApiKeyAuthentication::user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a raw key, recording usage as a side effect. Returns the owning user, a display label
|
||||
* for the resolved key ({@code null} for the legacy per-user key), and the owner's authorities.
|
||||
*/
|
||||
public Optional<ApiKeyAuthentication> authenticate(String rawKey) {
|
||||
if (rawKey == null || rawKey.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
ApiKey key = apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(rawKey)).orElse(null);
|
||||
if (key != null) {
|
||||
if (!key.isActive()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
User owner = userRepository.findById(key.getOwnerUserId()).orElse(null);
|
||||
if (owner == null || !owner.isEnabled()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
usageRecorder.record(key.getId());
|
||||
return Optional.of(
|
||||
new ApiKeyAuthentication(owner, auditLabel(key), owner.getAuthorities()));
|
||||
}
|
||||
|
||||
// Legacy single per-user key: keep working, always a personal key for its user.
|
||||
return userRepository
|
||||
.findByApiKey(rawKey)
|
||||
.filter(User::isEnabled)
|
||||
.map(user -> new ApiKeyAuthentication(user, null, user.getAuthorities()));
|
||||
}
|
||||
|
||||
/** "Production ingest (sk_a1b2c3d4)" - shown against API-sourced docs in the processor feed. */
|
||||
private static String auditLabel(ApiKey key) {
|
||||
return key.getName() + " (" + key.getPrefix() + ")";
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the {@code api_keys} row that mirrors a given raw key, if any. Called when the legacy
|
||||
* per-user key is rotated so the migrated shadow row can't keep authenticating the old secret.
|
||||
*/
|
||||
@Transactional
|
||||
public void revokeMigratedKey(String rawKey) {
|
||||
if (rawKey == null || rawKey.isBlank()) {
|
||||
return;
|
||||
}
|
||||
apiKeyRepository
|
||||
.findByKeyHash(ApiKeyHasher.hash(rawKey))
|
||||
.filter(ApiKey::isActive)
|
||||
.ifPresent(
|
||||
k -> {
|
||||
k.setEnabled(false);
|
||||
k.setRevokedAt(Instant.now());
|
||||
apiKeyRepository.save(k);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolved key: the user, an optional processor-feed label, and the authorities to run as.
|
||||
*/
|
||||
public record ApiKeyAuthentication(
|
||||
User user, String auditLabel, Collection<? extends GrantedAuthority> authorities) {}
|
||||
}
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/** Generates opaque API-key secrets and hashes them for storage/lookup. */
|
||||
public final class ApiKeyHasher {
|
||||
|
||||
/** Human-recognisable prefix so a leaked string is identifiable as a Stirling API key. */
|
||||
public static final String KEY_PREFIX = "sk_";
|
||||
|
||||
/** Chars of the raw key kept for non-secret display (includes the {@code sk_} prefix). */
|
||||
private static final int DISPLAY_PREFIX_LENGTH = 11;
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private ApiKeyHasher() {}
|
||||
|
||||
/** A fresh opaque secret: {@code sk_} followed by 40 hex chars of cryptographic randomness. */
|
||||
public static String generateRawKey() {
|
||||
byte[] bytes = new byte[20];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return KEY_PREFIX + HexFormat.of().formatHex(bytes);
|
||||
}
|
||||
|
||||
/** SHA-256 hex of a raw key; the value stored and looked up, never the raw key. */
|
||||
public static String hash(String rawKey) {
|
||||
try {
|
||||
byte[] digest =
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(rawKey.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Leading, non-secret fragment shown in listings (e.g. {@code sk_a1b2c3d4}). */
|
||||
public static String displayPrefix(String rawKey) {
|
||||
if (rawKey == null) {
|
||||
return "";
|
||||
}
|
||||
return rawKey.length() <= DISPLAY_PREFIX_LENGTH
|
||||
? rawKey
|
||||
: rawKey.substring(0, DISPLAY_PREFIX_LENGTH);
|
||||
}
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.security.model.ApiKey;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
|
||||
/**
|
||||
* Inserts the shadow {@code api_keys} row that mirrors a user's legacy {@code users.apiKey} in its
|
||||
* OWN ({@code REQUIRES_NEW}) transaction. Kept a separate bean so the write is isolated from the
|
||||
* caller's listing transaction: when two concurrent first-loads race to insert the same hash, the
|
||||
* loser's unique-key clash rolls back only this insert instead of poisoning the caller's
|
||||
* transaction (on Postgres a failed statement aborts the whole transaction). The {@code
|
||||
* DataIntegrityViolationException} is left to propagate so the caller can treat it as "already
|
||||
* migrated".
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
class ApiKeyLegacyMigrator {
|
||||
|
||||
private final ApiKeyRepository apiKeyRepository;
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void insertMigratedKey(ApiKey key) {
|
||||
apiKeyRepository.saveAndFlush(key);
|
||||
}
|
||||
}
|
||||
-235
@@ -1,235 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
|
||||
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
|
||||
import stirling.software.proprietary.model.api.apikey.PortalApiKeyDto;
|
||||
import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.ApiKey;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
|
||||
/**
|
||||
* Portal-facing CRUD for named, personal API keys: lists, creates, and revokes the caller's own
|
||||
* keys. Every key belongs to exactly one user and authenticates as that user; there is no sharing.
|
||||
*
|
||||
* <p>Every pre-existing single {@code users.apiKey} is lazily represented as a key owned by that
|
||||
* user, so historic keys list uniformly.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ApiKeyManagementService {
|
||||
|
||||
private static final DateTimeFormatter CREATED_FORMAT =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneOffset.UTC);
|
||||
private static final DateTimeFormatter LAST_USED_FORMAT =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneOffset.UTC);
|
||||
private static final int MONTH_WINDOW_DAYS = 30;
|
||||
|
||||
/** Bounds a key name so it can't bloat storage or the audit/processor feed. */
|
||||
private static final int MAX_NAME_LENGTH = 100;
|
||||
|
||||
/** Caps active keys per user so key creation can't be used to multiply rate-limit budget. */
|
||||
private static final int MAX_ACTIVE_KEYS_PER_USER = 50;
|
||||
|
||||
private final ApiKeyRepository apiKeyRepository;
|
||||
private final ApiKeyDailyUsageRepository usageRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final UserService userService;
|
||||
private final ApiKeyLegacyMigrator legacyMigrator;
|
||||
|
||||
/** All keys the caller owns. */
|
||||
@Transactional
|
||||
public PortalApiKeysResponse listVisibleKeys() {
|
||||
User caller = requireCaller();
|
||||
migrateLegacyKey(caller);
|
||||
|
||||
List<ApiKey> visible =
|
||||
apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(caller.getId());
|
||||
|
||||
// Batch usage for all keys into three queries rather than two-per-key (avoids N+1).
|
||||
long today = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay();
|
||||
List<Long> ids = visible.stream().map(ApiKey::getId).toList();
|
||||
Map<Long, Long> todayById = new HashMap<>();
|
||||
Map<Long, Long> monthById = new HashMap<>();
|
||||
Map<Long, Long> totalById = new HashMap<>();
|
||||
if (!ids.isEmpty()) {
|
||||
usageRepository
|
||||
.countForDayByIds(ids, today)
|
||||
.forEach(r -> todayById.put(r.getApiKeyId(), r.getTotal()));
|
||||
usageRepository
|
||||
.sumSinceByIds(ids, today - (MONTH_WINDOW_DAYS - 1))
|
||||
.forEach(r -> monthById.put(r.getApiKeyId(), r.getTotal()));
|
||||
usageRepository
|
||||
.sumSinceByIds(ids, Long.MIN_VALUE)
|
||||
.forEach(r -> totalById.put(r.getApiKeyId(), r.getTotal()));
|
||||
}
|
||||
|
||||
List<PortalApiKeyDto> keys =
|
||||
visible.stream()
|
||||
.map(
|
||||
k ->
|
||||
toDto(
|
||||
k,
|
||||
zeroIfNull(todayById.get(k.getId())),
|
||||
zeroIfNull(monthById.get(k.getId())),
|
||||
zeroIfNull(totalById.get(k.getId()))))
|
||||
.toList();
|
||||
return PortalApiKeysResponse.builder().keys(keys).build();
|
||||
}
|
||||
|
||||
private static long zeroIfNull(Long value) {
|
||||
return value == null ? 0L : value;
|
||||
}
|
||||
|
||||
/** Create a personal key and return its one-time secret. */
|
||||
@Transactional
|
||||
public CreatedApiKeyDto createKey(CreateApiKeyRequest request) {
|
||||
User caller = requireCaller();
|
||||
String name = request == null ? null : request.name();
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Key name is required");
|
||||
}
|
||||
if (name.trim().length() > MAX_NAME_LENGTH) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Key name must be " + MAX_NAME_LENGTH + " characters or fewer");
|
||||
}
|
||||
long activeOwned =
|
||||
apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(caller.getId()).stream()
|
||||
.filter(ApiKey::isActive)
|
||||
.count();
|
||||
if (activeOwned >= MAX_ACTIVE_KEYS_PER_USER) {
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
"You have reached the maximum of "
|
||||
+ MAX_ACTIVE_KEYS_PER_USER
|
||||
+ " active API keys; revoke one before creating another");
|
||||
}
|
||||
|
||||
String rawKey = ApiKeyHasher.generateRawKey();
|
||||
ApiKey saved =
|
||||
apiKeyRepository.save(
|
||||
ApiKey.builder()
|
||||
.name(name.trim())
|
||||
.keyHash(ApiKeyHasher.hash(rawKey))
|
||||
.prefix(ApiKeyHasher.displayPrefix(rawKey))
|
||||
.ownerUserId(caller.getId())
|
||||
.enabled(true)
|
||||
.createdAt(Instant.now())
|
||||
.build());
|
||||
|
||||
return CreatedApiKeyDto.builder().key(toDto(saved, 0L, 0L, 0L)).secret(rawKey).build();
|
||||
}
|
||||
|
||||
/** Soft-revoke a key the caller owns; also clears the legacy column if it is that key. */
|
||||
@Transactional
|
||||
public void revokeKey(Long id) {
|
||||
User caller = requireCaller();
|
||||
ApiKey key =
|
||||
apiKeyRepository
|
||||
.findById(id)
|
||||
.orElseThrow(
|
||||
() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "No key"));
|
||||
if (!key.getOwnerUserId().equals(caller.getId())) {
|
||||
// Not-found rather than forbidden so a caller can't probe other users' key ids.
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "No key");
|
||||
}
|
||||
key.setEnabled(false);
|
||||
key.setRevokedAt(Instant.now());
|
||||
apiKeyRepository.save(key);
|
||||
clearLegacyColumnIfMatches(key);
|
||||
}
|
||||
|
||||
/** Represent a user's pre-existing single key as a row so it lists uniformly. */
|
||||
private void migrateLegacyKey(User user) {
|
||||
String legacy = user.getApiKey();
|
||||
if (legacy == null || legacy.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String hash = ApiKeyHasher.hash(legacy);
|
||||
if (apiKeyRepository.existsByKeyHash(hash)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Insert in its own transaction so a concurrent-insert clash can't poison this
|
||||
// listing transaction (see ApiKeyLegacyMigrator).
|
||||
legacyMigrator.insertMigratedKey(
|
||||
ApiKey.builder()
|
||||
.name("Default key")
|
||||
.keyHash(hash)
|
||||
.prefix(ApiKeyHasher.displayPrefix(legacy))
|
||||
.ownerUserId(user.getId())
|
||||
.enabled(true)
|
||||
.createdAt(Instant.now())
|
||||
.build());
|
||||
} catch (DataIntegrityViolationException alreadyMigrated) {
|
||||
// A concurrent first-load won the race and inserted the same hash; that's fine.
|
||||
log.debug("Legacy key already migrated concurrently for user {}", user.getId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If a revoked key is the owner's legacy {@code users.apiKey}, null it so it stops resolving.
|
||||
*/
|
||||
private void clearLegacyColumnIfMatches(ApiKey key) {
|
||||
userRepository
|
||||
.findById(key.getOwnerUserId())
|
||||
.ifPresent(
|
||||
owner -> {
|
||||
String legacy = owner.getApiKey();
|
||||
if (legacy != null
|
||||
&& ApiKeyHasher.hash(legacy).equals(key.getKeyHash())) {
|
||||
owner.setApiKey(null);
|
||||
userRepository.save(owner);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private PortalApiKeyDto toDto(ApiKey key, long usageToday, long usageMonth, long usageTotal) {
|
||||
return PortalApiKeyDto.builder()
|
||||
.id(String.valueOf(key.getId()))
|
||||
.name(key.getName())
|
||||
.prefix(key.getPrefix())
|
||||
.created(
|
||||
key.getCreatedAt() == null ? "" : CREATED_FORMAT.format(key.getCreatedAt()))
|
||||
.lastUsed(
|
||||
key.getLastUsedAt() == null
|
||||
? "Never"
|
||||
: LAST_USED_FORMAT.format(key.getLastUsedAt()))
|
||||
.status(key.isActive() ? "active" : "revoked")
|
||||
.usageToday(usageToday)
|
||||
.usageMonth(usageMonth)
|
||||
.usageTotal(usageTotal)
|
||||
.build();
|
||||
}
|
||||
|
||||
private User requireCaller() {
|
||||
String username = userService.getCurrentUsername();
|
||||
if (username == null || username.isBlank() || "anonymousUser".equalsIgnoreCase(username)) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
||||
}
|
||||
return userService
|
||||
.findByUsernameIgnoreCase(username)
|
||||
.orElseThrow(
|
||||
() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unknown user"));
|
||||
}
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Records per-key usage off the request thread. Kept a separate bean so the {@code @Async} proxy is
|
||||
* honoured (a self-invocation from the resolver would run inline). Best-effort: never fails a
|
||||
* request. The actual writes go through {@link ApiKeyUsageWriter} so each step commits in its own
|
||||
* transaction and a first-write race can't drop a count.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ApiKeyUsageRecorder {
|
||||
|
||||
private final ApiKeyUsageWriter writer;
|
||||
|
||||
/** Bump today's tally for the key and stamp last-used. */
|
||||
@Async("auditExecutor")
|
||||
public void record(Long apiKeyId) {
|
||||
if (apiKeyId == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
long epochDay = Instant.now().atZone(ZoneOffset.UTC).toLocalDate().toEpochDay();
|
||||
// First writer of the day inserts the row; everyone else (and the loser of an insert
|
||||
// race) increments. Separate transactions mean a unique-key clash never rolls back an
|
||||
// already-counted request.
|
||||
if (writer.increment(apiKeyId, epochDay) == 0
|
||||
&& !firstUseInserted(apiKeyId, epochDay)) {
|
||||
writer.increment(apiKeyId, epochDay);
|
||||
}
|
||||
writer.stampLastUsed(apiKeyId);
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to record API key usage for id={}", apiKeyId, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether we inserted the day's first row. A lost insert race can surface either as a {@code
|
||||
* false} return or - when the failed flush marked the REQUIRES_NEW transaction rollback-only,
|
||||
* so its commit throws - as an exception; both mean "someone else inserted", so we treat any
|
||||
* failure as not-inserted and let the caller fall back to an increment rather than dropping the
|
||||
* count.
|
||||
*/
|
||||
private boolean firstUseInserted(Long apiKeyId, long epochDay) {
|
||||
try {
|
||||
return writer.tryInsertFirstUse(apiKeyId, epochDay);
|
||||
} catch (RuntimeException raced) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.proprietary.security.model.ApiKeyDailyUsage;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
|
||||
/**
|
||||
* Per-step transactional writes for {@link ApiKeyUsageRecorder}. Each method runs in its own
|
||||
* ({@code REQUIRES_NEW}) transaction so a unique-key clash when two requests race to insert the
|
||||
* day's first row rolls back only that failed insert - never an already-counted request or the
|
||||
* last-used stamp.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
class ApiKeyUsageWriter {
|
||||
|
||||
private final ApiKeyRepository apiKeyRepository;
|
||||
private final ApiKeyDailyUsageRepository usageRepository;
|
||||
|
||||
/** Bump today's tally if the row already exists; returns rows updated (0 if none yet). */
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public int increment(Long apiKeyId, long epochDay) {
|
||||
return usageRepository.incrementIfPresent(apiKeyId, epochDay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert today's row with a count of 1. Flushes so a concurrent first-write's unique-key clash
|
||||
* surfaces here (returning false) instead of at commit; the caller then increments instead.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public boolean tryInsertFirstUse(Long apiKeyId, long epochDay) {
|
||||
try {
|
||||
usageRepository.saveAndFlush(new ApiKeyDailyUsage(apiKeyId, epochDay, 1));
|
||||
return true;
|
||||
} catch (DataIntegrityViolationException raced) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void stampLastUsed(Long apiKeyId) {
|
||||
apiKeyRepository
|
||||
.findById(apiKeyId)
|
||||
.ifPresent(
|
||||
key -> {
|
||||
key.setLastUsedAt(Instant.now());
|
||||
apiKeyRepository.save(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
+10
-16
@@ -95,7 +95,6 @@ public class UserService implements UserServiceInterface {
|
||||
private final ResourceGrantRepository resourceGrantRepository;
|
||||
private final IntegrationConfigRepository integrationConfigRepository;
|
||||
private final TeamMembershipService teamMembershipService;
|
||||
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
|
||||
@Transactional
|
||||
public void processSSOPostLogin(
|
||||
@@ -148,16 +147,15 @@ public class UserService implements UserServiceInterface {
|
||||
}
|
||||
|
||||
public Authentication getAuthentication(String apiKey) {
|
||||
// Resolve through the shared service (multi-key table, then the legacy per-user column).
|
||||
// The key runs as its owner with the owner's authorities.
|
||||
var resolved =
|
||||
apiKeyAuthenticationService
|
||||
.authenticate(apiKey)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("API key is not valid"));
|
||||
return new UsernamePasswordAuthenticationToken(
|
||||
resolved.user(), // principal
|
||||
null, // credentials (we don't expose the password or API key here)
|
||||
resolved.authorities()); // the owner's authorities
|
||||
Optional<User> user = getUserByApiKey(apiKey);
|
||||
if (user.isEmpty()) {
|
||||
throw new UsernameNotFoundException("API key is not valid");
|
||||
}
|
||||
// Convert the user into an Authentication object
|
||||
return new UsernamePasswordAuthenticationToken( // principal (typically the user)
|
||||
user, // credentials (we don't expose the password or API key here)
|
||||
null, // user's authorities (roles/permissions)
|
||||
getAuthorities(user.get()));
|
||||
}
|
||||
|
||||
private Collection<? extends GrantedAuthority> getAuthorities(User user) {
|
||||
@@ -175,9 +173,6 @@ public class UserService implements UserServiceInterface {
|
||||
|
||||
public User addApiKeyToUser(String username) {
|
||||
Optional<User> userOpt = findByUsernameIgnoreCase(username);
|
||||
// Rotating/regenerating the legacy key must also revoke its migrated api_keys shadow row,
|
||||
// otherwise the old secret keeps authenticating (it resolves from api_keys first).
|
||||
userOpt.map(User::getApiKey).ifPresent(apiKeyAuthenticationService::revokeMigratedKey);
|
||||
User user = saveUser(userOpt, generateApiKey());
|
||||
try {
|
||||
databaseService.exportDatabase();
|
||||
@@ -225,8 +220,7 @@ public class UserService implements UserServiceInterface {
|
||||
}
|
||||
|
||||
public Optional<User> getUserByApiKey(String apiKey) {
|
||||
// Resolves the multi-key api_keys table first, then the legacy per-user column.
|
||||
return apiKeyAuthenticationService.resolveUser(apiKey);
|
||||
return userRepository.findByApiKey(apiKey);
|
||||
}
|
||||
|
||||
public Optional<User> loadUserByApiKey(String apiKey) {
|
||||
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
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 the admin-configured AI settings (model provider/name, per-provider API key, RAG choices,
|
||||
* limits) to the Python engine so a self-hosted deployment can drive the engine's model and API key
|
||||
* from the Stirling settings UI. Pushed on processor startup and again live whenever AI settings
|
||||
* are saved (so model/RAG/limit changes apply without a restart). The engine applies it live
|
||||
* (rebuilds its models) and caches it, so it self-restores on its own reboot. Empty
|
||||
* key/baseUrl/model fields mean "keep the engine's own environment credential".
|
||||
*
|
||||
* <p>Gated by {@code aiEngine.pushConfigToEngine} (default true). Environment-driven deployments
|
||||
* pin it false (SaaS does so in application-saas.properties) so the engine stays entirely
|
||||
* env-controlled and the processor never pushes settings-derived config to it. Best-effort and
|
||||
* non-blocking: a slow or unreachable engine never delays or fails Stirling startup.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@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;
|
||||
|
||||
@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 on a virtual thread with a few retries so we never
|
||||
// block or crash Stirling startup when the engine is slow or briefly unreachable.
|
||||
Thread.ofVirtual().name("ai-engine-config-sync").start(() -> pushWithRetries(cfg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Push AI settings to the engine immediately after an admin save so model/RAG/limit changes
|
||||
* reach the engine without waiting for a processor restart. {@code pendingAiEngine} are the
|
||||
* pending {@code aiEngine.*} dot-notation changes; the running bean overlaid with these is the
|
||||
* current settings.yml state. No-op unless AI is enabled and an engine-relevant key changed.
|
||||
*/
|
||||
public void pushLiveAfterSave(Map<String, Object> pendingAiEngine) {
|
||||
// Gate on the RUNNING bean: AiEngineClient refuses calls while the bean is disabled, so
|
||||
// pushing on a pending-but-not-restarted enable would always fail. The post-restart
|
||||
// startup push covers first-time enablement.
|
||||
AiEngine cfg = applicationProperties.getAiEngine();
|
||||
if (pendingAiEngine == null
|
||||
|| pendingAiEngine.isEmpty()
|
||||
|| !cfg.isPushConfigToEngine()
|
||||
|| !cfg.isEnabled()) {
|
||||
return;
|
||||
}
|
||||
boolean engineRelevant =
|
||||
pendingAiEngine.keySet().stream().anyMatch(AiEngineConfigSync::isEngineRelevantKey);
|
||||
if (!engineRelevant) {
|
||||
return;
|
||||
}
|
||||
ObjectNode node = buildConfigNode(cfg);
|
||||
pendingAiEngine.forEach((k, v) -> overlayIfEngineRelevant(node, k, v));
|
||||
String body = node.toString();
|
||||
Thread.ofVirtual().name("ai-engine-config-live-push").start(() -> pushOnce(body));
|
||||
}
|
||||
|
||||
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.warn(
|
||||
"Live AI engine config push failed: {} (will re-sync on next restart)",
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Only models/rag/limits are forwarded to the engine; enabled/url/timeouts/features are
|
||||
// processor-side and don't need a live engine push.
|
||||
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("\\.");
|
||||
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) {
|
||||
String body = buildConfigNode(cfg).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;
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
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 per-capability AI feature switches ({@code aiEngine.features.*}). Each AI
|
||||
* capability entry point calls the matching {@code require*} method so an admin who turns a feature
|
||||
* off in the settings UI gets a clean 503 instead of the request silently reaching the engine. All
|
||||
* checks also fail closed when the AI engine itself is disabled.
|
||||
*/
|
||||
@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");
|
||||
}
|
||||
}
|
||||
|
||||
public void requireChat() {
|
||||
require(features().isChat(), "chat");
|
||||
}
|
||||
|
||||
public void requireDocumentQuestions() {
|
||||
require(features().isDocumentQuestions(), "documentQuestions");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
+4
-9
@@ -65,13 +65,12 @@ public class PortalDocumentsService {
|
||||
// "API". The automation marker distinguishes a policy-run step from real API traffic.
|
||||
boolean automation = isAutomation(data);
|
||||
String policyName = asString(data.get("policyName"));
|
||||
String origin = asString(data.get("__origin"));
|
||||
String source =
|
||||
automation
|
||||
? (policyName != null && !policyName.isBlank()
|
||||
? "Policy: " + policyName
|
||||
: "Policy automation")
|
||||
: sourceLabel(origin, asString(data.get("__apiKeyLabel")));
|
||||
: sourceLabel(asString(data.get("__origin")));
|
||||
String product = automation ? "Automation" : productLabel(source);
|
||||
String action = prettyTool(path);
|
||||
boolean failed = isFailure(data);
|
||||
@@ -174,12 +173,9 @@ public class PortalDocumentsService {
|
||||
return code instanceof Number n && n.intValue() >= 400;
|
||||
}
|
||||
|
||||
private static String sourceLabel(String origin, String apiKeyLabel) {
|
||||
private static String sourceLabel(String origin) {
|
||||
if ("API".equals(origin)) {
|
||||
// Attribute to the specific named key when known, else the generic API channel.
|
||||
return apiKeyLabel != null && !apiKeyLabel.isBlank()
|
||||
? "API key · " + apiKeyLabel
|
||||
: "API integration";
|
||||
return "API integration";
|
||||
}
|
||||
if ("SYSTEM".equals(origin)) {
|
||||
return "System";
|
||||
@@ -188,8 +184,7 @@ public class PortalDocumentsService {
|
||||
}
|
||||
|
||||
private static String productLabel(String source) {
|
||||
// Covers both the generic "API integration" and per-key "API key · <label>" sources.
|
||||
return source != null && source.startsWith("API") ? "API" : "Editor";
|
||||
return "API integration".equals(source) ? "API" : "Editor";
|
||||
}
|
||||
|
||||
private static boolean isAutomation(Map<String, Object> data) {
|
||||
|
||||
+3
@@ -32,6 +32,7 @@ import stirling.software.proprietary.classification.model.ClassificationLabels;
|
||||
import stirling.software.proprietary.classification.store.InProcessClassificationLabelStore;
|
||||
import stirling.software.proprietary.policy.config.PolicyManagementAuthority;
|
||||
import stirling.software.proprietary.service.AiEngineClient;
|
||||
import stirling.software.proprietary.service.AiFeatureGate;
|
||||
import stirling.software.proprietary.service.PdfContentExtractor;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
@@ -49,6 +50,7 @@ class ClassifyLabelControllerTest {
|
||||
@Mock private PdfContentExtractor pdfContentExtractor;
|
||||
@Mock private PdfMetadataService pdfMetadataService;
|
||||
@Mock private AiEngineClient aiEngineClient;
|
||||
@Mock private AiFeatureGate aiFeatureGate;
|
||||
@Mock private PolicyManagementAuthority policyManagementAuthority;
|
||||
|
||||
private final ObjectMapper objectMapper = JsonMapper.builder().build();
|
||||
@@ -65,6 +67,7 @@ class ClassifyLabelControllerTest {
|
||||
pdfContentExtractor,
|
||||
pdfMetadataService,
|
||||
aiEngineClient,
|
||||
aiFeatureGate,
|
||||
objectMapper,
|
||||
null,
|
||||
labelStore,
|
||||
|
||||
+1
-4
@@ -29,7 +29,6 @@ import stirling.software.proprietary.security.database.repository.PersistentLogi
|
||||
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
|
||||
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
|
||||
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
@@ -161,9 +160,7 @@ class SecurityConfigurationTest {
|
||||
@Test
|
||||
@DisplayName("jwtAuthenticationFilter is created")
|
||||
void jwtAuthenticationFilter() {
|
||||
JwtAuthenticationFilter filter =
|
||||
newConfig(true)
|
||||
.jwtAuthenticationFilter(mock(ApiKeyAuthenticationService.class));
|
||||
JwtAuthenticationFilter filter = newConfig(true).jwtAuthenticationFilter();
|
||||
assertThat(filter).isNotNull();
|
||||
}
|
||||
|
||||
|
||||
+55
-1
@@ -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();
|
||||
}
|
||||
|
||||
@@ -277,6 +286,51 @@ class AdminSettingsControllerTest {
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("drops a masked ******** secret so a UI round-trip can't overwrite a real key")
|
||||
void dropsMaskedSecretValue() {
|
||||
UpdateSettingsRequest request = new UpdateSettingsRequest();
|
||||
Map<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
|
||||
|
||||
+3
-14
@@ -29,8 +29,6 @@ import org.springframework.security.core.session.SessionInformation;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
|
||||
@@ -39,7 +37,6 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
class UserAuthenticationFilterTest {
|
||||
|
||||
@Mock private UserService userService;
|
||||
@Mock private ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
|
||||
|
||||
private ApplicationProperties.Security securityProp;
|
||||
@@ -63,11 +60,7 @@ class UserAuthenticationFilterTest {
|
||||
|
||||
private UserAuthenticationFilter filter(boolean loginEnabled) {
|
||||
return new UserAuthenticationFilter(
|
||||
securityProp,
|
||||
userService,
|
||||
apiKeyAuthenticationService,
|
||||
sessionPersistentRegistry,
|
||||
loginEnabled);
|
||||
securityProp, userService, sessionPersistentRegistry, loginEnabled);
|
||||
}
|
||||
|
||||
private static User enabledUser(String username) {
|
||||
@@ -106,11 +99,7 @@ class UserAuthenticationFilterTest {
|
||||
User user = enabledUser("api-user");
|
||||
user.addAuthority(
|
||||
new stirling.software.proprietary.security.model.Authority("ROLE_USER", user));
|
||||
when(apiKeyAuthenticationService.authenticate("good-key"))
|
||||
.thenReturn(
|
||||
Optional.of(
|
||||
new ApiKeyAuthentication(
|
||||
user, "Prod (sk_demo0000)", user.getAuthorities())));
|
||||
when(userService.getUserByApiKey("good-key")).thenReturn(Optional.of(user));
|
||||
when(userService.usernameExistsIgnoreCase("api-user")).thenReturn(true);
|
||||
when(userService.isUserDisabled("api-user")).thenReturn(false);
|
||||
when(sessionPersistentRegistry.getAllSessions(any(), anyBoolean()))
|
||||
@@ -128,7 +117,7 @@ class UserAuthenticationFilterTest {
|
||||
void invalidApiKeyRejected() throws Exception {
|
||||
request.setRequestURI("/api/v1/some/protected");
|
||||
request.addHeader("X-API-KEY", "bad-key");
|
||||
when(apiKeyAuthenticationService.authenticate("bad-key")).thenReturn(Optional.empty());
|
||||
when(userService.getUserByApiKey("bad-key")).thenReturn(Optional.empty());
|
||||
|
||||
filter(true).doFilter(request, response, filterChain);
|
||||
|
||||
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
package stirling.software.proprietary.security.filter;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.mock.web.MockFilterChain;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("UserBasedRateLimitingFilter")
|
||||
class UserBasedRateLimitingFilterTest {
|
||||
|
||||
@AfterEach
|
||||
void clear() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
private void authenticateAs(String username) {
|
||||
User u = new User();
|
||||
u.setUsername(username);
|
||||
u.setEnabled(true);
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new ApiKeyAuthenticationToken(
|
||||
u,
|
||||
"irrelevant",
|
||||
List.of(new SimpleGrantedAuthority(Role.USER.getRoleId()))));
|
||||
}
|
||||
|
||||
private long remainingAfterApiPost(UserBasedRateLimitingFilter filter, String apiKey)
|
||||
throws Exception {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/x");
|
||||
req.addHeader("X-API-KEY", apiKey);
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
filter.doFilter(req, res, new MockFilterChain());
|
||||
return Long.parseLong(res.getHeader("X-Rate-Limit-Remaining"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("all of a user's keys share ONE bucket - minting keys can't multiply the quota")
|
||||
void keysShareOnePerUserBucket() throws Exception {
|
||||
UserBasedRateLimitingFilter filter = new UserBasedRateLimitingFilter(true);
|
||||
authenticateAs("alice");
|
||||
|
||||
long afterKeyA = remainingAfterApiPost(filter, "key-A");
|
||||
long afterKeyB = remainingAfterApiPost(filter, "key-B"); // different key, same user
|
||||
|
||||
// The second (different) key drew from the SAME per-user bucket, so remaining fell by one.
|
||||
// If it were keyed per-API-key, both would report the same remaining.
|
||||
assertThat(afterKeyB).isEqualTo(afterKeyA - 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("non-POST requests are not rate limited")
|
||||
void nonPostPassesThrough() throws Exception {
|
||||
UserBasedRateLimitingFilter filter = new UserBasedRateLimitingFilter(true);
|
||||
authenticateAs("alice");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("GET", "/api/v1/general/x");
|
||||
req.addHeader("X-API-KEY", "key-A");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
MockFilterChain chain = new MockFilterChain();
|
||||
|
||||
filter.doFilter(req, res, chain);
|
||||
|
||||
assertThat(res.getHeader("X-Rate-Limit-Remaining")).isNull();
|
||||
assertThat(chain.getRequest()).isNotNull(); // passed down the chain
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rate limiting disabled: passes through untouched")
|
||||
void disabledPassesThrough() throws Exception {
|
||||
UserBasedRateLimitingFilter filter = new UserBasedRateLimitingFilter(false);
|
||||
authenticateAs("alice");
|
||||
MockHttpServletRequest req = new MockHttpServletRequest("POST", "/api/v1/general/x");
|
||||
req.addHeader("X-API-KEY", "key-A");
|
||||
MockHttpServletResponse res = new MockHttpServletResponse();
|
||||
|
||||
filter.doFilter(req, res, new MockFilterChain());
|
||||
|
||||
assertThat(res.getHeader("X-Rate-Limit-Remaining")).isNull();
|
||||
}
|
||||
}
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.ApiKey;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("ApiKeyAuthenticationService")
|
||||
class ApiKeyAuthenticationServiceTest {
|
||||
|
||||
@Mock private ApiKeyRepository apiKeyRepository;
|
||||
@Mock private ApiKeyUsageRecorder usageRecorder;
|
||||
@Mock private UserRepository userRepository;
|
||||
@InjectMocks private ApiKeyAuthenticationService service;
|
||||
|
||||
private User user(long id, boolean enabled) {
|
||||
User u = new User();
|
||||
u.setId(id);
|
||||
u.setUsername("user" + id);
|
||||
u.setEnabled(enabled);
|
||||
return u;
|
||||
}
|
||||
|
||||
private ApiKey key(long id, long ownerId, boolean enabled, Instant revoked) {
|
||||
return ApiKey.builder()
|
||||
.id(id)
|
||||
.name("Production ingest")
|
||||
.keyHash(ApiKeyHasher.hash("raw-" + id))
|
||||
.prefix("sk_demo0000")
|
||||
.ownerUserId(ownerId)
|
||||
.enabled(enabled)
|
||||
.revokedAt(revoked)
|
||||
.createdAt(Instant.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolves an active multi-key to its owner and records usage")
|
||||
void resolvesActiveKey() {
|
||||
String raw = "raw-1";
|
||||
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
|
||||
.thenReturn(Optional.of(key(1, 7, true, null)));
|
||||
when(userRepository.findById(7L)).thenReturn(Optional.of(user(7, true)));
|
||||
|
||||
var result = service.authenticate(raw);
|
||||
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().user().getId()).isEqualTo(7L);
|
||||
assertThat(result.get().auditLabel()).isEqualTo("Production ingest (sk_demo0000)");
|
||||
verify(usageRecorder).record(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a revoked key without recording usage")
|
||||
void rejectsRevokedKey() {
|
||||
String raw = "raw-2";
|
||||
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
|
||||
.thenReturn(Optional.of(key(2, 7, true, Instant.now())));
|
||||
|
||||
assertThat(service.authenticate(raw)).isEmpty();
|
||||
verifyNoInteractions(usageRecorder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a key whose owner is disabled")
|
||||
void rejectsDisabledOwner() {
|
||||
String raw = "raw-3";
|
||||
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
|
||||
.thenReturn(Optional.of(key(3, 8, true, null)));
|
||||
when(userRepository.findById(8L)).thenReturn(Optional.of(user(8, false)));
|
||||
|
||||
assertThat(service.authenticate(raw)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("falls back to the legacy per-user column, with no per-key label")
|
||||
void legacyFallback() {
|
||||
String raw = "legacy-key";
|
||||
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw))).thenReturn(Optional.empty());
|
||||
when(userRepository.findByApiKey(raw)).thenReturn(Optional.of(user(9, true)));
|
||||
|
||||
var result = service.authenticate(raw);
|
||||
|
||||
assertThat(result).isPresent();
|
||||
assertThat(result.get().user().getId()).isEqualTo(9L);
|
||||
assertThat(result.get().auditLabel()).isNull();
|
||||
verifyNoInteractions(usageRecorder);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("blank keys resolve to nothing")
|
||||
void blankKey() {
|
||||
assertThat(service.authenticate(" ")).isEmpty();
|
||||
assertThat(service.resolveUser(null)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a key authenticates with its owner's authorities (owner acts as self)")
|
||||
void keyKeepsOwnerAuthorities() {
|
||||
String raw = "raw-6";
|
||||
User owner = user(8, true);
|
||||
owner.addAuthority(new Authority(Role.ADMIN.getRoleId(), owner));
|
||||
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
|
||||
.thenReturn(Optional.of(key(6, 8, true, null)));
|
||||
when(userRepository.findById(8L)).thenReturn(Optional.of(owner));
|
||||
|
||||
var result = service.authenticate(raw);
|
||||
|
||||
List<String> auths =
|
||||
result.get().authorities().stream().map(GrantedAuthority::getAuthority).toList();
|
||||
assertThat(auths).contains(Role.ADMIN.getRoleId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("revokeMigratedKey disables the shadow row so a rotated legacy key stops working")
|
||||
void revokeMigratedKeyRevokesRow() {
|
||||
String raw = "raw-9";
|
||||
ApiKey shadow = key(9, 1, true, null);
|
||||
when(apiKeyRepository.findByKeyHash(ApiKeyHasher.hash(raw)))
|
||||
.thenReturn(Optional.of(shadow));
|
||||
|
||||
service.revokeMigratedKey(raw);
|
||||
|
||||
assertThat(shadow.isEnabled()).isFalse();
|
||||
assertThat(shadow.getRevokedAt()).isNotNull();
|
||||
verify(apiKeyRepository).save(shadow);
|
||||
}
|
||||
}
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@DisplayName("ApiKeyHasher")
|
||||
class ApiKeyHasherTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("generated keys are unique, prefixed, and hash deterministically")
|
||||
void generateAndHash() {
|
||||
String a = ApiKeyHasher.generateRawKey();
|
||||
String b = ApiKeyHasher.generateRawKey();
|
||||
|
||||
assertThat(a).startsWith("sk_").isNotEqualTo(b);
|
||||
// Same input hashes the same; SHA-256 hex is 64 chars.
|
||||
assertThat(ApiKeyHasher.hash(a)).isEqualTo(ApiKeyHasher.hash(a)).hasSize(64);
|
||||
assertThat(ApiKeyHasher.hash(a)).isNotEqualTo(ApiKeyHasher.hash(b));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("hash never returns the raw key")
|
||||
void hashHidesRaw() {
|
||||
String raw = ApiKeyHasher.generateRawKey();
|
||||
assertThat(ApiKeyHasher.hash(raw)).isNotEqualTo(raw);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("display prefix is a short non-secret leading fragment")
|
||||
void displayPrefix() {
|
||||
String raw = ApiKeyHasher.generateRawKey();
|
||||
String prefix = ApiKeyHasher.displayPrefix(raw);
|
||||
assertThat(prefix).hasSize(11).startsWith("sk_");
|
||||
assertThat(raw).startsWith(prefix);
|
||||
}
|
||||
}
|
||||
-198
@@ -1,198 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.proprietary.model.api.apikey.CreateApiKeyRequest;
|
||||
import stirling.software.proprietary.model.api.apikey.CreatedApiKeyDto;
|
||||
import stirling.software.proprietary.model.api.apikey.PortalApiKeysResponse;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.ApiKey;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyDailyUsageRepository;
|
||||
import stirling.software.proprietary.security.repository.ApiKeyRepository;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("ApiKeyManagementService")
|
||||
class ApiKeyManagementServiceTest {
|
||||
|
||||
@Mock private ApiKeyRepository apiKeyRepository;
|
||||
@Mock private ApiKeyDailyUsageRepository usageRepository;
|
||||
@Mock private UserRepository userRepository;
|
||||
@Mock private UserService userService;
|
||||
@Mock private ApiKeyLegacyMigrator legacyMigrator;
|
||||
@InjectMocks private ApiKeyManagementService service;
|
||||
|
||||
private User caller;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
caller = new User();
|
||||
caller.setId(1L);
|
||||
caller.setUsername("alice");
|
||||
lenient().when(userService.getCurrentUsername()).thenReturn("alice");
|
||||
lenient()
|
||||
.when(userService.findByUsernameIgnoreCase("alice"))
|
||||
.thenReturn(Optional.of(caller));
|
||||
lenient().when(apiKeyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||
lenient().when(usageRepository.countForDayByIds(any(), anyLong())).thenReturn(List.of());
|
||||
lenient().when(usageRepository.sumSinceByIds(any(), anyLong())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
private ApiKey personalKey(long id, long ownerId) {
|
||||
return ApiKey.builder()
|
||||
.id(id)
|
||||
.name("Key " + id)
|
||||
.keyHash("hash" + id)
|
||||
.prefix("sk_demo0000")
|
||||
.ownerUserId(ownerId)
|
||||
.enabled(true)
|
||||
.createdAt(Instant.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
// ---- migration safety ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("an existing legacy key migrates to an owner-only row")
|
||||
void legacyKeyMigratesAsPersonal() {
|
||||
caller.setApiKey("legacy-raw-key");
|
||||
when(apiKeyRepository.existsByKeyHash(ApiKeyHasher.hash("legacy-raw-key")))
|
||||
.thenReturn(false);
|
||||
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of());
|
||||
|
||||
service.listVisibleKeys();
|
||||
|
||||
// Migration insert is isolated in its own transaction (ApiKeyLegacyMigrator).
|
||||
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
|
||||
verify(legacyMigrator).insertMigratedKey(saved.capture());
|
||||
ApiKey migrated = saved.getValue();
|
||||
assertThat(migrated.getOwnerUserId()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("migration is idempotent - an already-migrated legacy key is not re-saved")
|
||||
void legacyKeyMigrationIdempotent() {
|
||||
caller.setApiKey("legacy-raw-key");
|
||||
when(apiKeyRepository.existsByKeyHash(ApiKeyHasher.hash("legacy-raw-key")))
|
||||
.thenReturn(true);
|
||||
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L)).thenReturn(List.of());
|
||||
|
||||
service.listVisibleKeys();
|
||||
|
||||
verify(legacyMigrator, never()).insertMigratedKey(any());
|
||||
}
|
||||
|
||||
// ---- personal isolation -------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("listing scopes keys to the caller by owner id")
|
||||
void personalKeysScopedToOwner() {
|
||||
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L))
|
||||
.thenReturn(List.of(personalKey(10, 1L)));
|
||||
|
||||
PortalApiKeysResponse res = service.listVisibleKeys();
|
||||
|
||||
assertThat(res.keys()).singleElement().satisfies(k -> assertThat(k.id()).isEqualTo("10"));
|
||||
// Isolation: the query is keyed by the caller's id, never a broad scan.
|
||||
verify(apiKeyRepository).findByOwnerUserIdOrderByCreatedAtDesc(1L);
|
||||
}
|
||||
|
||||
// ---- creation -----------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("a user creates a personal key and gets a one-time secret")
|
||||
void createPersonalKey() {
|
||||
CreatedApiKeyDto created = service.createKey(new CreateApiKeyRequest("My key"));
|
||||
|
||||
assertThat(created.secret()).startsWith("sk_");
|
||||
ArgumentCaptor<ApiKey> saved = ArgumentCaptor.forClass(ApiKey.class);
|
||||
verify(apiKeyRepository).save(saved.capture());
|
||||
assertThat(saved.getValue().getOwnerUserId()).isEqualTo(1L);
|
||||
assertThat(saved.getValue().getName()).isEqualTo("My key");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects an over-long key name")
|
||||
void rejectsLongName() {
|
||||
String longName = "a".repeat(101);
|
||||
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(longName)))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("characters or fewer");
|
||||
verify(apiKeyRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects a blank key name")
|
||||
void rejectsBlankName() {
|
||||
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest(" ")))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("required");
|
||||
verify(apiKeyRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("rejects creating a key past the per-user active-key cap")
|
||||
void rejectsPastActiveKeyCap() {
|
||||
when(apiKeyRepository.findByOwnerUserIdOrderByCreatedAtDesc(1L))
|
||||
.thenReturn(java.util.Collections.nCopies(50, personalKey(100, 1L)));
|
||||
|
||||
assertThatThrownBy(() -> service.createKey(new CreateApiKeyRequest("One too many")))
|
||||
.isInstanceOf(ResponseStatusException.class);
|
||||
verify(apiKeyRepository, never()).save(any());
|
||||
}
|
||||
|
||||
// ---- revocation ---------------------------------------------------------
|
||||
|
||||
@Test
|
||||
@DisplayName("owner revokes their key and the legacy column is cleared")
|
||||
void revokePersonalClearsLegacy() {
|
||||
caller.setApiKey("legacy-raw-key");
|
||||
ApiKey legacyRow = personalKey(30, 1L);
|
||||
legacyRow.setKeyHash(ApiKeyHasher.hash("legacy-raw-key"));
|
||||
when(apiKeyRepository.findById(30L)).thenReturn(Optional.of(legacyRow));
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(caller));
|
||||
|
||||
service.revokeKey(30L);
|
||||
|
||||
assertThat(legacyRow.isEnabled()).isFalse();
|
||||
assertThat(legacyRow.getRevokedAt()).isNotNull();
|
||||
assertThat(caller.getApiKey()).isNull();
|
||||
verify(userRepository).save(caller);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"a non-owner cannot revoke someone else's key (404, not 403, so ids can't be probed)")
|
||||
void revokeForeignKeyForbidden() {
|
||||
when(apiKeyRepository.findById(31L)).thenReturn(Optional.of(personalKey(31, 999L)));
|
||||
|
||||
assertThatThrownBy(() -> service.revokeKey(31L))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(
|
||||
e ->
|
||||
assertThat(((ResponseStatusException) e).getStatusCode().value())
|
||||
.isEqualTo(404));
|
||||
verify(apiKeyRepository, never()).save(any());
|
||||
}
|
||||
}
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
/**
|
||||
* Unit tests for the increment/insert/increment race protocol. {@code @Async} has no proxy in a
|
||||
* plain Mockito test, so {@code record()} runs inline and is directly testable.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("ApiKeyUsageRecorder")
|
||||
class ApiKeyUsageRecorderTest {
|
||||
|
||||
private static final long KEY = 7L;
|
||||
|
||||
@Mock private ApiKeyUsageWriter writer;
|
||||
@InjectMocks private ApiKeyUsageRecorder recorder;
|
||||
|
||||
@Test
|
||||
@DisplayName("a null key id is a no-op")
|
||||
void nullIdIsNoOp() {
|
||||
recorder.record(null);
|
||||
verifyNoInteractions(writer);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("row already exists: one increment, never inserts")
|
||||
void rowExistsFastPath() {
|
||||
when(writer.increment(eq(KEY), anyLong())).thenReturn(1);
|
||||
|
||||
recorder.record(KEY);
|
||||
|
||||
verify(writer, times(1)).increment(eq(KEY), anyLong());
|
||||
verify(writer, never()).tryInsertFirstUse(anyLong(), anyLong());
|
||||
verify(writer).stampLastUsed(KEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("first writer of the day: increment misses, insert wins, no second increment")
|
||||
void firstWriterInserts() {
|
||||
when(writer.increment(eq(KEY), anyLong())).thenReturn(0);
|
||||
when(writer.tryInsertFirstUse(eq(KEY), anyLong())).thenReturn(true);
|
||||
|
||||
recorder.record(KEY);
|
||||
|
||||
verify(writer, times(1)).increment(eq(KEY), anyLong());
|
||||
verify(writer).tryInsertFirstUse(eq(KEY), anyLong());
|
||||
verify(writer).stampLastUsed(KEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName(
|
||||
"lost the insert race: falls back to a second increment so the count is not dropped")
|
||||
void lostInsertRaceReincrements() {
|
||||
when(writer.increment(eq(KEY), anyLong())).thenReturn(0);
|
||||
when(writer.tryInsertFirstUse(eq(KEY), anyLong())).thenReturn(false);
|
||||
|
||||
recorder.record(KEY);
|
||||
|
||||
verify(writer, times(2)).increment(eq(KEY), anyLong());
|
||||
verify(writer).stampLastUsed(KEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("insert throws (rollback-only commit): still re-increments, count not dropped")
|
||||
void insertThrowsStillReincrements() {
|
||||
when(writer.increment(eq(KEY), anyLong())).thenReturn(0);
|
||||
when(writer.tryInsertFirstUse(eq(KEY), anyLong()))
|
||||
.thenThrow(new RuntimeException("UnexpectedRollbackException"));
|
||||
|
||||
recorder.record(KEY);
|
||||
|
||||
verify(writer, times(2)).increment(eq(KEY), anyLong());
|
||||
verify(writer).stampLastUsed(KEY);
|
||||
}
|
||||
}
|
||||
+2
-5
@@ -41,7 +41,6 @@ import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
|
||||
import stirling.software.proprietary.storage.repository.FileShareRepository;
|
||||
@@ -77,7 +76,6 @@ class UserServiceMoreTest {
|
||||
integrationConfigRepository;
|
||||
|
||||
@Mock private TeamMembershipService teamMembershipService;
|
||||
@Mock private ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
|
||||
@InjectMocks private UserService userService;
|
||||
|
||||
@@ -101,8 +99,7 @@ class UserServiceMoreTest {
|
||||
void getAuthenticationValid() {
|
||||
User u = user("api");
|
||||
u.addAuthority(new Authority("ROLE_USER", u));
|
||||
when(apiKeyAuthenticationService.authenticate("k"))
|
||||
.thenReturn(Optional.of(new ApiKeyAuthentication(u, null, u.getAuthorities())));
|
||||
when(userRepository.findByApiKey("k")).thenReturn(Optional.of(u));
|
||||
|
||||
assertThat(userService.getAuthentication("k")).isNotNull();
|
||||
}
|
||||
@@ -110,7 +107,7 @@ class UserServiceMoreTest {
|
||||
@Test
|
||||
@DisplayName("getAuthentication throws when key is unknown")
|
||||
void getAuthenticationInvalid() {
|
||||
when(apiKeyAuthenticationService.authenticate("bad")).thenReturn(Optional.empty());
|
||||
when(userRepository.findByApiKey("bad")).thenReturn(Optional.empty());
|
||||
|
||||
assertThatThrownBy(() -> userService.getAuthentication("bad"))
|
||||
.isInstanceOf(UsernameNotFoundException.class);
|
||||
|
||||
-22
@@ -71,7 +71,6 @@ class UserServiceTest {
|
||||
integrationConfigRepository;
|
||||
|
||||
@Mock private TeamMembershipService teamMembershipService;
|
||||
@Mock private ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
|
||||
@Spy @InjectMocks private UserService userService;
|
||||
|
||||
@@ -197,27 +196,6 @@ class UserServiceTest {
|
||||
verify(userRepository).save(user);
|
||||
}
|
||||
|
||||
@Test
|
||||
void addApiKeyToUserRevokesOldMigratedShadowRow() {
|
||||
User user = new User();
|
||||
user.setUsername("user");
|
||||
user.setApiKey("old-secret");
|
||||
when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user));
|
||||
when(userRepository.findByApiKey(any())).thenReturn(Optional.empty());
|
||||
when(userRepository.save(any(User.class)))
|
||||
.thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
User updated = userService.addApiKeyToUser("user");
|
||||
|
||||
// Rotating a legacy key must revoke its migrated api_keys shadow row with the OLD secret,
|
||||
// and do so before the new key is generated - otherwise the old secret keeps
|
||||
// authenticating.
|
||||
org.mockito.InOrder inOrder = inOrder(apiKeyAuthenticationService, userRepository);
|
||||
inOrder.verify(apiKeyAuthenticationService).revokeMigratedKey("old-secret");
|
||||
inOrder.verify(userRepository).save(user);
|
||||
assertNotEquals("old-secret", updated.getApiKey());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getApiKeyForUserCreatesWhenMissing() {
|
||||
User user = new User();
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.timeout;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.ApplicationProperties.AiEngine;
|
||||
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* The config-push bridge is a self-hosted-only feature: it lets the admin UI drive the engine's
|
||||
* model/key/RAG/limit config. Environment-driven deployments pin {@code
|
||||
* aiEngine.pushConfigToEngine} false (SaaS does so in application-saas.properties) so the engine
|
||||
* stays entirely env-controlled - these tests lock in that the processor stays silent then while
|
||||
* still pushing when it is enabled.
|
||||
*/
|
||||
class AiEngineConfigSyncTest {
|
||||
|
||||
private ApplicationProperties applicationProperties;
|
||||
private AiEngineClient aiEngineClient;
|
||||
private AiEngineConfigSync sync;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.getAiEngine().setEnabled(true);
|
||||
applicationProperties.getAiEngine().setPushConfigToEngine(true);
|
||||
aiEngineClient = mock(AiEngineClient.class);
|
||||
ObjectMapper objectMapper = JsonMapper.builder().build();
|
||||
sync = new AiEngineConfigSync(applicationProperties, aiEngineClient, objectMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupPushSkippedWhenPushDisabled() throws Exception {
|
||||
applicationProperties.getAiEngine().setPushConfigToEngine(false);
|
||||
|
||||
sync.pushConfigOnStartup();
|
||||
|
||||
// Returns synchronously before spawning the push thread, so no interaction ever happens.
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupPushSkippedWhenDisabled() throws Exception {
|
||||
applicationProperties.getAiEngine().setEnabled(false);
|
||||
|
||||
sync.pushConfigOnStartup();
|
||||
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupPushSentWhenEnabledAndPushOn() throws Exception {
|
||||
sync.pushConfigOnStartup();
|
||||
|
||||
// Push runs on a virtual thread; wait for the single POST to /api/v1/config.
|
||||
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), anyString(), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void livePushSkippedWhenPushDisabled() throws Exception {
|
||||
applicationProperties.getAiEngine().setPushConfigToEngine(false);
|
||||
|
||||
sync.pushLiveAfterSave(Map.of("aiEngine.models.provider", "ollama"));
|
||||
|
||||
verify(aiEngineClient, never()).post(anyString(), anyString(), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void livePushSentForEngineRelevantChangeWhenPushOn() throws Exception {
|
||||
sync.pushLiveAfterSave(Map.of("aiEngine.models.provider", "ollama"));
|
||||
|
||||
verify(aiEngineClient, timeout(3000)).post(eq("/api/v1/config"), anyString(), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupPushSerialisesTheEngineWireContract() throws Exception {
|
||||
// Distinct values so a dropped/renamed field is detectable. Mirrors
|
||||
// engine/tests/fixtures/processor_config_push.json - keep the two in sync; the engine's
|
||||
// test_config_contract.py validates that same shape on the receiving side.
|
||||
AiEngine ai = applicationProperties.getAiEngine();
|
||||
ai.getModels().setProvider("ollama");
|
||||
ai.getModels().setSmartModel("smart-model-x");
|
||||
ai.getModels().setFastModel("fast-model-x");
|
||||
ai.getModels().setSmartMaxTokens(1111);
|
||||
ai.getModels().setFastMaxTokens(2222);
|
||||
ai.getModels().setApiKey("provider-key-abc");
|
||||
ai.getModels().setBaseUrl("http://engine.example/v1");
|
||||
ai.getRag().setEmbeddingProvider("custom");
|
||||
ai.getRag().setEmbeddingModel("embed-model-x");
|
||||
ai.getRag().setEmbeddingApiKey("embed-key-abc");
|
||||
ai.getRag().setEmbeddingBaseUrl("http://embed.example/v1");
|
||||
ai.getRag().setTopK(33);
|
||||
ai.getRag().setMaxSearches(7);
|
||||
ai.getLimits().setMaxPages(111);
|
||||
ai.getLimits().setMaxCharacters(222222);
|
||||
ai.getLimits().setModelMaxConcurrency(9);
|
||||
|
||||
ArgumentCaptor<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());
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package stirling.software.proprietary.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Every AI capability entry point calls the matching {@code require*} before reaching the engine.
|
||||
* These lock in fail-closed behaviour: a 503 when the engine is disabled OR the individual feature
|
||||
* flag is off, and a clean pass only when both are on.
|
||||
*/
|
||||
class AiFeatureGateTest {
|
||||
|
||||
private ApplicationProperties props;
|
||||
private AiFeatureGate gate;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
props = new ApplicationProperties();
|
||||
props.getAiEngine().setEnabled(true); // features default all-on
|
||||
gate = new AiFeatureGate(props);
|
||||
}
|
||||
|
||||
@Test
|
||||
void passesWhenEngineEnabledAndFeatureOn() {
|
||||
assertDoesNotThrow(() -> gate.requireClassify());
|
||||
assertDoesNotThrow(() -> gate.requireChat());
|
||||
}
|
||||
|
||||
@Test
|
||||
void throws503WhenFeatureFlagOff() {
|
||||
props.getAiEngine().getFeatures().setClassify(false);
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(ResponseStatusException.class, () -> gate.requireClassify());
|
||||
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void throws503WhenEngineDisabledEvenIfFeatureOn() {
|
||||
props.getAiEngine().setEnabled(false); // feature flag still true
|
||||
|
||||
ResponseStatusException ex =
|
||||
assertThrows(ResponseStatusException.class, () -> gate.requireChat());
|
||||
assertEquals(HttpStatus.SERVICE_UNAVAILABLE, ex.getStatusCode());
|
||||
}
|
||||
}
|
||||
-14
@@ -70,18 +70,4 @@ class PortalDocumentsServiceTest {
|
||||
assertThat(doc.getProduct()).isEqualTo("API");
|
||||
assertThat(doc.getSource()).isEqualTo("API integration");
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiDocumentIsAttributedToItsNamedKey() {
|
||||
PortalReviewDocumentDto doc =
|
||||
onlyDoc(
|
||||
"{\"path\":\"/api/v1/misc/compress-pdf\",\"__origin\":\"API\","
|
||||
+ "\"__apiKeyLabel\":\"Production ingest (sk_demo0000)\","
|
||||
+ "\"files\":[{\"name\":\"a.pdf\",\"type\":\"application/pdf\"}],"
|
||||
+ "\"statusCode\":200}");
|
||||
|
||||
// The specific key label surfaces as the source; product stays "API".
|
||||
assertThat(doc.getProduct()).isEqualTo("API");
|
||||
assertThat(doc.getSource()).isEqualTo("API key · Production ingest (sk_demo0000)");
|
||||
}
|
||||
}
|
||||
|
||||
+5
-20
@@ -18,7 +18,6 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
@@ -44,8 +43,6 @@ import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService.ApiKeyAuthentication;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.saas.model.AmrMethod;
|
||||
@@ -68,7 +65,6 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
||||
private final SupabaseUserService supabaseUserService;
|
||||
private final SaasTeamService saasTeamService;
|
||||
private final JwtDecoder jwtDecoder;
|
||||
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
private final AuthenticationEntryPoint authenticationEntryPoint =
|
||||
new BearerTokenAuthenticationEntryPoint();
|
||||
|
||||
@@ -77,14 +73,12 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
||||
UserService userService,
|
||||
SupabaseUserService supabaseUserService,
|
||||
SaasTeamService saasTeamService,
|
||||
JwtDecoder jwtDecoder,
|
||||
ApiKeyAuthenticationService apiKeyAuthenticationService) {
|
||||
JwtDecoder jwtDecoder) {
|
||||
this.teamService = teamService;
|
||||
this.userService = userService;
|
||||
this.supabaseUserService = supabaseUserService;
|
||||
this.saasTeamService = saasTeamService;
|
||||
this.jwtDecoder = jwtDecoder;
|
||||
this.apiKeyAuthenticationService = apiKeyAuthenticationService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -92,9 +86,6 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
||||
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
// Start clean so a pooled thread can't inherit a prior request's API-key label.
|
||||
MDC.remove(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY);
|
||||
|
||||
if (isStaticResource(request.getContextPath(), request.getRequestURI())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
@@ -415,22 +406,16 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolves the multi-key table then the legacy key, records per-key usage, and yields a
|
||||
// label for the processor's document-source attribution.
|
||||
Optional<ApiKeyAuthentication> resolved = apiKeyAuthenticationService.authenticate(apiKey);
|
||||
if (resolved.isEmpty()) {
|
||||
Optional<User> user = userService.getUserByApiKey(apiKey);
|
||||
if (user.isEmpty()) {
|
||||
throw new InvalidBearerTokenException("Invalid API Key.");
|
||||
}
|
||||
User user = resolved.get().user();
|
||||
|
||||
userService.trackApiKeyFirstUse(user);
|
||||
userService.trackApiKeyFirstUse(user.get());
|
||||
|
||||
ApiKeyAuthenticationToken authToken =
|
||||
new ApiKeyAuthenticationToken(user, apiKey, resolved.get().authorities());
|
||||
new ApiKeyAuthenticationToken(user.get(), apiKey, user.get().getAuthorities());
|
||||
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||
if (resolved.get().auditLabel() != null) {
|
||||
MDC.put(ApiKeyAuthenticationService.AUDIT_LABEL_MDC_KEY, resolved.get().auditLabel());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.RequestUriUtils;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.saas.accountlink.DeviceCredentialAuthenticationFilter;
|
||||
@@ -70,7 +69,6 @@ public class SupabaseSecurityConfig {
|
||||
private final SupabaseUserService supabaseUserService;
|
||||
private final SaasTeamService saasTeamService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final ApiKeyAuthenticationService apiKeyAuthenticationService;
|
||||
|
||||
@Value("${app.supabase.issuer:}")
|
||||
private String issuer;
|
||||
@@ -127,8 +125,7 @@ public class SupabaseSecurityConfig {
|
||||
userService,
|
||||
supabaseUserService,
|
||||
saasTeamService,
|
||||
jwtDecoder,
|
||||
apiKeyAuthenticationService),
|
||||
jwtDecoder),
|
||||
BearerTokenAuthenticationFilter.class)
|
||||
.exceptionHandling(
|
||||
ex ->
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
# Stirling-PDF SaaS profile. Pure multi-tenant cloud.
|
||||
# Activated when the :saas module is on the classpath.
|
||||
|
||||
# ---------- AI engine ----------
|
||||
# The SaaS AI backend is env-driven and reached through the saas AiProxyController, so the
|
||||
# processor must never push settings-derived config to it. Pin the config-push off; the engine
|
||||
# stays entirely environment-controlled.
|
||||
aiEngine.pushConfigToEngine=false
|
||||
|
||||
# ---------- Datasource ----------
|
||||
system.datasource.enableCustomDatabase=true
|
||||
system.datasource.customDatabaseUrl=${SAAS_DB_URL:}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
-- Named, multi-key personal API keys, plus per-key daily usage.
|
||||
-- Idempotent: Hibernate ddl-auto=update may already have created these on some deployments.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
key_hash VARCHAR(64) NOT NULL,
|
||||
prefix VARCHAR(32) NOT NULL,
|
||||
owner_user_id BIGINT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_key_hash ON api_keys (key_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_key_owner ON api_keys (owner_user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_key_daily_usage (
|
||||
api_key_id BIGINT NOT NULL,
|
||||
epoch_day BIGINT NOT NULL,
|
||||
count BIGINT NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (api_key_id, epoch_day)
|
||||
);
|
||||
+2
-16
@@ -58,10 +58,6 @@ class SupabaseAuthenticationFilterMoreTest {
|
||||
@Mock private SaasTeamService saasTeamService;
|
||||
@Mock private JwtDecoder jwtDecoder;
|
||||
|
||||
@Mock
|
||||
private stirling.software.proprietary.security.service.ApiKeyAuthenticationService
|
||||
apiKeyAuthenticationService;
|
||||
|
||||
private SupabaseAuthenticationFilter filter;
|
||||
private MockHttpServletRequest request;
|
||||
private MockHttpServletResponse response;
|
||||
@@ -72,12 +68,7 @@ class SupabaseAuthenticationFilterMoreTest {
|
||||
SecurityContextHolder.clearContext();
|
||||
filter =
|
||||
new SupabaseAuthenticationFilter(
|
||||
teamService,
|
||||
userService,
|
||||
supabaseUserService,
|
||||
saasTeamService,
|
||||
jwtDecoder,
|
||||
apiKeyAuthenticationService);
|
||||
teamService, userService, supabaseUserService, saasTeamService, jwtDecoder);
|
||||
request = new MockHttpServletRequest();
|
||||
response = new MockHttpServletResponse();
|
||||
chain = new MockFilterChain();
|
||||
@@ -243,12 +234,7 @@ class SupabaseAuthenticationFilterMoreTest {
|
||||
@DisplayName("returns true and skips lookup when an api key sets an authenticated context")
|
||||
void apiKeyValidStillAuthenticates() throws Exception {
|
||||
User user = newUser("alice");
|
||||
when(apiKeyAuthenticationService.authenticate("k1"))
|
||||
.thenReturn(
|
||||
Optional.of(
|
||||
new stirling.software.proprietary.security.service
|
||||
.ApiKeyAuthenticationService.ApiKeyAuthentication(
|
||||
user, null, user.getAuthorities())));
|
||||
when(userService.getUserByApiKey("k1")).thenReturn(Optional.of(user));
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
|
||||
+3
-17
@@ -48,10 +48,6 @@ class SupabaseAuthenticationFilterTest {
|
||||
@Mock private stirling.software.saas.service.SaasTeamService saasTeamService;
|
||||
@Mock private JwtDecoder jwtDecoder;
|
||||
|
||||
@Mock
|
||||
private stirling.software.proprietary.security.service.ApiKeyAuthenticationService
|
||||
apiKeyAuthenticationService;
|
||||
|
||||
private SupabaseAuthenticationFilter filter;
|
||||
private MockHttpServletRequest request;
|
||||
private MockHttpServletResponse response;
|
||||
@@ -62,12 +58,7 @@ class SupabaseAuthenticationFilterTest {
|
||||
SecurityContextHolder.clearContext();
|
||||
filter =
|
||||
new SupabaseAuthenticationFilter(
|
||||
teamService,
|
||||
userService,
|
||||
supabaseUserService,
|
||||
saasTeamService,
|
||||
jwtDecoder,
|
||||
apiKeyAuthenticationService);
|
||||
teamService, userService, supabaseUserService, saasTeamService, jwtDecoder);
|
||||
request = new MockHttpServletRequest();
|
||||
response = new MockHttpServletResponse();
|
||||
chain = new MockFilterChain();
|
||||
@@ -94,12 +85,7 @@ class SupabaseAuthenticationFilterTest {
|
||||
void apiKeyHeaderPopulatesSecurityContext() throws Exception {
|
||||
User user = newUser("alice");
|
||||
user.setApiKey("api-key-123");
|
||||
when(apiKeyAuthenticationService.authenticate("api-key-123"))
|
||||
.thenReturn(
|
||||
Optional.of(
|
||||
new stirling.software.proprietary.security.service
|
||||
.ApiKeyAuthenticationService.ApiKeyAuthentication(
|
||||
user, null, user.getAuthorities())));
|
||||
when(userService.getUserByApiKey("api-key-123")).thenReturn(Optional.of(user));
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
@@ -117,7 +103,7 @@ class SupabaseAuthenticationFilterTest {
|
||||
|
||||
@Test
|
||||
void invalidApiKeyTriggers401() throws Exception {
|
||||
when(apiKeyAuthenticationService.authenticate("nope")).thenReturn(Optional.empty());
|
||||
when(userService.getUserByApiKey("nope")).thenReturn(Optional.empty());
|
||||
|
||||
request.setRequestURI("/api/v1/something");
|
||||
request.setMethod("POST");
|
||||
|
||||
+1
-10
@@ -43,18 +43,9 @@ class SupabaseSecurityConfigMoreTest {
|
||||
@Mock private SupabaseUserService supabaseUserService;
|
||||
@Mock private SaasTeamService saasTeamService;
|
||||
|
||||
@Mock
|
||||
private stirling.software.proprietary.security.service.ApiKeyAuthenticationService
|
||||
apiKeyAuthenticationService;
|
||||
|
||||
private SupabaseSecurityConfig config(ApplicationProperties props) {
|
||||
return new SupabaseSecurityConfig(
|
||||
userService,
|
||||
teamService,
|
||||
supabaseUserService,
|
||||
saasTeamService,
|
||||
props,
|
||||
apiKeyAuthenticationService);
|
||||
userService, teamService, supabaseUserService, saasTeamService, props);
|
||||
}
|
||||
|
||||
@Nested
|
||||
|
||||
-22
@@ -21,7 +21,6 @@ import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import stirling.software.common.model.enumeration.TeamRole;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.model.TeamMembership;
|
||||
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamMembershipRepository;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
@@ -108,27 +107,6 @@ class TeamSecurityExpressionsTest {
|
||||
assertNull(expressions().currentUserTeamId());
|
||||
}
|
||||
|
||||
private User leaderUser() {
|
||||
User leader = new User();
|
||||
leader.setId(USER_ID);
|
||||
Team team = new Team();
|
||||
team.setId(TEAM_ID);
|
||||
leader.setTeam(team);
|
||||
return leader;
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKeyOfALeaderStillLeads() {
|
||||
// A key acts as the owner; if they lead the team, the key leads.
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(
|
||||
new ApiKeyAuthenticationToken(leaderUser(), "sk_personal", List.of()));
|
||||
when(membershipRepository.findByTeamIdAndUserId(TEAM_ID, USER_ID))
|
||||
.thenReturn(Optional.of(membershipWithRole(TeamRole.LEADER)));
|
||||
|
||||
assertTrue(expressions().isCurrentUserTeamLeader());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unauthenticatedIsNotLeader() {
|
||||
// No authentication set on the context.
|
||||
|
||||
@@ -88,3 +88,8 @@ 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
|
||||
|
||||
# Allow the Java processor to push admin-configured AI settings (model,
|
||||
# credentials, limits) to POST /api/v1/config at startup. Set false in
|
||||
# environment-driven deployments so the environment is the single source of truth.
|
||||
#STIRLING_ALLOW_CONFIG_PUSH=true
|
||||
|
||||
@@ -4,6 +4,7 @@ version = "0.1.0"
|
||||
description = "AI Document Engine"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"cryptography>=44.0.0",
|
||||
"fastapi>=0.116.0",
|
||||
"jinja2>=3.1.0",
|
||||
"pgvector>=0.3.6",
|
||||
|
||||
@@ -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,10 @@ class MathIntentClassifier:
|
||||
self._agent: Agent[None, _MathIntentDecision] = Agent(
|
||||
model=runtime.fast_model,
|
||||
output_type=_MathIntentDecision,
|
||||
# Local models emit valid structured output only intermittently (they
|
||||
# sometimes wrap the JSON in prose); a few 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,
|
||||
)
|
||||
|
||||
@@ -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,38 @@ class OrchestratorDeps:
|
||||
request: OrchestratorRequest
|
||||
|
||||
|
||||
# Enum-style routing used for Ollama/custom (OpenAI-compatible local) models. The
|
||||
# function-ToolOutput delegates below take no arguments (they read everything from
|
||||
# ctx), but local models reliably try to pass the user's message as tool args, which
|
||||
# the zero-arg delegate schemas reject. Having the model pick a capability by name and
|
||||
# dispatching in Python (via the same _run_* methods used on resume) sidesteps that.
|
||||
_RouteCapability = Literal["pdf_edit", "pdf_question", "user_spec", "pdf_review", "pdf_create", "unsupported"]
|
||||
|
||||
|
||||
class _RouteDecision(ApiModel):
|
||||
# Local models routinely add stray tool args (the filename, the question echoed back)
|
||||
# and send null for optional fields; tolerate both so routing never fails on cosmetics.
|
||||
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 +121,9 @@ class OrchestratorAgent:
|
||||
description="Return this when none of the delegate outputs fit the request.",
|
||||
),
|
||||
],
|
||||
# Local models (Ollama/custom) pick a delegate less reliably; give the
|
||||
# routing a few extra validation 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 +141,24 @@ class OrchestratorAgent:
|
||||
),
|
||||
model_settings=runtime.fast_model_settings,
|
||||
)
|
||||
# Local models can't drive the zero-arg function-delegate routing above, so
|
||||
# route them by capability name and dispatch in Python instead.
|
||||
self._route_via_enum = uses_tool_output(runtime.settings.chat_provider)
|
||||
# The router has no tools of its own, so NativeOutput (a direct structured
|
||||
# response) works on Ollama here - unlike tool-using agents, which need
|
||||
# ToolOutput. A single output tool would tempt a local model to answer in
|
||||
# plain text and never call it ("include your response in a tool call").
|
||||
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 +170,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 +179,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.
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Provider-aware structured-output strategy.
|
||||
|
||||
OpenAI-compatible local models (Ollama, and 'custom' OpenAI-compatible endpoints)
|
||||
block tool-calling whenever a native json-schema ``response_format`` is set: the
|
||||
API constrains every completion to the output schema, so the model can never emit
|
||||
a tool-call turn. Agents that combine structured output with tools (RAG search,
|
||||
whole-document read, etc.) therefore get an ungrounded answer - the model fills the
|
||||
schema directly instead of calling a retrieval tool.
|
||||
|
||||
Delivering the structured result via a tool call (:class:`ToolOutput`) sidesteps
|
||||
this: it's all tool-calling, which these endpoints handle, so the model can call a
|
||||
retrieval tool and then the output tool. Real providers (Anthropic, OpenAI) keep
|
||||
:class:`NativeOutput`, which is unaffected and preferred there.
|
||||
"""
|
||||
|
||||
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.
|
||||
|
||||
Ollama/custom deliver each output variant via a :class:`ToolOutput`; every other
|
||||
provider uses a single :class:`NativeOutput` over the variants.
|
||||
"""
|
||||
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 -
|
||||
small models produce valid complex/nested structured output only intermittently."""
|
||||
return tool if uses_tool_output(chat_provider) else native
|
||||
@@ -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,17 @@ class PdfQuestionAgent:
|
||||
files=request.files,
|
||||
principals=principals,
|
||||
)
|
||||
# Ollama/custom (OpenAI-compatible local) models block tool-calling under a
|
||||
# native json-schema response format, so deliver the structured result via a
|
||||
# tool call instead - otherwise the model never calls the retrieval tools and
|
||||
# answers ungrounded. Real providers keep NativeOutput. 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.
|
||||
|
||||
@@ -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,10 +26,12 @@ from stirling.api.routes import (
|
||||
pdf_edit_router,
|
||||
pdf_question_router,
|
||||
)
|
||||
from stirling.api.routes.config import CONFIG_APPLY_ERRORS, resolve_and_apply
|
||||
from stirling.config import AppSettings, load_settings
|
||||
from stirling.config.config_cache import 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__)
|
||||
|
||||
@@ -83,22 +77,52 @@ 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 on-disk cache.
|
||||
|
||||
Returns the effective settings plus the pre-built smart/fast models and
|
||||
embedder to inject into the initial app state. Falls back to the env settings
|
||||
(all-None) when config push is disabled, no cache exists, or the cached config
|
||||
can't be applied (bad/unavailable model) - the cache never crashes boot.
|
||||
"""
|
||||
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. Restore the last-applied pushed
|
||||
# config unless config push is disabled (then env is the single source of truth).
|
||||
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))
|
||||
reaper_task = asyncio.create_task(
|
||||
@@ -135,10 +159,18 @@ 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/admin sync from the Java processor with no X-User-Id, so
|
||||
# it is guarded by the X-Engine-Auth shared secret (global middleware) and the
|
||||
# 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 (env < cache < push) held on app.state, not the
|
||||
# boot-time env cache, so an admin "Test connection" check shows the model
|
||||
# actually in use after a config push. Falls back to env if state isn't up yet.
|
||||
settings: AppSettings = getattr(http_request.app.state, "settings", None) or load_settings()
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
smart_model=settings.smart_model_name,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Assemble the runtime + every agent into a single swappable bundle.
|
||||
|
||||
Both the lifespan startup and the config-push endpoint build the same object
|
||||
graph: one :class:`AppRuntime` plus one agent instance per flow, each of which
|
||||
captures ``runtime.smart_model``/``runtime.fast_model`` at construction. To change
|
||||
models at runtime the whole bundle has to be rebuilt and swapped into
|
||||
``app.state`` atomically, which is what :func:`build_app_state` /
|
||||
:func:`apply_app_state` exist for.
|
||||
"""
|
||||
|
||||
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``.
|
||||
|
||||
The keyword arguments are threaded straight to :func:`build_runtime` so a
|
||||
config-push rebuild can reuse the live document store and inject already
|
||||
validated models (and, on a cache-restore boot, a pre-built embedder); all
|
||||
None reproduces the original startup path.
|
||||
"""
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, 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 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").
|
||||
|
||||
Only used when falling back to the env-configured name under an explicit
|
||||
pushed provider; a bare pushed name (which may itself contain ``:`` such as
|
||||
"llama3:8b") is used verbatim and never passed through here.
|
||||
"""
|
||||
_, 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
|
||||
|
||||
|
||||
def _is_loopback_client(request: Request) -> bool:
|
||||
"""True when the request originates from the local host (127.0.0.0/8, ::1)."""
|
||||
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.
|
||||
|
||||
Builds and validates the smart/fast models, resolves the scalar overrides,
|
||||
and builds a new embedder when the embedding config changed. Empty/None pushed
|
||||
fields keep the current value. Returns the effective settings, the two built
|
||||
models, an optional new embedder (None when the embedding config is unchanged),
|
||||
and human-readable notes.
|
||||
|
||||
Raises one of :data:`CONFIG_APPLY_ERRORS` if a chosen model/embedder fails to
|
||||
build or validate; callers decide whether that becomes a 400 (HTTP route) or an
|
||||
env fallback (boot). It never swaps any live state - the caller owns that.
|
||||
"""
|
||||
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:
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
@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 in place.
|
||||
|
||||
Gated by the X-Engine-Auth shared secret (global middleware) plus the
|
||||
``allow_config_push`` flag. Empty credential/model fields keep the engine's
|
||||
env-configured values. Returns 403 when config push is disabled, 400 (without
|
||||
swapping state) when a chosen model fails to build/validate. On success the
|
||||
config is also persisted (encrypted) so it survives an engine 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 for this sensitive endpoint. When a shared secret is set the
|
||||
# global middleware has already authenticated the caller (only the processor has the
|
||||
# secret). When NO secret is set, only trust a loopback caller - a remote party must
|
||||
# never be able to push a config unauthenticated, since a pushed base_url/model could
|
||||
# repoint the engine to exfiltrate document content.
|
||||
if not current.engine_shared_secret and not _is_loopback_client(http_request):
|
||||
client_host = http_request.client.host if http_request.client else "unknown"
|
||||
logger.warning("Rejected config push from non-local caller %s with no shared secret set", client_host)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
"Config push from a non-local caller requires STIRLING_ENGINE_SHARED_SECRET"
|
||||
" to be set on both the engine and the processor."
|
||||
),
|
||||
)
|
||||
|
||||
runtime: AppRuntime = app.state.runtime
|
||||
try:
|
||||
effective, smart_model, fast_model, new_embedder, notes = resolve_and_apply(current, 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
|
||||
|
||||
# Rebuild runtime + every agent, reusing the existing document store, then
|
||||
# swap the whole bundle onto app.state so in-flight lookups see one config.
|
||||
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 so we never tear down
|
||||
# the live vector store / connection pool.
|
||||
runtime.documents.embedder = new_embedder
|
||||
|
||||
# Persist the applied config (encrypted) so it is restored on the next boot.
|
||||
try:
|
||||
save_config(request)
|
||||
except OSError:
|
||||
logger.warning("Applied AI config but failed to persist the encrypted cache", exc_info=True)
|
||||
notes.append("Config applied but could not be persisted; it will not survive an engine restart.")
|
||||
|
||||
logger.info(
|
||||
"Applied pushed AI config: provider=%s smart_model=%s fast_model=%s top_k=%s",
|
||||
request.models.provider.strip() or "<env>",
|
||||
effective.smart_model_name,
|
||||
effective.fast_model_name,
|
||||
effective.rag_default_top_k,
|
||||
)
|
||||
|
||||
return ConfigApplyResponse(
|
||||
status="applied",
|
||||
provider=request.models.provider.strip(),
|
||||
smart_model=effective.smart_model_name,
|
||||
fast_model=effective.fast_model_name,
|
||||
smart_max_tokens=effective.smart_model_max_tokens,
|
||||
fast_max_tokens=effective.fast_model_max_tokens,
|
||||
rag_embedding_model=effective.rag_embedding_model,
|
||||
rag_top_k=effective.rag_default_top_k,
|
||||
rag_max_searches=effective.rag_max_searches,
|
||||
max_pages=effective.max_pages,
|
||||
max_characters=effective.max_characters,
|
||||
model_max_concurrency=effective.model_max_concurrency,
|
||||
notes=notes,
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Persistent, encrypted cache of the last-applied config-push.
|
||||
|
||||
The engine otherwise holds pushed config in RAM only, so a restart reverts to the
|
||||
env values until the processor pushes again. We persist the last-applied
|
||||
:class:`ConfigPushRequest` to ``<engine data dir>/ai_config_cache.enc`` (Fernet
|
||||
encrypted) and reload it on boot so the effective config survives restarts.
|
||||
|
||||
Key derivation: when ``STIRLING_ENGINE_SHARED_SECRET`` is set, the Fernet key is
|
||||
HKDF-SHA256 over that secret (constant salt/info) so no key material touches disk.
|
||||
Otherwise a random Fernet key is generated once and stored in a sibling
|
||||
``ai_config_cache.key`` (0600, best-effort) with a one-time warning.
|
||||
"""
|
||||
|
||||
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 _chmod_600(path: Path) -> None:
|
||||
# Best-effort owner-only perms; silently ignored where unsupported (e.g. Windows).
|
||||
try:
|
||||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
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)
|
||||
key_path.write_bytes(key)
|
||||
_chmod_600(key_path)
|
||||
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)
|
||||
(data_dir / _CACHE_FILENAME).write_bytes(token)
|
||||
|
||||
|
||||
def load_config(*, data_dir: Path | None = None) -> ConfigPushRequest | None:
|
||||
"""Load + decrypt the persisted pushed config.
|
||||
|
||||
Returns None (never raises) when the cache is absent, corrupt, or was written
|
||||
under a different key, so a bad cache can never crash boot.
|
||||
"""
|
||||
data_dir = data_dir or _default_data_dir()
|
||||
cache_path = data_dir / _CACHE_FILENAME
|
||||
if not cache_path.exists():
|
||||
return None
|
||||
try:
|
||||
token = cache_path.read_bytes()
|
||||
payload = _fernet(data_dir).decrypt(token)
|
||||
return ConfigPushRequest.model_validate_json(payload)
|
||||
except (InvalidToken, ValueError, OSError) as exc:
|
||||
logger.warning("Ignoring unreadable AI config cache at %s: %s", cache_path, exc)
|
||||
return None
|
||||
@@ -25,6 +25,12 @@ 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 providers; set to
|
||||
# 'ollama'/'custom' by a config push. Agents that combine structured output with
|
||||
# tools read this to pick a tool-compatible output strategy, because OpenAI-compatible
|
||||
# local models (Ollama) block tool calls when a native json-schema response format is
|
||||
# set, so their structured result must be delivered via a tool call instead.
|
||||
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 +129,11 @@ 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-configured AI settings (model,
|
||||
# credentials, limits) to POST /api/v1/config at startup. Turn this off in
|
||||
# environment-driven deployments so the environment is the single source of truth.
|
||||
allow_config_push: bool = Field(default=True, validation_alias="STIRLING_ALLOW_CONFIG_PUSH")
|
||||
|
||||
|
||||
def _configure_logging(level_name: str, log_file: str, http_debug: bool) -> None:
|
||||
"""Configure the ``stirling`` logger hierarchy."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import ConfigDict, Field
|
||||
|
||||
from stirling.models import ApiModel
|
||||
|
||||
# Tolerate unknown fields so a newer processor pushing a field this engine version
|
||||
# doesn't know is ignored rather than rejecting the whole push. Overrides only the
|
||||
# ``extra`` policy from ApiModel; the camelCase alias generator is inherited.
|
||||
_TOLERANT = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class ConfigModelsSection(ApiModel):
|
||||
"""Model provider + credentials pushed by the Java processor.
|
||||
|
||||
Field names on the wire are camelCase (``smartModel`` etc.) via the
|
||||
:class:`ApiModel` alias generator. Empty ``provider``/``apiKey``/``baseUrl``
|
||||
or empty model names mean "keep the engine's env-configured value".
|
||||
"""
|
||||
|
||||
model_config = _TOLERANT
|
||||
|
||||
provider: str = ""
|
||||
smart_model: str = ""
|
||||
fast_model: str = ""
|
||||
smart_max_tokens: int | None = None
|
||||
fast_max_tokens: int | None = None
|
||||
api_key: str = ""
|
||||
base_url: str = ""
|
||||
|
||||
|
||||
class ConfigRagSection(ApiModel):
|
||||
model_config = _TOLERANT
|
||||
|
||||
embedding_provider: str = ""
|
||||
embedding_model: str = ""
|
||||
embedding_api_key: str = ""
|
||||
# OpenAI-compatible endpoint URL for ollama/custom embedding providers.
|
||||
# Empty keeps the engine's env value, same convention as the other fields.
|
||||
embedding_base_url: str = ""
|
||||
top_k: int | None = None
|
||||
max_searches: int | None = None
|
||||
|
||||
|
||||
class ConfigLimitsSection(ApiModel):
|
||||
model_config = _TOLERANT
|
||||
|
||||
max_pages: int | None = None
|
||||
max_characters: int | None = None
|
||||
model_max_concurrency: int | None = None
|
||||
|
||||
|
||||
class ConfigPushRequest(ApiModel):
|
||||
"""Admin-configured AI settings pushed at processor startup."""
|
||||
|
||||
model_config = _TOLERANT
|
||||
|
||||
models: ConfigModelsSection = Field(default_factory=ConfigModelsSection)
|
||||
rag: ConfigRagSection = Field(default_factory=ConfigRagSection)
|
||||
limits: ConfigLimitsSection = Field(default_factory=ConfigLimitsSection)
|
||||
|
||||
|
||||
class ConfigApplyResponse(ApiModel):
|
||||
"""Summary of the effective config after a push. Never echoes credentials."""
|
||||
|
||||
status: str
|
||||
provider: str
|
||||
smart_model: str
|
||||
fast_model: str
|
||||
smart_max_tokens: int
|
||||
fast_max_tokens: int
|
||||
rag_embedding_model: str
|
||||
rag_top_k: int
|
||||
rag_max_searches: int
|
||||
max_pages: int
|
||||
max_characters: int
|
||||
model_max_concurrency: int
|
||||
notes: list[str] = Field(default_factory=list)
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from 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,37 @@ 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` for ``model_name``.
|
||||
|
||||
With no explicit ``provider``/``api_key``/``base_url`` this keeps the original
|
||||
behaviour: ``model_name`` is the ``provider:model`` string form (e.g.
|
||||
"voyageai:voyage-4") and credentials come from the provider's native env var.
|
||||
|
||||
With an explicit provider (config-push path) ``model_name`` is the bare model
|
||||
without a prefix. ``voyageai``/``openai`` compose the same string form; the
|
||||
OpenAI-compatible ``ollama``/``custom`` build an OpenAI embedding model against
|
||||
``base_url`` (Ollama ignores the key but the SDK still needs a non-empty one).
|
||||
"""
|
||||
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 +54,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
|
||||
|
||||
@@ -43,6 +43,25 @@ 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
|
||||
# (and its connection pool). Existing vectors need re-indexing afterwards.
|
||||
self._embedder = value
|
||||
|
||||
async def ingest(
|
||||
self,
|
||||
collection: FileId,
|
||||
|
||||
@@ -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,79 @@ def _build_anthropic_http_client() -> httpx.AsyncClient:
|
||||
)
|
||||
|
||||
|
||||
# Placeholder used when no Anthropic key is configured; lets the engine boot so a
|
||||
# deployment that only uses another provider (e.g. Ollama via the admin UI / config
|
||||
# push) isn't blocked. 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 at startup.
|
||||
|
||||
pydantic-ai's ``AnthropicProvider`` raises at construction when no key is
|
||||
available, which would crash the whole engine at boot even for deployments
|
||||
that never call Anthropic (Ollama/OpenAI-configured, or awaiting a config
|
||||
push that supplies credentials). We fall back to a placeholder key and warn
|
||||
once so startup succeeds; the placeholder only surfaces as a clear
|
||||
provider-side auth error if an Anthropic call is actually made.
|
||||
"""
|
||||
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 chat requests.
|
||||
|
||||
OpenAI's schema uses ``content: null`` for assistant messages that carry only
|
||||
tool calls, but Ollama's OpenAI-compatible endpoint rejects it ("invalid message
|
||||
content type: <nil>"), which intermittently breaks multi-turn tool conversations
|
||||
(RAG search + tool-delivered output). Rewriting null -> "" makes them acceptable
|
||||
without changing semantics. Scoped to the ollama/custom clients only.
|
||||
"""
|
||||
|
||||
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 +208,97 @@ 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 by per-request RAG capabilities.
|
||||
|
||||
``embedder`` lets a cache-restore boot inject an already-built embedder (e.g.
|
||||
an OpenAI-compatible ollama/custom one whose base_url the plain
|
||||
``settings.rag_embedding_model`` string can't encode); None builds from settings.
|
||||
"""
|
||||
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.
|
||||
|
||||
``documents``/``fast_model``/``smart_model`` let a config-push rebuild reuse
|
||||
the live vector-store connection pool and inject already-built (and already
|
||||
validated) models instead of reconstructing them from the model-name strings.
|
||||
``embedder`` injects a pre-built embedder when building a fresh document store
|
||||
(cache-restore boot); it is ignored when ``documents`` is supplied. All None
|
||||
(the lifespan path) preserves the original startup behaviour.
|
||||
"""
|
||||
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``.
|
||||
|
||||
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.
|
||||
With no explicit ``provider``/``api_key``/``base_url`` this keeps the original
|
||||
env-driven behaviour: Anthropic-prefixed names get our keepalive-free httpx
|
||||
client (so workers don't pick up stale pooled connections) and everything
|
||||
else falls back to ``infer_model``.
|
||||
|
||||
When the caller passes an explicit provider (config-push path), ``model_name``
|
||||
is the bare model without a ``provider:`` prefix and credentials come from the
|
||||
call. An empty ``api_key``/``base_url`` is treated as unset so the provider
|
||||
reads its native environment variable (e.g. ANTHROPIC_API_KEY / OPENAI_API_KEY).
|
||||
"""
|
||||
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)
|
||||
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)
|
||||
|
||||
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 still
|
||||
# requires a non-empty one, so default to a harmless placeholder. The custom
|
||||
# http client coerces null assistant content so multi-turn tool calls work.
|
||||
openai_provider = OpenAIProvider(
|
||||
base_url=base_url or None,
|
||||
api_key=key or "ollama",
|
||||
http_client=_openai_compat_http_client(),
|
||||
)
|
||||
return OpenAIChatModel(model_name, provider=openai_provider)
|
||||
raise ValueError(f"Unsupported model provider {provider!r}.")
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"models": {
|
||||
"provider": "ollama",
|
||||
"smartModel": "smart-model-x",
|
||||
"fastModel": "fast-model-x",
|
||||
"smartMaxTokens": 1111,
|
||||
"fastMaxTokens": 2222,
|
||||
"apiKey": "provider-key-abc",
|
||||
"baseUrl": "http://engine.example/v1"
|
||||
},
|
||||
"rag": {
|
||||
"embeddingProvider": "custom",
|
||||
"embeddingModel": "embed-model-x",
|
||||
"embeddingApiKey": "embed-key-abc",
|
||||
"embeddingBaseUrl": "http://embed.example/v1",
|
||||
"topK": 33,
|
||||
"maxSearches": 7
|
||||
},
|
||||
"limits": {
|
||||
"maxPages": 111,
|
||||
"maxCharacters": 222222,
|
||||
"modelMaxConcurrency": 9
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Tests for the encrypted config cache (stirling.config.config_cache)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Wire-contract test for the processor -> engine config push.
|
||||
|
||||
The Java processor (AiEngineConfigSync.buildConfigNode) serialises the admin AI settings as
|
||||
camelCase JSON and POSTs them to /api/v1/config. Because ConfigPushRequest uses extra="ignore"
|
||||
(so version skew never 422s), a field the engine can no longer map is silently dropped to its
|
||||
default instead of erroring. This test pins the exact camelCase contract: every field in the
|
||||
shared fixture must round-trip onto the model, so a rename on either side fails loudly.
|
||||
|
||||
The same fixture (tests/fixtures/processor_config_push.json) is the contract the Java
|
||||
AiEngineConfigSyncTest asserts its serialiser produces - keep the two in sync.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from stirling.contracts import ConfigPushRequest
|
||||
|
||||
FIXTURE = Path(__file__).parent / "fixtures" / "processor_config_push.json"
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
return json.loads(FIXTURE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_processor_contract_round_trips_every_field() -> None:
|
||||
payload = _load()
|
||||
req = ConfigPushRequest.model_validate(payload)
|
||||
|
||||
m = payload["models"]
|
||||
assert req.models.provider == m["provider"]
|
||||
assert req.models.smart_model == m["smartModel"]
|
||||
assert req.models.fast_model == m["fastModel"]
|
||||
assert req.models.smart_max_tokens == m["smartMaxTokens"]
|
||||
assert req.models.fast_max_tokens == m["fastMaxTokens"]
|
||||
assert req.models.api_key == m["apiKey"]
|
||||
assert req.models.base_url == m["baseUrl"]
|
||||
|
||||
r = payload["rag"]
|
||||
assert req.rag.embedding_provider == r["embeddingProvider"]
|
||||
assert req.rag.embedding_model == r["embeddingModel"]
|
||||
assert req.rag.embedding_api_key == r["embeddingApiKey"]
|
||||
assert req.rag.embedding_base_url == r["embeddingBaseUrl"]
|
||||
assert req.rag.top_k == r["topK"]
|
||||
assert req.rag.max_searches == r["maxSearches"]
|
||||
|
||||
limits = payload["limits"]
|
||||
assert req.limits.max_pages == limits["maxPages"]
|
||||
assert req.limits.max_characters == limits["maxCharacters"]
|
||||
assert req.limits.model_max_concurrency == limits["modelMaxConcurrency"]
|
||||
|
||||
|
||||
def test_processor_contract_has_no_unmapped_keys() -> None:
|
||||
"""Guard the fixture itself: every wire key must map to a model field (none silently
|
||||
absorbed by extra="ignore"). If the processor adds a field, extend the contract here."""
|
||||
payload = _load()
|
||||
expected = {
|
||||
"models": {
|
||||
"provider",
|
||||
"smartModel",
|
||||
"fastModel",
|
||||
"smartMaxTokens",
|
||||
"fastMaxTokens",
|
||||
"apiKey",
|
||||
"baseUrl",
|
||||
},
|
||||
"rag": {
|
||||
"embeddingProvider",
|
||||
"embeddingModel",
|
||||
"embeddingApiKey",
|
||||
"embeddingBaseUrl",
|
||||
"topK",
|
||||
"maxSearches",
|
||||
},
|
||||
"limits": {"maxPages", "maxCharacters", "modelMaxConcurrency"},
|
||||
}
|
||||
assert set(payload) == set(expected)
|
||||
for section, keys in expected.items():
|
||||
assert set(payload[section]) == keys
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Tests for the config-push endpoint (POST /api/v1/config).
|
||||
|
||||
These drive the real lifespan (via ``with TestClient(app)``) so ``app.state`` is
|
||||
populated, then exercise the gate + model-swap behaviour against the in-memory
|
||||
"test" runtime built by :func:`build_app_settings`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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.config import AppSettings, config_cache, load_settings
|
||||
from stirling.contracts import ConfigPushRequest
|
||||
|
||||
|
||||
@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
|
||||
touches the real engine/data dir or leaks between tests."""
|
||||
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``.
|
||||
|
||||
Defaults to a loopback client address so the config endpoint's local-caller gate
|
||||
(which blocks remote unauthenticated pushes) allows the request through.
|
||||
"""
|
||||
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_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 builds an OpenAI-compatible
|
||||
embedder and swaps it onto the reused DocumentService without tearing down
|
||||
the store; the response carries 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 current (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 fields this engine doesn't know must not 422;
|
||||
unknown fields are ignored and the rest of the push still 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
|
||||
Generated
+2
@@ -603,6 +603,7 @@ name = "engine"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
@@ -630,6 +631,7 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "cryptography", specifier = ">=44.0.0" },
|
||||
{ name = "fastapi", specifier = ">=0.116.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1.0" },
|
||||
{ name = "opentelemetry-sdk", specifier = ">=1.39.0" },
|
||||
|
||||
@@ -692,6 +692,160 @@ 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"
|
||||
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 invalidates previously-embedded documents. A full engine restart and re-index of existing documents is required for search to work correctly."
|
||||
title = "Re-index required"
|
||||
|
||||
[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"
|
||||
|
||||
[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."
|
||||
title = "AI settings saved"
|
||||
|
||||
[admin.settings.badge]
|
||||
clickToUpgrade = "Click to view plan details"
|
||||
|
||||
@@ -6951,31 +7105,31 @@ subtitle = "Deployments, credentials, security posture, storage, and the audit t
|
||||
title = "Infrastructure"
|
||||
|
||||
# Fixed-enum label maps rendered via t(MAP[value]) in the infrastructure tabs.
|
||||
[portal.infrastructure.apiKeyPermission]
|
||||
admin = "Admin"
|
||||
read = "Read"
|
||||
write = "Write"
|
||||
|
||||
[portal.infrastructure.apiKeys]
|
||||
createKey = "Create key"
|
||||
heading = "API keys"
|
||||
subheading = "Personal credentials, each with its own usage tracking."
|
||||
subheading = "Scoped credentials with per-key rate limits, permissions, and IP allowlists."
|
||||
|
||||
[portal.infrastructure.apiKeys.card]
|
||||
allowedIps = "Allowed IPs"
|
||||
anyIp = "Any IP (no allowlist)"
|
||||
created = "Created"
|
||||
lastUsed = "Last used"
|
||||
revoke = "Revoke key"
|
||||
permissions = "Permissions"
|
||||
rateLimit = "Rate limit"
|
||||
rateLimitValue = "{{value}} req/min"
|
||||
usageMonth = "Usage this month"
|
||||
usageToday = "Usage today"
|
||||
|
||||
[portal.infrastructure.apiKeys.empty]
|
||||
description = "Create a key to start calling the Stirling API."
|
||||
description = "Create a scoped key to start calling the Stirling API."
|
||||
title = "No API keys yet"
|
||||
|
||||
[portal.infrastructure.apiKeys.error]
|
||||
load = "Couldn't load your API keys. Please try again."
|
||||
|
||||
[portal.infrastructure.apiKeys.revoke]
|
||||
body = "Revoke \"{{name}}\"? Any integration still using this key will immediately start receiving 401 errors. This can't be undone."
|
||||
cancel = "Cancel"
|
||||
confirm = "Revoke key"
|
||||
title = "Revoke API key"
|
||||
|
||||
[portal.infrastructure.attestationLabel]
|
||||
attested = "Attested"
|
||||
inScope = "In scope"
|
||||
@@ -7061,11 +7215,14 @@ notStarted = "Not started"
|
||||
cancel = "Cancel"
|
||||
createKey = "Create key"
|
||||
done = "Done"
|
||||
ipAllowlistHelper = "Comma-separated CIDR ranges. Leave blank to allow any IP."
|
||||
ipAllowlistLabel = "IP allowlist"
|
||||
keyNameLabel = "Key name"
|
||||
keyNamePlaceholder = "e.g. Production ingest"
|
||||
keyNamePlaceholder = "e.g. Production · ingest"
|
||||
permissionsLabel = "Permissions"
|
||||
secretKeyCaption = "Secret key"
|
||||
secretWarning = "Store this in a secrets manager. Stirling only ever stores a hash, so there is no way to recover it later."
|
||||
subtitle = "Give the key a name so you can recognise it later. You can revoke it at any time."
|
||||
secretWarning = "Store this in a secrets manager. Stirling only ever stores a hash — there is no way to recover it later."
|
||||
subtitle = "Scope the key to the minimum it needs. You can rotate or revoke at any time."
|
||||
subtitleCreated = "Copy this secret now — it won't be shown again."
|
||||
title = "Create API key"
|
||||
titleCreated = "Key created"
|
||||
@@ -7114,6 +7271,7 @@ title = "No regions deployed"
|
||||
[portal.infrastructure.keyLabel]
|
||||
active = "Active"
|
||||
revoked = "Revoked"
|
||||
rotateSoon = "Rotate soon"
|
||||
|
||||
[portal.infrastructure.modelLabel]
|
||||
active = "Active"
|
||||
@@ -8018,7 +8176,6 @@ title = "Sources"
|
||||
[portal.sources.actions]
|
||||
agentBuilder = "Agent Builder"
|
||||
connectSource = "Connect source"
|
||||
createApiKey = "Create API key"
|
||||
|
||||
[portal.sources.builder]
|
||||
back = "Back to sources"
|
||||
@@ -8949,6 +9106,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"
|
||||
|
||||
@@ -32,6 +32,10 @@ export const VALID_NAV_KEYS = [
|
||||
"adminEndpoints",
|
||||
"adminStorageSharing",
|
||||
"adminMcp",
|
||||
"adminAiGeneral",
|
||||
"adminAiModels",
|
||||
"adminAiDocuments",
|
||||
"adminAiLimits",
|
||||
"help",
|
||||
"legal",
|
||||
"backendThirdPartyLicenses",
|
||||
|
||||
@@ -43,33 +43,23 @@ export interface RecentDeployment {
|
||||
/* API Keys */
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
export type ApiKeyStatus = "active" | "revoked";
|
||||
export type ApiKeyStatus = "active" | "revoked" | "rotate-soon";
|
||||
export type ApiKeyPermission = "Read" | "Write" | "Admin";
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Non-secret leading fragment, e.g. "sk_a3f81b2c". */
|
||||
/** Masked prefix shown in the list, e.g. "sk_live_a3f8…". */
|
||||
prefix: string;
|
||||
created: string;
|
||||
/** Formatted last-use time, or "Never". */
|
||||
lastUsed: string;
|
||||
status: ApiKeyStatus;
|
||||
/** Requests made today (UTC). */
|
||||
/** Requests/min ceiling. */
|
||||
rateLimit: number;
|
||||
permissions: ApiKeyPermission[];
|
||||
allowedIps: string[];
|
||||
usageToday: number;
|
||||
/** Requests in the trailing 30 days. */
|
||||
usageMonth: number;
|
||||
/** Lifetime request count. */
|
||||
usageTotal: number;
|
||||
}
|
||||
|
||||
export interface ApiKeysResponse {
|
||||
keys: ApiKey[];
|
||||
}
|
||||
|
||||
/** Returned once on creation: the listed row plus the plaintext secret, shown once. */
|
||||
export interface CreatedApiKey {
|
||||
key: ApiKey;
|
||||
secret: string;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
@@ -288,34 +278,11 @@ export async function fetchDeployments(
|
||||
);
|
||||
}
|
||||
|
||||
const API_KEYS_PATH = "/api/v1/proprietary/ui-data/infrastructure/api-keys";
|
||||
|
||||
/** GET the caller's personal API keys; SaaS or local, scoped server-side per user. */
|
||||
export async function fetchApiKeys(): Promise<ApiKeysResponse> {
|
||||
return apiClient.saas.isConfigured()
|
||||
? apiClient.saas.json<ApiKeysResponse>(API_KEYS_PATH)
|
||||
: apiClient.local.json<ApiKeysResponse>(API_KEYS_PATH);
|
||||
}
|
||||
|
||||
/** POST a new key; the response carries the one-time secret. */
|
||||
export async function createApiKey(body: {
|
||||
name: string;
|
||||
}): Promise<CreatedApiKey> {
|
||||
const opts = { method: "POST" as const, body };
|
||||
return apiClient.saas.isConfigured()
|
||||
? apiClient.saas.json<CreatedApiKey>(API_KEYS_PATH, opts)
|
||||
: apiClient.local.json<CreatedApiKey>(API_KEYS_PATH, opts);
|
||||
}
|
||||
|
||||
/** DELETE (revoke) a key the caller owns. */
|
||||
export async function revokeApiKey(id: string): Promise<void> {
|
||||
const path = `${API_KEYS_PATH}/${encodeURIComponent(id)}`;
|
||||
const opts = { method: "DELETE" as const };
|
||||
if (apiClient.saas.isConfigured()) {
|
||||
await apiClient.saas.json<void>(path, opts);
|
||||
} else {
|
||||
await apiClient.local.json<void>(path, opts);
|
||||
}
|
||||
/** GET /v1/infrastructure/api-keys?tier=… */
|
||||
export async function fetchApiKeys(tier: Tier): Promise<ApiKey[]> {
|
||||
return apiClient.local.json<ApiKey[]>(
|
||||
`/v1/infrastructure/api-keys${q(tier)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /v1/infrastructure/security?tier=… */
|
||||
|
||||
@@ -6,20 +6,21 @@ import "@portal/views/Infrastructure.css";
|
||||
const BASE: ApiKey = {
|
||||
id: "key-1",
|
||||
name: "Production · ingest",
|
||||
prefix: "sk_a3f81b2c",
|
||||
created: "2026-03-02",
|
||||
lastUsed: "2026-07-10 09:14",
|
||||
prefix: "sk_live_a3f8…",
|
||||
created: "Mar 2, 2026",
|
||||
lastUsed: "2m ago",
|
||||
status: "active",
|
||||
rateLimit: 1200,
|
||||
permissions: ["Read", "Write"],
|
||||
allowedIps: ["52.14.0.0/16", "18.221.0.0/16"],
|
||||
usageToday: 84210,
|
||||
usageMonth: 2410933,
|
||||
usageTotal: 9820145,
|
||||
};
|
||||
|
||||
const meta: Meta<typeof ApiKeyCard> = {
|
||||
title: "Portal/Infrastructure/ApiKeyCard",
|
||||
component: ApiKeyCard,
|
||||
parameters: { layout: "padded" },
|
||||
args: { onRevoke: (key: ApiKey) => console.log("revoke", key.id) },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "44rem" }}>
|
||||
@@ -31,18 +32,37 @@ const meta: Meta<typeof ApiKeyCard> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ApiKeyCard>;
|
||||
|
||||
export const Personal: Story = { args: { apiKey: BASE } };
|
||||
export const Active: Story = { args: { apiKey: BASE } };
|
||||
|
||||
export const RotateSoon: Story = {
|
||||
args: {
|
||||
apiKey: {
|
||||
...BASE,
|
||||
name: "Ops · admin (legacy)",
|
||||
status: "rotate-soon",
|
||||
permissions: ["Read", "Write", "Admin"],
|
||||
allowedIps: ["203.0.113.7/32"],
|
||||
usageToday: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Revoked: Story = {
|
||||
args: {
|
||||
apiKey: {
|
||||
...BASE,
|
||||
name: "Sandbox · webhook tester",
|
||||
prefix: "sk_2c4a91de",
|
||||
prefix: "sk_test_2c4a…",
|
||||
status: "revoked",
|
||||
lastUsed: "Never",
|
||||
lastUsed: "never",
|
||||
permissions: ["Read"],
|
||||
allowedIps: [],
|
||||
usageToday: 0,
|
||||
usageMonth: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoIpAllowlist: Story = {
|
||||
args: { apiKey: { ...BASE, allowedIps: [] } },
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Card, StatusBadge } from "@app/ui";
|
||||
import { Button, Card, Chip, StatusBadge } from "@app/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ApiKey } from "@portal/api/infrastructure";
|
||||
import {
|
||||
@@ -8,17 +8,9 @@ import {
|
||||
} from "@portal/components/infrastructure/infraFormat";
|
||||
|
||||
/** Collapsible row for a single API key: header summary + expandable detail grid. */
|
||||
export function ApiKeyCard({
|
||||
apiKey,
|
||||
onRevoke,
|
||||
}: {
|
||||
apiKey: ApiKey;
|
||||
/** Ask to revoke this key; the parent confirms before the destructive call. */
|
||||
onRevoke: (key: ApiKey) => void;
|
||||
}) {
|
||||
export function ApiKeyCard({ apiKey }: { apiKey: ApiKey }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const revocable = apiKey.status === "active";
|
||||
return (
|
||||
<Card padding="default" className="portal-infra__key">
|
||||
<Button
|
||||
@@ -59,6 +51,14 @@ export function ApiKeyCard({
|
||||
<dt>{t("portal.infrastructure.apiKeys.card.lastUsed")}</dt>
|
||||
<dd>{apiKey.lastUsed}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("portal.infrastructure.apiKeys.card.rateLimit")}</dt>
|
||||
<dd className="portal-infra__mono">
|
||||
{t("portal.infrastructure.apiKeys.card.rateLimitValue", {
|
||||
value: apiKey.rateLimit.toLocaleString(),
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("portal.infrastructure.apiKeys.card.usageToday")}</dt>
|
||||
<dd className="portal-infra__mono">
|
||||
@@ -71,20 +71,36 @@ export function ApiKeyCard({
|
||||
{apiKey.usageMonth.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{revocable && (
|
||||
<div className="portal-infra__modal-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
accent="danger"
|
||||
size="sm"
|
||||
onClick={() => onRevoke(apiKey)}
|
||||
>
|
||||
{t("portal.infrastructure.apiKeys.card.revoke")}
|
||||
</Button>
|
||||
<div>
|
||||
<dt>{t("portal.infrastructure.apiKeys.card.permissions")}</dt>
|
||||
<dd className="portal-infra__chips">
|
||||
{apiKey.permissions.map((p) => (
|
||||
<Chip key={p} size="sm">
|
||||
{t(
|
||||
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
|
||||
p,
|
||||
)}
|
||||
</Chip>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
<div className="portal-infra__kv-wide">
|
||||
<dt>{t("portal.infrastructure.apiKeys.card.allowedIps")}</dt>
|
||||
<dd className="portal-infra__chips">
|
||||
{apiKey.allowedIps.length === 0 ? (
|
||||
<span className="portal-infra__muted">
|
||||
{t("portal.infrastructure.apiKeys.card.anyIp")}
|
||||
</span>
|
||||
) : (
|
||||
apiKey.allowedIps.map((ip) => (
|
||||
<Chip key={ip} size="sm">
|
||||
<span className="portal-infra__mono">{ip}</span>
|
||||
</Chip>
|
||||
))
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -20,19 +20,14 @@ type Story = StoryObj<typeof ApiKeysTab>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
const EMPTY = { keys: [] };
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get(
|
||||
"*/api/v1/proprietary/ui-data/infrastructure/api-keys",
|
||||
async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json(EMPTY);
|
||||
},
|
||||
),
|
||||
http.get("/v1/infrastructure/api-keys", async () => {
|
||||
await delay("infinite");
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -42,9 +37,7 @@ export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get("*/api/v1/proprietary/ui-data/infrastructure/api-keys", () =>
|
||||
HttpResponse.json(EMPTY),
|
||||
),
|
||||
http.get("/v1/infrastructure/api-keys", () => HttpResponse.json([])),
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,48 +1,20 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, EmptyState, Modal, Skeleton } from "@app/ui";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import {
|
||||
fetchApiKeys,
|
||||
revokeApiKey,
|
||||
type ApiKey,
|
||||
type ApiKeysResponse,
|
||||
} from "@portal/api/infrastructure";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import { Button, EmptyState, Skeleton } from "@app/ui";
|
||||
import { useTier } from "@portal/contexts/TierContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import { fetchApiKeys, type ApiKey } from "@portal/api/infrastructure";
|
||||
import { ApiKeyCard } from "@portal/components/infrastructure/ApiKeyCard";
|
||||
import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal";
|
||||
import { SectionHeader } from "@portal/components/infrastructure/SectionHeader";
|
||||
|
||||
export function ApiKeysTab() {
|
||||
const { t } = useTranslation();
|
||||
const { tier } = useTier();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pendingRevoke, setPendingRevoke] = useState<ApiKey | null>(null);
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
const state = useAsync<ApiKeysResponse>(() => fetchApiKeys(), [reloadKey]);
|
||||
const { data, loading, error: loadError } = state;
|
||||
|
||||
const reload = () => setReloadKey((n) => n + 1);
|
||||
const keys = data?.keys ?? [];
|
||||
const isLoading = loading && data === null;
|
||||
// A failed load must not masquerade as a genuinely empty list.
|
||||
const isEmpty = !loading && !loadError && keys.length === 0;
|
||||
|
||||
async function confirmRevoke() {
|
||||
if (!pendingRevoke) return;
|
||||
setError(null);
|
||||
setRevoking(true);
|
||||
try {
|
||||
await revokeApiKey(pendingRevoke.id);
|
||||
setPendingRevoke(null);
|
||||
reload();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setRevoking(false);
|
||||
}
|
||||
}
|
||||
const state = useAsync<ApiKey[]>(() => fetchApiKeys(tier), [tier]);
|
||||
const { data: keys } = state;
|
||||
const { isLoading, isEmpty } = useSectionFlags(state);
|
||||
|
||||
return (
|
||||
<div className="portal-infra__stack">
|
||||
@@ -60,14 +32,6 @@ export function ApiKeysTab() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
{!loading && loadError && (
|
||||
<Banner
|
||||
tone="danger"
|
||||
description={t("portal.infrastructure.apiKeys.error.load")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="portal-infra__stack" aria-hidden>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
@@ -84,52 +48,15 @@ export function ApiKeysTab() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{keys.length > 0 && (
|
||||
{keys && keys.length > 0 && (
|
||||
<div className="portal-infra__keys">
|
||||
{keys.map((k) => (
|
||||
<ApiKeyCard key={k.id} apiKey={k} onRevoke={setPendingRevoke} />
|
||||
<ApiKeyCard key={k.id} apiKey={k} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateKeyModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onCreated={reload}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={pendingRevoke !== null}
|
||||
onClose={() => !revoking && setPendingRevoke(null)}
|
||||
width="sm"
|
||||
title={t("portal.infrastructure.apiKeys.revoke.title")}
|
||||
footer={
|
||||
<div className="portal-infra__modal-actions">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="sm"
|
||||
disabled={revoking}
|
||||
onClick={() => setPendingRevoke(null)}
|
||||
>
|
||||
{t("portal.infrastructure.apiKeys.revoke.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
accent="danger"
|
||||
loading={revoking}
|
||||
onClick={confirmRevoke}
|
||||
>
|
||||
{t("portal.infrastructure.apiKeys.revoke.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
{t("portal.infrastructure.apiKeys.revoke.body", {
|
||||
name: pendingRevoke?.name ?? "",
|
||||
})}
|
||||
</p>
|
||||
</Modal>
|
||||
<CreateKeyModal open={modalOpen} onClose={() => setModalOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,7 @@ const meta: Meta<typeof CreateKeyModal> = {
|
||||
title: "Portal/Infrastructure/CreateKeyModal",
|
||||
component: CreateKeyModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
args: {
|
||||
open: true,
|
||||
onClose: () => console.log("close"),
|
||||
onCreated: () => console.log("created"),
|
||||
},
|
||||
args: { open: true, onClose: () => console.log("close") },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CreateKeyModal>;
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// Deterministic i18n: keys returned verbatim, so assertions are stable.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: vi.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Stub the API layer so no real request is made; capture the create payload.
|
||||
// vi.hoisted keeps the mock fn defined before the hoisted vi.mock factory runs.
|
||||
const { createApiKey } = vi.hoisted(() => ({ createApiKey: vi.fn() }));
|
||||
vi.mock("@portal/api/infrastructure", () => ({ createApiKey }));
|
||||
|
||||
import { CreateKeyModal } from "@portal/components/infrastructure/CreateKeyModal";
|
||||
|
||||
const K = "portal.infrastructure.createKey";
|
||||
|
||||
function renderModal(props: Partial<ComponentProps<typeof CreateKeyModal>>) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<CreateKeyModal open onClose={() => {}} onCreated={() => {}} {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CreateKeyModal", () => {
|
||||
it("gates the create button on a non-empty name", () => {
|
||||
renderModal({});
|
||||
const cta = screen.getByRole("button", { name: `${K}.createKey` });
|
||||
expect(cta).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(`${K}.keyNamePlaceholder`), {
|
||||
target: { value: "Production ingest" },
|
||||
});
|
||||
expect(cta).toBeEnabled();
|
||||
});
|
||||
|
||||
it("creates a key and reveals the returned secret", async () => {
|
||||
createApiKey.mockResolvedValueOnce({
|
||||
key: { id: "1", name: "Production ingest" },
|
||||
secret: "sk_live_demo_key_rotate_in_prod",
|
||||
});
|
||||
const onCreated = vi.fn();
|
||||
renderModal({ onCreated });
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(`${K}.keyNamePlaceholder`), {
|
||||
target: { value: "Production ingest" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: `${K}.createKey` }));
|
||||
|
||||
expect(await screen.findByText(`${K}.secretWarning`)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("sk_live_demo_key_rotate_in_prod"),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => expect(onCreated).toHaveBeenCalled());
|
||||
expect(createApiKey).toHaveBeenCalledWith({ name: "Production ingest" });
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Banner, Button, CodeBlock, FormField, Input, Modal } from "@app/ui";
|
||||
import { createApiKey, type CreatedApiKey } from "@portal/api/infrastructure";
|
||||
import { errorMessage } from "@portal/api/http";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
Checkbox,
|
||||
CodeBlock,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
} from "@app/ui";
|
||||
import type { ApiKeyPermission } from "@portal/api/infrastructure";
|
||||
|
||||
const PERMISSION_OPTS: ApiKeyPermission[] = ["Read", "Write", "Admin"];
|
||||
|
||||
// Shown once after a key is created. TODO(backend): use the one-time secret
|
||||
// returned by POST /v1/infrastructure/api-keys — it is never persisted server-side.
|
||||
const DEMO_NEW_KEY_SECRET = "sk_live_demo_key_rotate_in_prod";
|
||||
|
||||
export function CreateKeyModal({
|
||||
open,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after a successful create so the tab can refresh its list. */
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [created, setCreated] = useState<CreatedApiKey | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [perms, setPerms] = useState<ApiKeyPermission[]>(["Read"]);
|
||||
const [ips, setIps] = useState("");
|
||||
const [created, setCreated] = useState(false);
|
||||
|
||||
function reset() {
|
||||
setName("");
|
||||
setCreated(null);
|
||||
setSubmitting(false);
|
||||
setError(null);
|
||||
setPerms(["Read"]);
|
||||
setIps("");
|
||||
setCreated(false);
|
||||
}
|
||||
|
||||
function close() {
|
||||
@@ -33,18 +43,16 @@ export function CreateKeyModal({
|
||||
setTimeout(reset, 200);
|
||||
}
|
||||
|
||||
async function createKey() {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await createApiKey({ name: name.trim() });
|
||||
setCreated(result);
|
||||
onCreated();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
function togglePerm(p: ApiKeyPermission) {
|
||||
setPerms((prev) =>
|
||||
prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p],
|
||||
);
|
||||
}
|
||||
|
||||
function createKey() {
|
||||
// TODO(backend): POST /v1/infrastructure/api-keys { name, perms, ips }
|
||||
// and render the one-time secret from the response instead of the fixture.
|
||||
setCreated(true);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -64,7 +72,7 @@ export function CreateKeyModal({
|
||||
}
|
||||
footer={
|
||||
created ? (
|
||||
<Button variant="primary" onClick={close}>
|
||||
<Button variant="primary" accent="premium" onClick={close}>
|
||||
{t("portal.infrastructure.createKey.done")}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -74,7 +82,8 @@ export function CreateKeyModal({
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={name.trim() === "" || submitting}
|
||||
accent="premium"
|
||||
disabled={name.trim() === "" || perms.length === 0}
|
||||
onClick={createKey}
|
||||
>
|
||||
{t("portal.infrastructure.createKey.createKey")}
|
||||
@@ -86,7 +95,7 @@ export function CreateKeyModal({
|
||||
{created ? (
|
||||
<div className="portal-infra__stack">
|
||||
<CodeBlock
|
||||
code={created.secret}
|
||||
code={DEMO_NEW_KEY_SECRET}
|
||||
lang="bash"
|
||||
caption={t("portal.infrastructure.createKey.secretKeyCaption")}
|
||||
/>
|
||||
@@ -97,8 +106,6 @@ export function CreateKeyModal({
|
||||
</div>
|
||||
) : (
|
||||
<div className="portal-infra__form">
|
||||
{error && <Banner tone="danger" description={error} />}
|
||||
|
||||
<FormField
|
||||
label={t("portal.infrastructure.createKey.keyNameLabel")}
|
||||
required
|
||||
@@ -111,6 +118,35 @@ export function CreateKeyModal({
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t("portal.infrastructure.createKey.permissionsLabel")}
|
||||
>
|
||||
<div className="portal-infra__perm-row">
|
||||
{PERMISSION_OPTS.map((p) => (
|
||||
<Checkbox
|
||||
key={p}
|
||||
label={t(
|
||||
`portal.infrastructure.apiKeyPermission.${p.toLowerCase()}`,
|
||||
p,
|
||||
)}
|
||||
checked={perms.includes(p)}
|
||||
onChange={() => togglePerm(p)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label={t("portal.infrastructure.createKey.ipAllowlistLabel")}
|
||||
helperText={t("portal.infrastructure.createKey.ipAllowlistHelper")}
|
||||
>
|
||||
<Input
|
||||
value={ips}
|
||||
onChange={(e) => setIps(e.target.value)}
|
||||
placeholder="52.14.0.0/16, 203.0.113.7/32"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -55,11 +55,13 @@ export const DEPLOY_LABEL: Record<DeploymentStatus, string> = {
|
||||
export const KEY_TONE: Record<ApiKeyStatus, StatusTone> = {
|
||||
active: "success",
|
||||
revoked: "danger",
|
||||
"rotate-soon": "warning",
|
||||
};
|
||||
|
||||
export const KEY_LABEL: Record<ApiKeyStatus, string> = {
|
||||
active: "portal.infrastructure.keyLabel.active",
|
||||
revoked: "portal.infrastructure.keyLabel.revoked",
|
||||
"rotate-soon": "portal.infrastructure.keyLabel.rotateSoon",
|
||||
};
|
||||
|
||||
export const CERT_TONE: Record<CertStatus, StatusTone> = {
|
||||
|
||||
@@ -25,48 +25,10 @@ export const infrastructureHandlers = [
|
||||
});
|
||||
}),
|
||||
|
||||
// Real backend route; wildcard prefix intercepts both local (same-origin) and
|
||||
// SaaS (absolute) callers.
|
||||
http.get(
|
||||
"*/api/v1/proprietary/ui-data/infrastructure/api-keys",
|
||||
async ({ request }) => {
|
||||
await delay(120);
|
||||
return HttpResponse.json(apiKeysFor(tierFrom(request)));
|
||||
},
|
||||
),
|
||||
|
||||
// Create returns a one-time secret; the mock is non-persistent (dev/Storybook only).
|
||||
http.post(
|
||||
"*/api/v1/proprietary/ui-data/infrastructure/api-keys",
|
||||
async ({ request }) => {
|
||||
await delay(120);
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
name?: string;
|
||||
};
|
||||
return HttpResponse.json({
|
||||
key: {
|
||||
id: `key-${Date.now()}`,
|
||||
name: body.name ?? "New key",
|
||||
prefix: "sk_demo0000",
|
||||
created: "2026-07-10",
|
||||
lastUsed: "Never",
|
||||
status: "active",
|
||||
usageToday: 0,
|
||||
usageMonth: 0,
|
||||
usageTotal: 0,
|
||||
},
|
||||
secret: "sk_live_demo_key_rotate_in_prod",
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
http.delete(
|
||||
"*/api/v1/proprietary/ui-data/infrastructure/api-keys/:id",
|
||||
async () => {
|
||||
await delay(120);
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
},
|
||||
),
|
||||
http.get("/v1/infrastructure/api-keys", async ({ request }) => {
|
||||
await delay(120);
|
||||
return HttpResponse.json(apiKeysFor(tierFrom(request)));
|
||||
}),
|
||||
|
||||
http.get("/v1/infrastructure/security", async ({ request }) => {
|
||||
await delay(120);
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
import type { Tier } from "@portal/contexts/TierContext";
|
||||
import type {
|
||||
ApiKey,
|
||||
ApiKeysResponse,
|
||||
AuditEvent,
|
||||
AuditLogResponse,
|
||||
AuditSummary,
|
||||
@@ -167,57 +166,61 @@ const API_KEYS_ALL: ApiKey[] = [
|
||||
{
|
||||
id: "key-1",
|
||||
name: "Production · ingest",
|
||||
prefix: "sk_a3f81b2c",
|
||||
created: "2026-03-02",
|
||||
lastUsed: "2026-07-10 09:14",
|
||||
prefix: "sk_live_a3f8…",
|
||||
created: "Mar 2, 2026",
|
||||
lastUsed: "2m ago",
|
||||
status: "active",
|
||||
rateLimit: 1200,
|
||||
permissions: ["Read", "Write"],
|
||||
allowedIps: ["52.14.0.0/16", "18.221.0.0/16"],
|
||||
usageToday: 84210,
|
||||
usageMonth: 2410933,
|
||||
usageTotal: 2410933,
|
||||
},
|
||||
{
|
||||
id: "key-2",
|
||||
name: "Nightly batch",
|
||||
prefix: "sk_77be0f42",
|
||||
created: "2026-01-18",
|
||||
lastUsed: "2026-07-10 08:33",
|
||||
name: "Analytics · read-only",
|
||||
prefix: "sk_live_77be…",
|
||||
created: "Jan 18, 2026",
|
||||
lastUsed: "41m ago",
|
||||
status: "active",
|
||||
rateLimit: 300,
|
||||
permissions: ["Read"],
|
||||
allowedIps: [],
|
||||
usageToday: 6120,
|
||||
usageMonth: 188400,
|
||||
usageTotal: 188400,
|
||||
},
|
||||
{
|
||||
id: "key-3",
|
||||
name: "CI pipeline",
|
||||
prefix: "sk_d9013ab7",
|
||||
created: "2025-08-09",
|
||||
lastUsed: "2026-07-04 17:02",
|
||||
status: "active",
|
||||
name: "Ops · admin (legacy)",
|
||||
prefix: "sk_live_d901…",
|
||||
created: "Aug 9, 2025",
|
||||
lastUsed: "6d ago",
|
||||
status: "rotate-soon",
|
||||
rateLimit: 600,
|
||||
permissions: ["Read", "Write", "Admin"],
|
||||
allowedIps: ["203.0.113.7/32"],
|
||||
usageToday: 0,
|
||||
usageMonth: 14200,
|
||||
usageTotal: 14200,
|
||||
},
|
||||
{
|
||||
id: "key-4",
|
||||
name: "Sandbox · webhook tester",
|
||||
prefix: "sk_2c4a91de",
|
||||
created: "2026-05-30",
|
||||
lastUsed: "Never",
|
||||
prefix: "sk_test_2c4a…",
|
||||
created: "May 30, 2026",
|
||||
lastUsed: "never",
|
||||
status: "revoked",
|
||||
rateLimit: 60,
|
||||
permissions: ["Read"],
|
||||
allowedIps: [],
|
||||
usageToday: 0,
|
||||
usageMonth: 0,
|
||||
usageTotal: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export function apiKeysFor(tier: Tier): ApiKeysResponse {
|
||||
const keys =
|
||||
tier === "free"
|
||||
? API_KEYS_ALL.slice(0, 1)
|
||||
: tier === "pro"
|
||||
? API_KEYS_ALL.slice(0, 3)
|
||||
: API_KEYS_ALL;
|
||||
return { keys };
|
||||
export function apiKeysFor(tier: Tier): ApiKey[] {
|
||||
if (tier === "free") return API_KEYS_ALL.slice(0, 1);
|
||||
if (tier === "pro") return API_KEYS_ALL.slice(0, 3);
|
||||
return API_KEYS_ALL;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -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";
|
||||
@@ -163,6 +167,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-rounded",
|
||||
component: <AdminAiModelsSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminAiDocuments",
|
||||
label: t("settings.ai.documents", "Documents & RAG"),
|
||||
icon: "description-rounded",
|
||||
component: <AdminAiDocumentsSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
{
|
||||
key: "adminAiLimits",
|
||||
label: t("settings.ai.limits", "Limits & Performance"),
|
||||
icon: "speed-rounded",
|
||||
component: <AdminAiLimitsSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? enableLoginTooltip : undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Security & Authentication
|
||||
sections.push({
|
||||
title: t("settings.securityAuth.title", "Security & Authentication"),
|
||||
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
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,
|
||||
} 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 deltaSettings: Record<string, unknown> = {
|
||||
"aiEngine.rag.embeddingProvider":
|
||||
s.rag?.embeddingProvider ?? "voyageai",
|
||||
"aiEngine.rag.embeddingModel": s.rag?.embeddingModel ?? "",
|
||||
"aiEngine.rag.embeddingBaseUrl": s.rag?.embeddingBaseUrl ?? "",
|
||||
"aiEngine.rag.topK": s.rag?.topK ?? 0,
|
||||
"aiEngine.rag.maxSearches": s.rag?.maxSearches ?? 0,
|
||||
};
|
||||
if (embeddingApiKeyDirty) {
|
||||
deltaSettings["aiEngine.rag.embeddingApiKey"] =
|
||||
s.rag?.embeddingApiKey ?? "";
|
||||
}
|
||||
return { sectionData: {}, deltaSettings };
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const { isDirty, resetToSnapshot, markSaved } = useSettingsDirty(
|
||||
settings,
|
||||
loading,
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await saveSettings();
|
||||
setEmbeddingApiKeyDirty(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: t(
|
||||
"admin.settings.ai.saved.body",
|
||||
"Changes are pushed to the AI engine automatically.",
|
||||
),
|
||||
});
|
||||
} 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) => setRag({ embeddingProvider: v || "voyageai" })}
|
||||
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.",
|
||||
)}
|
||||
value={settings.rag?.embeddingApiKey || ""}
|
||||
onChange={(e) => {
|
||||
setEmbeddingApiKeyDirty(true);
|
||||
setRag({ embeddingApiKey: e.target.value });
|
||||
}}
|
||||
placeholder={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 invalidates previously-embedded documents. A full engine restart and re-index of existing documents is required for search to work correctly.",
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
</Stack>
|
||||
|
||||
<SettingsStickyFooter
|
||||
isDirty={isDirty}
|
||||
saving={saving}
|
||||
loginEnabled={loginEnabled}
|
||||
onSave={handleSave}
|
||||
onDiscard={handleDiscard}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+500
@@ -0,0 +1,500 @@
|
||||
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,
|
||||
} 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 ?? "",
|
||||
"aiEngine.timeoutSeconds": s.timeoutSeconds ?? 0,
|
||||
"aiEngine.longRunningTimeoutSeconds": s.longRunningTimeoutSeconds ?? 0,
|
||||
"aiEngine.streamTimeoutSeconds": s.streamTimeoutSeconds ?? 0,
|
||||
"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 })
|
||||
}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
<PendingBadge show={pending} />
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
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,
|
||||
} 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: {
|
||||
"aiEngine.limits.maxPages": s.limits?.maxPages ?? 0,
|
||||
"aiEngine.limits.maxCharacters": s.limits?.maxCharacters ?? 0,
|
||||
"aiEngine.limits.modelMaxConcurrency":
|
||||
s.limits?.modelMaxConcurrency ?? 0,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
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: t(
|
||||
"admin.settings.ai.saved.body",
|
||||
"Changes are pushed to the AI engine automatically.",
|
||||
),
|
||||
});
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
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,
|
||||
} 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 deltaSettings: Record<string, unknown> = {
|
||||
"aiEngine.models.provider": s.models?.provider ?? "anthropic",
|
||||
"aiEngine.models.smartModel": s.models?.smartModel ?? "",
|
||||
"aiEngine.models.fastModel": s.models?.fastModel ?? "",
|
||||
"aiEngine.models.smartMaxTokens": s.models?.smartMaxTokens ?? 0,
|
||||
"aiEngine.models.fastMaxTokens": s.models?.fastMaxTokens ?? 0,
|
||||
"aiEngine.models.baseUrl": s.models?.baseUrl ?? "",
|
||||
};
|
||||
// Only include the secret when the user typed a new value.
|
||||
if (apiKeyDirty) {
|
||||
deltaSettings["aiEngine.models.apiKey"] = 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: t(
|
||||
"admin.settings.ai.saved.body",
|
||||
"Changes are pushed to the AI engine automatically.",
|
||||
),
|
||||
});
|
||||
} 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) => setModels({ provider: v || "anthropic" })}
|
||||
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.",
|
||||
)}
|
||||
value={settings.models?.apiKey || ""}
|
||||
onChange={(e) => {
|
||||
setApiKeyDirty(true);
|
||||
setModels({ apiKey: e.target.value });
|
||||
}}
|
||||
placeholder={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>
|
||||
);
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
// Shared types + constants for the aiEngine admin settings section.
|
||||
// The four AI sub-pages fetch the same GET /section/aiEngine payload but each
|
||||
// saves only its own dot-notation keys so sibling keys are preserved.
|
||||
|
||||
/** Secret fields come back masked as this literal when a value is set. */
|
||||
export const MASKED_SECRET = "********";
|
||||
|
||||
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;
|
||||
timeoutSeconds?: number;
|
||||
longRunningTimeoutSeconds?: number;
|
||||
streamTimeoutSeconds?: number;
|
||||
models?: AiEngineModels;
|
||||
rag?: AiEngineRag;
|
||||
limits?: AiEngineLimits;
|
||||
features?: AiEngineFeatures;
|
||||
}
|
||||
|
||||
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-4-5", "claude-opus-4-1"],
|
||||
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,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { type TFunction } from "i18next";
|
||||
import { allowConsole } from "@app/tests/failOnConsole";
|
||||
import { createSaasConfigNavSections } from "@app/components/shared/config/saasConfigNavSections";
|
||||
|
||||
// Passthrough i18n stub: return the provided fallback (2nd arg) or the key.
|
||||
const t = ((key: string, fallback?: string) =>
|
||||
fallback ?? key) as unknown as TFunction<"translation", undefined>;
|
||||
|
||||
const Overview = () => null;
|
||||
|
||||
type Sections = ReturnType<typeof createSaasConfigNavSections>;
|
||||
|
||||
function itemKeys(sections: Sections): string[] {
|
||||
return sections.flatMap((s) => s.items.map((i) => i.key));
|
||||
}
|
||||
|
||||
// The admin AI settings pages exist only in the self-hosted proprietary flavor. SaaS builds its
|
||||
// settings nav from scratch here, so this locks in that the AI group can never leak into SaaS -
|
||||
// if someone later wires the AI sections into the SaaS cascade, this fails loudly.
|
||||
describe("saasConfigNavSections", () => {
|
||||
const AI_ITEM_KEYS = [
|
||||
"adminAiGeneral",
|
||||
"adminAiModels",
|
||||
"adminAiDocuments",
|
||||
"adminAiLimits",
|
||||
];
|
||||
|
||||
it("never exposes the admin AI settings group or its pages", () => {
|
||||
// The shared core nav helper warns it is deprecated; incidental to this test.
|
||||
allowConsole.warn(/createConfigNavSections is deprecated/);
|
||||
const sections = createSaasConfigNavSections(Overview, () => {}, { t });
|
||||
|
||||
const keys = itemKeys(sections);
|
||||
for (const aiKey of AI_ITEM_KEYS) {
|
||||
expect(keys).not.toContain(aiKey);
|
||||
}
|
||||
expect(sections.map((s) => s.title)).not.toContain("AI");
|
||||
});
|
||||
|
||||
it("also hides the AI pages for anonymous users", () => {
|
||||
allowConsole.warn(/createConfigNavSections is deprecated/);
|
||||
const sections = createSaasConfigNavSections(Overview, () => {}, {
|
||||
t,
|
||||
isAnonymous: true,
|
||||
});
|
||||
|
||||
const keys = itemKeys(sections);
|
||||
for (const aiKey of AI_ITEM_KEYS) {
|
||||
expect(keys).not.toContain(aiKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
@jwt @auth @apikey
|
||||
Feature: API Keys management API
|
||||
|
||||
Tests for the portal API-keys REST API, which lets a user mint, list, and
|
||||
revoke named personal API keys.
|
||||
|
||||
Endpoints (all @EnterpriseEndpoint, JWT/ROLE required):
|
||||
- GET /api/v1/proprietary/ui-data/infrastructure/api-keys (list)
|
||||
- POST /api/v1/proprietary/ui-data/infrastructure/api-keys (create)
|
||||
- DELETE /api/v1/proprietary/ui-data/infrastructure/api-keys/{id} (revoke)
|
||||
|
||||
Because these are @EnterpriseEndpoint, authenticated responses may be 200
|
||||
(enterprise enabled) or 403 (feature not in this build). Unauthenticated
|
||||
requests must always be rejected with 401.
|
||||
|
||||
The legacy single per-user key (the global API key) must keep working so no
|
||||
key created before multi-key support is ever lost.
|
||||
|
||||
Admin credentials: username=admin, password=stirling
|
||||
Global API key: 123456789
|
||||
|
||||
# =========================================================================
|
||||
# LIST
|
||||
# =========================================================================
|
||||
|
||||
@positive
|
||||
Scenario: Admin can list API keys
|
||||
Given I am logged in as admin
|
||||
When I send a GET request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication
|
||||
Then the response status code should be one of "200, 403"
|
||||
|
||||
@negative
|
||||
Scenario: Unauthenticated list request returns 401
|
||||
When I send a GET request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with no authentication
|
||||
Then the response status code should be 401
|
||||
|
||||
# =========================================================================
|
||||
# CREATE
|
||||
# =========================================================================
|
||||
|
||||
@positive
|
||||
Scenario: Admin can create a personal API key
|
||||
Given I am logged in as admin
|
||||
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": "bdd_personal_key"}'
|
||||
Then the response status code should be one of "200, 403"
|
||||
|
||||
@negative
|
||||
Scenario: Creating a key without a name is rejected
|
||||
Given I am logged in as admin
|
||||
When I send a JSON POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with JWT authentication and body '{"name": ""}'
|
||||
Then the response status code should be one of "400, 403"
|
||||
|
||||
@negative
|
||||
Scenario: Unauthenticated create request returns 401
|
||||
When I send a POST request to "/api/v1/proprietary/ui-data/infrastructure/api-keys" with no authentication
|
||||
Then the response status code should be 401
|
||||
|
||||
# =========================================================================
|
||||
# REVOKE
|
||||
# =========================================================================
|
||||
|
||||
@negative
|
||||
Scenario: Unauthenticated revoke request returns 401
|
||||
When I send a DELETE request to "/api/v1/proprietary/ui-data/infrastructure/api-keys/1" with no authentication
|
||||
Then the response status code should be 401
|
||||
|
||||
@positive
|
||||
Scenario: Admin revoking a non-existent key is handled, not a bypass
|
||||
Given I am logged in as admin
|
||||
When I send a DELETE request to "/api/v1/proprietary/ui-data/infrastructure/api-keys/999999" with JWT authentication
|
||||
Then the response status code should be one of "204, 403, 404"
|
||||
|
||||
# =========================================================================
|
||||
# LEGACY KEY BACKWARD COMPATIBILITY
|
||||
# =========================================================================
|
||||
|
||||
@positive
|
||||
Scenario: The legacy global API key still authenticates
|
||||
When I send a GET request to "/api/v1/auth/me" with API key "123456789"
|
||||
Then the response status code should be 200
|
||||
And the response JSON should have field "username"
|
||||
|
||||
@negative
|
||||
Scenario: An unknown API key is rejected
|
||||
When I send a GET request to "/api/v1/auth/me" with API key "not-a-real-api-key-000"
|
||||
Then the response status code should be 401
|
||||
Reference in New Issue
Block a user