diff --git a/backend/src/main/java/com/krrishg/config/EncryptionService.java b/backend/src/main/java/com/krrishg/config/EncryptionService.java new file mode 100644 index 0000000..2dbedb6 --- /dev/null +++ b/backend/src/main/java/com/krrishg/config/EncryptionService.java @@ -0,0 +1,98 @@ +package com.krrishg.config; + +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.util.Base64; + +@Component +public class EncryptionService { + + private static final Logger log = LoggerFactory.getLogger(EncryptionService.class); + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_IV_LENGTH = 12; + private static final int GCM_TAG_LENGTH = 128; + + private final String masterKeyPath; + private SecretKey key; + + public EncryptionService( + @Value("${indie.encryption.master-key-path:config/encryption.key}") String masterKeyPath) { + this.masterKeyPath = masterKeyPath; + } + + @PostConstruct + void init() { + Path path = Path.of(masterKeyPath); + if (Files.exists(path)) { + try { + byte[] encoded = Files.readAllBytes(path); + this.key = new SecretKeySpec(Base64.getDecoder().decode(encoded), "AES"); + log.info("Loaded encryption key from {}", masterKeyPath); + return; + } catch (Exception e) { + log.warn("Failed to load encryption key from {}, generating new one: {}", masterKeyPath, e.getMessage()); + } + } + try { + KeyGenerator kg = KeyGenerator.getInstance("AES"); + kg.init(256); + this.key = kg.generateKey(); + Files.createDirectories(path.getParent()); + Files.write(path, Base64.getEncoder().encode(key.getEncoded())); + log.info("Generated and saved encryption key to {}", masterKeyPath); + } catch (Exception e) { + log.warn("Failed to persist encryption key, using ephemeral key (data lost on restart): {}", e.getMessage()); + try { + KeyGenerator kg = KeyGenerator.getInstance("AES"); + kg.init(256); + this.key = kg.generateKey(); + } catch (Exception ex) { + throw new RuntimeException("Failed to generate encryption key", ex); + } + } + } + + public String encrypt(String plaintext) { + try { + byte[] iv = new byte[GCM_IV_LENGTH]; + SecureRandom.getInstanceStrong().nextBytes(iv); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv)); + byte[] ciphertext = cipher.doFinal(plaintext.getBytes()); + ByteBuffer buffer = ByteBuffer.allocate(iv.length + ciphertext.length); + buffer.put(iv); + buffer.put(ciphertext); + return Base64.getEncoder().encodeToString(buffer.array()); + } catch (Exception e) { + throw new RuntimeException("Encryption failed", e); + } + } + + public String decrypt(String encrypted) { + try { + ByteBuffer buffer = ByteBuffer.wrap(Base64.getDecoder().decode(encrypted)); + byte[] iv = new byte[GCM_IV_LENGTH]; + buffer.get(iv); + byte[] ciphertext = new byte[buffer.remaining()]; + buffer.get(ciphertext); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH, iv)); + return new String(cipher.doFinal(ciphertext)); + } catch (Exception e) { + throw new RuntimeException("Decryption failed", e); + } + } +} diff --git a/backend/src/main/java/com/krrishg/config/MapToJsonConverter.java b/backend/src/main/java/com/krrishg/config/MapToJsonConverter.java new file mode 100644 index 0000000..a478d64 --- /dev/null +++ b/backend/src/main/java/com/krrishg/config/MapToJsonConverter.java @@ -0,0 +1,38 @@ +package com.krrishg.config; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; + +import java.util.Map; + +@Converter +public class MapToJsonConverter implements AttributeConverter, String> { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(Map attribute) { + if (attribute == null || attribute.isEmpty()) { + return null; + } + try { + return MAPPER.writeValueAsString(attribute); + } catch (Exception e) { + throw new RuntimeException("Failed to serialize map to JSON", e); + } + } + + @Override + public Map convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) { + return Map.of(); + } + try { + return MAPPER.readValue(dbData, new TypeReference>() {}); + } catch (Exception e) { + throw new RuntimeException("Failed to deserialize JSON to map", e); + } + } +} diff --git a/backend/src/main/java/com/krrishg/controller/LLMController.java b/backend/src/main/java/com/krrishg/controller/LLMController.java index a644bc7..2ce566f 100644 --- a/backend/src/main/java/com/krrishg/controller/LLMController.java +++ b/backend/src/main/java/com/krrishg/controller/LLMController.java @@ -53,14 +53,28 @@ public class LLMController { } UUID siteId = UUID.fromString(siteIdStr); + String provider = (String) request.get("provider"); + if (provider == null || provider.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("error", "provider is required")); + } + + String apiKey = (String) request.get("apiKey"); + String baseUrl = (String) request.get("baseUrl"); + String model = (String) request.get("model"); + List references = null; if (request.containsKey("references")) { references = objectMapper.convertValue( request.get("references"), new TypeReference>() {}); } - SiteStructure structure = llmService.generateSite(principal.id(), siteId, prompt, references); - return ResponseEntity.ok(structure); + try { + SiteStructure structure = llmService.generateSite( + principal.id(), siteId, prompt, references, provider, apiKey, baseUrl, model); + return ResponseEntity.ok(structure); + } catch (IllegalStateException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } } @PostMapping("/refine") @@ -85,6 +99,15 @@ public class LLMController { } UUID siteId = UUID.fromString(siteIdStr); + String provider = (String) request.get("provider"); + if (provider == null || provider.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("error", "provider is required")); + } + + String apiKey = (String) request.get("apiKey"); + String baseUrl = (String) request.get("baseUrl"); + String model = (String) request.get("model"); + SiteStructure currentStructure; try { currentStructure = objectMapper.convertValue( @@ -103,9 +126,13 @@ public class LLMController { request.get("references"), new TypeReference>() {}); } - SiteStructure structure = llmService.refineSite(principal.id(), siteId, - currentStructure, prompt, references); - return ResponseEntity.ok(structure); + try { + SiteStructure structure = llmService.refineSite(principal.id(), siteId, + currentStructure, prompt, references, provider, apiKey, baseUrl, model); + return ResponseEntity.ok(structure); + } catch (IllegalStateException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } } @GetMapping("/versions/{siteId}") diff --git a/backend/src/main/java/com/krrishg/controller/UserSettingsController.java b/backend/src/main/java/com/krrishg/controller/UserSettingsController.java new file mode 100644 index 0000000..8b86880 --- /dev/null +++ b/backend/src/main/java/com/krrishg/controller/UserSettingsController.java @@ -0,0 +1,124 @@ +package com.krrishg.controller; + +import com.krrishg.config.EncryptionService; +import com.krrishg.config.UserPrincipal; +import com.krrishg.dto.LLMConfigRequest; +import com.krrishg.model.User; +import com.krrishg.repository.UserRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/user") +public class UserSettingsController { + + private final UserRepository userRepository; + private final EncryptionService encryptionService; + + public UserSettingsController(UserRepository userRepository, EncryptionService encryptionService) { + this.userRepository = userRepository; + this.encryptionService = encryptionService; + } + + @GetMapping("/llm-config") + public ResponseEntity> getLlmConfig( + @AuthenticationPrincipal UserPrincipal principal) { + User user = userRepository.findById(principal.id()) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + Map keys = user.getLlmApiKeys(); + Map urls = user.getLlmBaseUrls(); + Map models = user.getLlmModels(); + Map result = new HashMap<>(); + if (keys != null) { + for (String provider : keys.keySet()) { + Map config = new HashMap<>(); + config.put("configured", true); + if (urls != null && urls.containsKey(provider)) { + config.put("baseUrl", urls.get(provider)); + } + if (models != null && models.containsKey(provider)) { + config.put("model", models.get(provider)); + } + result.put(provider, config); + } + } + return ResponseEntity.ok(result); + } + + @PutMapping("/llm-config") + public ResponseEntity saveLlmConfig( + @AuthenticationPrincipal UserPrincipal principal, + @RequestBody LLMConfigRequest request) { + if (request.provider() == null || request.provider().isBlank()) { + return ResponseEntity.badRequest().build(); + } + User user = userRepository.findById(principal.id()) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + if (request.apiKey() != null && !request.apiKey().isBlank()) { + Map keys = new HashMap<>(); + if (user.getLlmApiKeys() != null) { + keys.putAll(user.getLlmApiKeys()); + } + keys.put(request.provider(), encryptionService.encrypt(request.apiKey())); + user.setLlmApiKeys(keys); + } + + if (request.baseUrl() != null && !request.baseUrl().isBlank()) { + Map urls = new HashMap<>(); + if (user.getLlmBaseUrls() != null) { + urls.putAll(user.getLlmBaseUrls()); + } + urls.put(request.provider(), request.baseUrl().trim()); + user.setLlmBaseUrls(urls); + } + + if (request.model() != null && !request.model().isBlank()) { + Map models = new HashMap<>(); + if (user.getLlmModels() != null) { + models.putAll(user.getLlmModels()); + } + models.put(request.provider(), request.model().trim()); + user.setLlmModels(models); + } + + userRepository.save(user); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/llm-config/{provider}") + public ResponseEntity deleteLlmConfig( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable String provider) { + User user = userRepository.findById(principal.id()) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + + Map keys = new HashMap<>(); + if (user.getLlmApiKeys() != null) { + keys.putAll(user.getLlmApiKeys()); + } + keys.remove(provider); + user.setLlmApiKeys(keys); + + Map urls = new HashMap<>(); + if (user.getLlmBaseUrls() != null) { + urls.putAll(user.getLlmBaseUrls()); + } + urls.remove(provider); + user.setLlmBaseUrls(urls); + + Map models = new HashMap<>(); + if (user.getLlmModels() != null) { + models.putAll(user.getLlmModels()); + } + models.remove(provider); + user.setLlmModels(models); + + userRepository.save(user); + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/krrishg/dto/LLMConfigRequest.java b/backend/src/main/java/com/krrishg/dto/LLMConfigRequest.java new file mode 100644 index 0000000..11087a3 --- /dev/null +++ b/backend/src/main/java/com/krrishg/dto/LLMConfigRequest.java @@ -0,0 +1,12 @@ +package com.krrishg.dto; + +public record LLMConfigRequest(String provider, String apiKey, String baseUrl, String model) { + + public LLMConfigRequest(String provider, String apiKey) { + this(provider, apiKey, null, null); + } + + public LLMConfigRequest(String provider, String apiKey, String baseUrl) { + this(provider, apiKey, baseUrl, null); + } +} diff --git a/backend/src/main/java/com/krrishg/model/User.java b/backend/src/main/java/com/krrishg/model/User.java index 635997d..dcb731b 100644 --- a/backend/src/main/java/com/krrishg/model/User.java +++ b/backend/src/main/java/com/krrishg/model/User.java @@ -1,5 +1,6 @@ package com.krrishg.model; +import com.krrishg.config.MapToJsonConverter; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; @@ -7,6 +8,7 @@ import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.Map; import java.util.UUID; @Entity @@ -44,6 +46,18 @@ public class User { @Builder.Default private boolean emailVerified = false; + @Convert(converter = MapToJsonConverter.class) + @Column(name = "llm_api_keys", columnDefinition = "TEXT") + private Map llmApiKeys; + + @Convert(converter = MapToJsonConverter.class) + @Column(name = "llm_base_urls", columnDefinition = "TEXT") + private Map llmBaseUrls; + + @Convert(converter = MapToJsonConverter.class) + @Column(name = "llm_models", columnDefinition = "TEXT") + private Map llmModels; + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; diff --git a/backend/src/main/java/com/krrishg/service/LLMService.java b/backend/src/main/java/com/krrishg/service/LLMService.java index 5c41656..5b9a65c 100644 --- a/backend/src/main/java/com/krrishg/service/LLMService.java +++ b/backend/src/main/java/com/krrishg/service/LLMService.java @@ -1,12 +1,16 @@ package com.krrishg.service; +import com.krrishg.config.EncryptionService; import com.krrishg.dto.LLMOptions; import com.krrishg.dto.LLMResult; import com.krrishg.dto.Reference; import com.krrishg.dto.SiteStructure; +import org.springframework.web.client.HttpClientErrorException; import com.krrishg.model.GenerationVersion; -import com.krrishg.service.llm.LLMFactory; +import com.krrishg.model.User; +import com.krrishg.repository.UserRepository; import com.krrishg.service.llm.LLMProvider; +import com.krrishg.service.llm.ProviderFactory; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,6 +29,7 @@ import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.stream.Collectors; @@ -34,34 +39,54 @@ public class LLMService { private static final Logger log = LoggerFactory.getLogger(LLMService.class); private static final int MAX_VERSIONS_PER_SITE = 100; - private final LLMFactory llmFactory; + private final ProviderFactory providerFactory; private final LLMResponseParser responseParser; private final ReferenceService referenceService; private final MongoTemplate mongoTemplate; + private final UserRepository userRepository; + private final EncryptionService encryptionService; private final String systemPrompt; private final String refinerPrompt; - public LLMService(LLMFactory llmFactory, + public LLMService(ProviderFactory providerFactory, LLMResponseParser responseParser, ReferenceService referenceService, MongoTemplate mongoTemplate, + UserRepository userRepository, + EncryptionService encryptionService, ResourceLoader resourceLoader, @Value("${indie.llm.system-prompt-path:classpath:prompts/site-generator.txt}") String promptPath, @Value("${indie.llm.refiner-prompt-path:classpath:prompts/site-refiner.txt}") String refinerPath) { - this.llmFactory = llmFactory; + this.providerFactory = providerFactory; this.responseParser = responseParser; this.referenceService = referenceService; this.mongoTemplate = mongoTemplate; + this.userRepository = userRepository; + this.encryptionService = encryptionService; this.systemPrompt = loadSystemPrompt(resourceLoader, promptPath); this.refinerPrompt = loadSystemPrompt(resourceLoader, refinerPath); } - public SiteStructure generateSite(UUID userId, UUID siteId, String prompt, List references) { + public SiteStructure generateSite(UUID userId, UUID siteId, String prompt, + List references, String provider, String apiKey) { + return generateSite(userId, siteId, prompt, references, provider, apiKey, null, null); + } + + public SiteStructure generateSite(UUID userId, UUID siteId, String prompt, + List references, String provider, String apiKey, String baseUrl) { + return generateSite(userId, siteId, prompt, references, provider, apiKey, baseUrl, null); + } + + public SiteStructure generateSite(UUID userId, UUID siteId, String prompt, + List references, String provider, String apiKey, String baseUrl, String model) { + String resolvedKey = resolveApiKey(userId, provider, apiKey); + String resolvedBaseUrl = resolveBaseUrl(userId, provider, baseUrl); + String resolvedModel = resolveModel(userId, provider, model); String referenceContext = referenceService.buildReferenceContext(references); String fullPrompt = prompt + referenceContext; LLMOptions options = LLMOptions.builder().build(); - LLMResult result = callLLM(systemPrompt, fullPrompt, references, options); + LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel, systemPrompt, fullPrompt, references, options); log.info("LLM generation completed: model={}, latency={}ms, tokens={}+{}", result.getModel(), result.getLatencyMs(), result.getPromptTokens(), result.getCompletionTokens()); @@ -73,7 +98,23 @@ public class LLMService { } public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure, - String refinementPrompt, List references) { + String refinementPrompt, List references, + String provider, String apiKey) { + return refineSite(userId, siteId, currentStructure, refinementPrompt, references, provider, apiKey, null, null); + } + + public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure, + String refinementPrompt, List references, + String provider, String apiKey, String baseUrl) { + return refineSite(userId, siteId, currentStructure, refinementPrompt, references, provider, apiKey, baseUrl, null); + } + + public SiteStructure refineSite(UUID userId, UUID siteId, SiteStructure currentStructure, + String refinementPrompt, List references, + String provider, String apiKey, String baseUrl, String model) { + String resolvedKey = resolveApiKey(userId, provider, apiKey); + String resolvedBaseUrl = resolveBaseUrl(userId, provider, baseUrl); + String resolvedModel = resolveModel(userId, provider, model); String currentJson = serializeStructure(currentStructure); String referenceContext = referenceService.buildReferenceContext(references); String combinedPrompt = "Current site structure:\n" + currentJson @@ -84,7 +125,7 @@ public class LLMService { LLMOptions options = LLMOptions.builder() .temperature(0.5) .build(); - LLMResult result = callLLM(refinerPrompt, combinedPrompt, references, options); + LLMResult result = callLLM(provider, resolvedKey, resolvedBaseUrl, resolvedModel, refinerPrompt, combinedPrompt, references, options); log.info("LLM refinement completed: model={}, latency={}ms, tokens={}+{}", result.getModel(), result.getLatencyMs(), result.getPromptTokens(), result.getCompletionTokens()); @@ -118,16 +159,62 @@ public class LLMService { mongoTemplate.remove(version); } - private LLMResult callLLM(String systemPrompt, String userPrompt, List references, LLMOptions options) { - LLMProvider provider = llmFactory.getActiveProvider(); + private String resolveApiKey(UUID userId, String provider, String apiKey) { + if (apiKey != null && !apiKey.isBlank()) { + return apiKey; + } + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + Map keys = user.getLlmApiKeys(); + if (keys != null && keys.containsKey(provider)) { + return encryptionService.decrypt(keys.get(provider)); + } + throw new IllegalStateException("No API key configured for " + provider + + ". Add one in Settings."); + } + + private String resolveModel(UUID userId, String provider, String model) { + if (model != null && !model.isBlank()) { + return model; + } + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + Map models = user.getLlmModels(); + if (models != null && models.containsKey(provider)) { + return models.get(provider); + } + return null; + } + + private String resolveBaseUrl(UUID userId, String provider, String baseUrl) { + if (baseUrl != null && !baseUrl.isBlank()) { + return baseUrl; + } + User user = userRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("User not found")); + Map urls = user.getLlmBaseUrls(); + if (urls != null && urls.containsKey(provider)) { + return urls.get(provider); + } + return null; + } + + private LLMResult callLLM(String provider, String apiKey, String baseUrl, String model, String systemPrompt, + String userPrompt, List references, LLMOptions options) { + LLMProvider p = providerFactory.createProvider(provider, apiKey, baseUrl, model); try { - return provider.generate(systemPrompt, userPrompt, references, options); - } catch (Exception e) { - log.warn("Active provider {} failed: {}. Trying fallback...", - provider.getName(), e.getMessage()); - return llmFactory.getFallbackProvider() - .orElseThrow(() -> new RuntimeException("All LLM providers failed", e)) - .generate(systemPrompt, userPrompt, references, options); + return p.generate(systemPrompt, userPrompt, references, options); + } catch (HttpClientErrorException e) { + String detail = e.getResponseBodyAsString(); + String msg = switch (e.getStatusCode().value()) { + case 401 -> provider + " rejected the API key (401 Unauthorized). Verify the key is valid in Settings."; + case 429 -> provider + " rate limit exceeded (429). Try again later."; + default -> { + String body = (detail != null && !detail.isBlank()) ? ": " + detail : ""; + yield provider + " API error: " + e.getStatusCode() + " " + e.getStatusText() + body; + } + }; + throw new IllegalStateException(msg); } } diff --git a/backend/src/main/java/com/krrishg/service/llm/GeminiProvider.java b/backend/src/main/java/com/krrishg/service/llm/GeminiProvider.java index fd06c9c..bc2e9f2 100644 --- a/backend/src/main/java/com/krrishg/service/llm/GeminiProvider.java +++ b/backend/src/main/java/com/krrishg/service/llm/GeminiProvider.java @@ -3,18 +3,13 @@ package com.krrishg.service.llm; import com.krrishg.dto.LLMResult; import com.krrishg.dto.LLMOptions; import com.krrishg.dto.Reference; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.*; -import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; -import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Map; -@Component public class GeminiProvider implements LLMProvider { private final RestTemplate restTemplate; @@ -22,18 +17,11 @@ public class GeminiProvider implements LLMProvider { private final String model; private final String baseUrl; - public GeminiProvider( - @Value("${indie.llm.providers.gemini.api-key:}") String apiKey, - @Value("${indie.llm.providers.gemini.model:gemini-2.0-flash}") String model, - @Value("${indie.llm.providers.gemini.url:https://generativelanguage.googleapis.com/v1beta/models}") String baseUrl, - RestTemplateBuilder builder) { + public GeminiProvider(String apiKey, String model, String baseUrl, RestTemplate restTemplate) { this.apiKey = apiKey; this.model = model; this.baseUrl = baseUrl; - this.restTemplate = builder - .setConnectTimeout(Duration.ofSeconds(30)) - .setReadTimeout(Duration.ofSeconds(60)) - .build(); + this.restTemplate = restTemplate; } @Override diff --git a/backend/src/main/java/com/krrishg/service/llm/LLMFactory.java b/backend/src/main/java/com/krrishg/service/llm/LLMFactory.java deleted file mode 100644 index 05089ab..0000000 --- a/backend/src/main/java/com/krrishg/service/llm/LLMFactory.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.krrishg.service.llm; - -import jakarta.annotation.PostConstruct; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; -import java.util.stream.Collectors; - -@Component -public class LLMFactory { - - private static final Logger log = LoggerFactory.getLogger(LLMFactory.class); - - private final List providers; - private final String activeProvider; - private final String fallbackProvider; - - private Map providerMap; - - public LLMFactory(List providers, - @Value("${indie.llm.active-provider:gemini}") String activeProvider, - @Value("${indie.llm.fallback-provider:openai}") String fallbackProvider) { - this.providers = providers; - this.activeProvider = activeProvider; - this.fallbackProvider = fallbackProvider; - } - - @PostConstruct - void init() { - providerMap = providers.stream() - .collect(Collectors.toMap(LLMProvider::getName, Function.identity())); - log.info("Available LLM providers: {}", providerMap.keySet()); - } - - public LLMProvider getActiveProvider() { - return getProvider(activeProvider) - .orElseThrow(() -> new IllegalStateException( - "Active LLM provider '" + activeProvider + "' not found. Available: " + providerMap.keySet())); - } - - public Optional getFallbackProvider() { - return getProvider(fallbackProvider); - } - - private Optional getProvider(String name) { - return Optional.ofNullable(providerMap.get(name)); - } -} diff --git a/backend/src/main/java/com/krrishg/service/llm/OpenAIProvider.java b/backend/src/main/java/com/krrishg/service/llm/OpenAIProvider.java index 8d294e2..282ed7e 100644 --- a/backend/src/main/java/com/krrishg/service/llm/OpenAIProvider.java +++ b/backend/src/main/java/com/krrishg/service/llm/OpenAIProvider.java @@ -3,17 +3,12 @@ package com.krrishg.service.llm; import com.krrishg.dto.LLMResult; import com.krrishg.dto.LLMOptions; import com.krrishg.dto.Reference; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.*; -import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; -import java.time.Duration; import java.util.List; import java.util.Map; -@Component public class OpenAIProvider implements LLMProvider { private final RestTemplate restTemplate; @@ -21,18 +16,11 @@ public class OpenAIProvider implements LLMProvider { private final String model; private final String url; - public OpenAIProvider( - @Value("${indie.llm.providers.openai.api-key:}") String apiKey, - @Value("${indie.llm.providers.openai.model:gpt-4o-mini}") String model, - @Value("${indie.llm.providers.openai.url:https://api.openai.com/v1/chat/completions}") String url, - RestTemplateBuilder builder) { + public OpenAIProvider(String apiKey, String model, String url, RestTemplate restTemplate) { this.apiKey = apiKey; this.model = model; this.url = url; - this.restTemplate = builder - .setConnectTimeout(Duration.ofSeconds(30)) - .setReadTimeout(Duration.ofSeconds(60)) - .build(); + this.restTemplate = restTemplate; } @Override diff --git a/backend/src/main/java/com/krrishg/service/llm/ProviderFactory.java b/backend/src/main/java/com/krrishg/service/llm/ProviderFactory.java new file mode 100644 index 0000000..ec3afe9 --- /dev/null +++ b/backend/src/main/java/com/krrishg/service/llm/ProviderFactory.java @@ -0,0 +1,69 @@ +package com.krrishg.service.llm; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +@Component +public class ProviderFactory { + + private final RestTemplateBuilder restTemplateBuilder; + private final Map providerConfigs; + + public ProviderFactory( + @Value("${indie.llm.providers.gemini.model:gemini-3.1-flash-lite}") String geminiModel, + @Value("${indie.llm.providers.gemini.url:https://generativelanguage.googleapis.com/v1beta/models}") String geminiUrl, + @Value("${indie.llm.providers.openai.model:gpt-4o-mini}") String openaiModel, + @Value("${indie.llm.providers.openai.url:https://api.openai.com/v1/chat/completions}") String openaiUrl, + RestTemplateBuilder restTemplateBuilder) { + this.restTemplateBuilder = restTemplateBuilder; + this.providerConfigs = Map.of( + "gemini", new ProviderConfig(geminiModel, geminiUrl), + "openai", new ProviderConfig(openaiModel, openaiUrl) + ); + } + + public LLMProvider createProvider(String name, String apiKey) { + return createProvider(name, apiKey, null, null); + } + + public LLMProvider createProvider(String name, String apiKey, String baseUrl) { + return createProvider(name, apiKey, baseUrl, null); + } + + public LLMProvider createProvider(String name, String apiKey, String baseUrl, String model) { + ProviderConfig config = providerConfigs.get(name); + if (config == null) { + throw new IllegalArgumentException("Unknown LLM provider: " + name + + ". Available: " + providerConfigs.keySet()); + } + String resolvedModel = (model != null && !model.isBlank()) ? model : config.model; + String url = config.url; + if (baseUrl != null && !baseUrl.isBlank()) { + url = baseUrl; + if ("openai".equals(name) && !url.endsWith("/chat/completions")) { + url = url.endsWith("/") ? url + "chat/completions" : url + "/chat/completions"; + } + } + RestTemplate rt = restTemplateBuilder + .setConnectTimeout(Duration.ofSeconds(30)) + .setReadTimeout(Duration.ofSeconds(60)) + .build(); + return switch (name) { + case "gemini" -> new GeminiProvider(apiKey, resolvedModel, url, rt); + case "openai" -> new OpenAIProvider(apiKey, resolvedModel, url, rt); + default -> throw new IllegalArgumentException("Unknown LLM provider: " + name); + }; + } + + public List getAvailableProviders() { + return List.copyOf(providerConfigs.keySet()); + } + + private record ProviderConfig(String model, String url) {} +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index f846f10..de3f7e7 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -55,17 +55,16 @@ indie: bucket: ${INDIE_STORAGE_BUCKET:indie-sites} public-url-base: ${INDIE_STORAGE_PUBLIC_URL:http://localhost:9000/indie-sites} + encryption: + master-key-path: ${INDIE_ENCRYPTION_KEY_PATH:config/encryption.key} + llm: - active-provider: ${INDIE_LLM_ACTIVE_PROVIDER:gemini} - fallback-provider: ${INDIE_LLM_FALLBACK_PROVIDER:openai} rate-limit: max-requests-per-hour: ${INDIE_LLM_RATE_LIMIT:10} providers: gemini: - api-key: ${GEMINI_API_KEY:} model: ${GEMINI_MODEL:gemini-3.1-flash-lite} url: https://generativelanguage.googleapis.com/v1beta/models openai: - api-key: ${OPENAI_API_KEY:} model: ${OPENAI_MODEL:gpt-4o-mini} url: https://api.openai.com/v1/chat/completions diff --git a/backend/src/test/java/com/krrishg/controller/LLMControllerTest.java b/backend/src/test/java/com/krrishg/controller/LLMControllerTest.java index 7434bf9..ad9c17a 100644 --- a/backend/src/test/java/com/krrishg/controller/LLMControllerTest.java +++ b/backend/src/test/java/com/krrishg/controller/LLMControllerTest.java @@ -76,11 +76,12 @@ class LLMControllerTest { .build() )) .build(); - when(llmService.generateSite(any(), any(), anyString(), any())).thenReturn(structure); + when(llmService.generateSite(any(), any(), anyString(), any(), anyString(), any(), any(), any())).thenReturn(structure); Map request = Map.of( "prompt", "Build a site", - "siteId", UUID.randomUUID().toString() + "siteId", UUID.randomUUID().toString(), + "provider", "gemini" ); mockMvc.perform(post("/api/llm/generate") @@ -103,11 +104,12 @@ class LLMControllerTest { .build() )) .build(); - when(llmService.generateSite(any(), any(), anyString(), any())).thenReturn(structure); + when(llmService.generateSite(any(), any(), anyString(), any(), anyString(), any(), any(), any())).thenReturn(structure); Map request = Map.of( "prompt", "Build a site", "siteId", UUID.randomUUID().toString(), + "provider", "gemini", "references", List.of( Map.of("type", "IMAGE", "url", "data:img", "altText", "Photo") ) @@ -120,6 +122,34 @@ class LLMControllerTest { .andExpect(status().isOk()); } + @Test + void returns200WithPerRequestApiKey() throws Exception { + when(rateLimitConfig.tryConsume(userId)).thenReturn(true); + + SiteStructure structure = SiteStructure.builder() + .pages(List.of( + SiteStructure.PageStructure.builder() + .title("Home").slug("home") + .rawHtml("

Home

") + .build() + )) + .build(); + when(llmService.generateSite(any(), any(), anyString(), any(), eq("gemini"), eq("custom-key"), any(), any())).thenReturn(structure); + + Map request = Map.of( + "prompt", "Build a site", + "siteId", UUID.randomUUID().toString(), + "provider", "gemini", + "apiKey", "custom-key" + ); + + mockMvc.perform(post("/api/llm/generate") + .with(authentication(auth)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + @Test void returns429WhenRateLimited() throws Exception { when(rateLimitConfig.tryConsume(userId)).thenReturn(false); @@ -127,7 +157,8 @@ class LLMControllerTest { Map request = Map.of( "prompt", "Build a site", - "siteId", UUID.randomUUID().toString() + "siteId", UUID.randomUUID().toString(), + "provider", "gemini" ); mockMvc.perform(post("/api/llm/generate") @@ -142,7 +173,8 @@ class LLMControllerTest { when(rateLimitConfig.tryConsume(userId)).thenReturn(true); Map request = Map.of( - "siteId", UUID.randomUUID().toString() + "siteId", UUID.randomUUID().toString(), + "provider", "gemini" ); mockMvc.perform(post("/api/llm/generate") @@ -153,43 +185,12 @@ class LLMControllerTest { } @Test - void returns400WhenPromptBlank() throws Exception { - when(rateLimitConfig.tryConsume(userId)).thenReturn(true); - - Map request = Map.of( - "prompt", " ", - "siteId", UUID.randomUUID().toString() - ); - - mockMvc.perform(post("/api/llm/generate") - .with(authentication(auth)) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - } - - @Test - void returns400WhenSiteIdMissing() throws Exception { - when(rateLimitConfig.tryConsume(userId)).thenReturn(true); - - Map request = Map.of( - "prompt", "Build a site" - ); - - mockMvc.perform(post("/api/llm/generate") - .with(authentication(auth)) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - } - - @Test - void returns400WhenSiteIdBlank() throws Exception { + void returns400WhenProviderMissing() throws Exception { when(rateLimitConfig.tryConsume(userId)).thenReturn(true); Map request = Map.of( "prompt", "Build a site", - "siteId", "" + "siteId", UUID.randomUUID().toString() ); mockMvc.perform(post("/api/llm/generate") @@ -198,6 +199,26 @@ class LLMControllerTest { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); } + + @Test + void returns400WhenNoKeyConfigured() throws Exception { + when(rateLimitConfig.tryConsume(userId)).thenReturn(true); + when(llmService.generateSite(any(), any(), anyString(), any(), anyString(), any(), any(), any())) + .thenThrow(new IllegalStateException("No API key configured for gemini")); + + Map request = Map.of( + "prompt", "Build a site", + "siteId", UUID.randomUUID().toString(), + "provider", "gemini" + ); + + mockMvc.perform(post("/api/llm/generate") + .with(authentication(auth)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.error").value("No API key configured for gemini")); + } } @Nested @@ -223,11 +244,12 @@ class LLMControllerTest { .build() )) .build(); - when(llmService.refineSite(any(), any(), any(), anyString(), any())).thenReturn(refined); + when(llmService.refineSite(any(), any(), any(), anyString(), any(), anyString(), any(), any(), any())).thenReturn(refined); Map request = Map.of( "prompt", "Make it better", "siteId", UUID.randomUUID().toString(), + "provider", "gemini", "currentStructure", current ); @@ -247,6 +269,7 @@ class LLMControllerTest { Map request = Map.of( "prompt", "Refine", "siteId", UUID.randomUUID().toString(), + "provider", "gemini", "currentStructure", Map.of() ); @@ -258,10 +281,11 @@ class LLMControllerTest { } @Test - void returns400WhenPromptMissing() throws Exception { + void returns400WhenProviderMissing() throws Exception { when(rateLimitConfig.tryConsume(userId)).thenReturn(true); Map request = Map.of( + "prompt", "Refine", "siteId", UUID.randomUUID().toString(), "currentStructure", SiteStructure.builder().build() ); @@ -272,38 +296,6 @@ class LLMControllerTest { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); } - - @Test - void returns400WhenSiteIdMissing() throws Exception { - when(rateLimitConfig.tryConsume(userId)).thenReturn(true); - - Map request = Map.of( - "prompt", "Refine", - "currentStructure", SiteStructure.builder().build() - ); - - mockMvc.perform(post("/api/llm/refine") - .with(authentication(auth)) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - } - - @Test - void returns400WhenCurrentStructureMissing() throws Exception { - when(rateLimitConfig.tryConsume(userId)).thenReturn(true); - - Map request = Map.of( - "prompt", "Refine", - "siteId", UUID.randomUUID().toString() - ); - - mockMvc.perform(post("/api/llm/refine") - .with(authentication(auth)) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - } } @Nested diff --git a/backend/src/test/java/com/krrishg/service/LLMServiceTest.java b/backend/src/test/java/com/krrishg/service/LLMServiceTest.java index b154ecd..4dbed5a 100644 --- a/backend/src/test/java/com/krrishg/service/LLMServiceTest.java +++ b/backend/src/test/java/com/krrishg/service/LLMServiceTest.java @@ -3,8 +3,11 @@ package com.krrishg.service; import com.krrishg.dto.Reference; import com.krrishg.dto.SiteStructure; import com.krrishg.model.GenerationVersion; -import com.krrishg.service.llm.LLMFactory; +import com.krrishg.model.User; +import com.krrishg.support.FakeEncryptionService; import com.krrishg.support.FakeLLMProvider; +import com.krrishg.support.FakeProviderFactory; +import com.krrishg.support.FakeUserRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -19,9 +22,9 @@ import org.springframework.data.mongodb.core.query.Query; import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; -import java.lang.reflect.Method; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; @@ -37,11 +40,12 @@ class LLMServiceTest { @Mock private ResourceLoader resourceLoader; - private FakeLLMProvider activeProvider; - private FakeLLMProvider fallbackProvider; - private LLMFactory llmFactory; + private FakeLLMProvider geminiProvider; + private FakeProviderFactory providerFactory; private LLMResponseParser responseParser; private FakeReferenceService referenceService; + private FakeUserRepository userRepository; + private FakeEncryptionService encryptionService; private LLMService llmService; private UUID userId; @@ -52,15 +56,22 @@ class LLMServiceTest { userId = UUID.randomUUID(); siteId = UUID.randomUUID(); - activeProvider = new FakeLLMProvider("gemini"); - fallbackProvider = new FakeLLMProvider("openai"); - llmFactory = new LLMFactory(List.of(activeProvider, fallbackProvider), "gemini", "openai"); - Method init = LLMFactory.class.getDeclaredMethod("init"); - init.setAccessible(true); - init.invoke(llmFactory); + geminiProvider = new FakeLLMProvider("gemini"); + providerFactory = new FakeProviderFactory() + .withProvider("gemini", geminiProvider); responseParser = new LLMResponseParser(new ObjectMapper()); referenceService = new FakeReferenceService(); + userRepository = new FakeUserRepository(); + encryptionService = new FakeEncryptionService(); + + User user = User.builder() + .id(userId) + .email("user@test.com") + .name("Test User") + .llmApiKeys(Map.of("gemini", encryptionService.encrypt("test-key"))) + .build(); + userRepository.save(user); Resource mockResource = mock(Resource.class); when(mockResource.exists()).thenReturn(true); @@ -68,14 +79,15 @@ class LLMServiceTest { new ByteArrayInputStream("You are a site generator.".getBytes(StandardCharsets.UTF_8))); when(resourceLoader.getResource(anyString())).thenReturn(mockResource); - llmService = new LLMService(llmFactory, responseParser, referenceService, - mongoTemplate, resourceLoader, "classpath:prompts/site-generator.txt", + llmService = new LLMService(providerFactory, responseParser, referenceService, + mongoTemplate, userRepository, encryptionService, + resourceLoader, "classpath:prompts/site-generator.txt", "classpath:prompts/site-refiner.txt"); } @Test void generateSiteReturnsParsedStructure() { - activeProvider.withContent(""" + geminiProvider.withContent(""" { "pages": [ {"title": "Home", "slug": "home", "rawHtml": "

Welcome

"} @@ -85,7 +97,7 @@ class LLMServiceTest { when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null); - SiteStructure result = llmService.generateSite(userId, siteId, "Build a site", null); + SiteStructure result = llmService.generateSite(userId, siteId, "Build a site", null, "gemini", null); assertEquals(1, result.getPages().size()); assertEquals("Home", result.getPages().get(0).getTitle()); @@ -94,7 +106,7 @@ class LLMServiceTest { @Test void generateSiteSavesVersionWithCorrectData() { - activeProvider.withContent(""" + geminiProvider.withContent(""" { "pages": [ {"title": "About", "slug": "about", "rawHtml": "

About

"} @@ -104,7 +116,7 @@ class LLMServiceTest { when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null); - llmService.generateSite(userId, siteId, "Build about page", null); + llmService.generateSite(userId, siteId, "Build about page", null, "gemini", null); ArgumentCaptor captor = ArgumentCaptor.forClass(GenerationVersion.class); verify(mongoTemplate).save(captor.capture()); @@ -119,7 +131,7 @@ class LLMServiceTest { @Test void generateSiteIncrementsVersionNumber() { - activeProvider.withContent(""" + geminiProvider.withContent(""" { "pages": [ {"title": "Home", "slug": "home", "rawHtml": "

Hero

"} @@ -134,7 +146,7 @@ class LLMServiceTest { .build(); when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(existing); - llmService.generateSite(userId, siteId, "Build a site", null); + llmService.generateSite(userId, siteId, "Build a site", null, "gemini", null); ArgumentCaptor captor = ArgumentCaptor.forClass(GenerationVersion.class); verify(mongoTemplate).save(captor.capture()); @@ -144,7 +156,7 @@ class LLMServiceTest { @Test void generateSiteWithReferences() { - activeProvider.withContent(""" + geminiProvider.withContent(""" { "pages": [ {"title": "Home", "slug": "home", "rawHtml": "

Hero

"} @@ -158,7 +170,7 @@ class LLMServiceTest { when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null); - llmService.generateSite(userId, siteId, "Build a site", references); + llmService.generateSite(userId, siteId, "Build a site", references, "gemini", null); ArgumentCaptor captor = ArgumentCaptor.forClass(GenerationVersion.class); verify(mongoTemplate).save(captor.capture()); @@ -169,31 +181,30 @@ class LLMServiceTest { } @Test - void generateSiteFallsBackOnProviderFailure() { - activeProvider.setFailure(new RuntimeException("API error")); - fallbackProvider.withContent(""" + void generateSiteUsesProvidedApiKeyOverStored() { + geminiProvider.withContent(""" { "pages": [ - {"title": "Fallback", "slug": "fallback", "rawHtml": "

Hero

"} + {"title": "Home", "slug": "home", "rawHtml": "

Hero

"} ] } """); when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null); - SiteStructure result = llmService.generateSite(userId, siteId, "Build", null); + llmService.generateSite(userId, siteId, "Build", null, "gemini", "override-key"); - assertEquals("Fallback", result.getPages().get(0).getTitle()); verify(mongoTemplate).save(any(GenerationVersion.class)); } @Test - void generateSiteThrowsWhenAllProvidersFail() { - activeProvider.setFailure(new RuntimeException("API error")); - fallbackProvider.setFailure(new RuntimeException("Fallback also failed")); + void generateSiteThrowsWhenNoKeyConfigured() { + User user = userRepository.findById(userId).orElseThrow(); + user.setLlmApiKeys(null); + userRepository.save(user); - assertThrows(RuntimeException.class, - () -> llmService.generateSite(userId, siteId, "Build", null)); + assertThrows(IllegalStateException.class, + () -> llmService.generateSite(userId, siteId, "Build", null, "gemini", null)); verify(mongoTemplate, never()).save(any()); } @@ -209,7 +220,7 @@ class LLMServiceTest { )) .build(); - activeProvider.withContent(""" + geminiProvider.withContent(""" { "pages": [ {"title": "Refined", "slug": "refined", "rawHtml": "

Hero

"} @@ -219,7 +230,7 @@ class LLMServiceTest { when(mongoTemplate.findOne(any(Query.class), eq(GenerationVersion.class))).thenReturn(null); - SiteStructure result = llmService.refineSite(userId, siteId, current, "Make it better", null); + SiteStructure result = llmService.refineSite(userId, siteId, current, "Make it better", null, "gemini", null); assertEquals("Refined", result.getPages().get(0).getTitle()); verify(mongoTemplate).save(any(GenerationVersion.class)); diff --git a/backend/src/test/java/com/krrishg/service/llm/GeminiProviderTest.java b/backend/src/test/java/com/krrishg/service/llm/GeminiProviderTest.java index 6b0c820..ac44d76 100644 --- a/backend/src/test/java/com/krrishg/service/llm/GeminiProviderTest.java +++ b/backend/src/test/java/com/krrishg/service/llm/GeminiProviderTest.java @@ -8,12 +8,10 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.HttpEntity; import org.springframework.http.ResponseEntity; import org.springframework.web.client.RestTemplate; -import java.time.Duration; import java.util.List; import java.util.Map; @@ -34,25 +32,9 @@ class GeminiProviderTest { @BeforeEach void setUp() { - RestTemplateBuilder builder = new RestTemplateBuilder() - .setConnectTimeout(Duration.ofSeconds(1)); - try { - var field = RestTemplateBuilder.class.getDeclaredField("interceptors"); - field.setAccessible(true); - } catch (Exception e) { - } - provider = new GeminiProvider("test-key", "gemini-2.0-flash", "https://generativelanguage.googleapis.com/v1beta/models", - builder); - - try { - var restTemplateField = GeminiProvider.class.getDeclaredField("restTemplate"); - restTemplateField.setAccessible(true); - restTemplateField.set(provider, restTemplate); - } catch (Exception e) { - throw new RuntimeException(e); - } + restTemplate); defaultOptions = LLMOptions.builder().build(); } diff --git a/backend/src/test/java/com/krrishg/service/llm/LLMFactoryTest.java b/backend/src/test/java/com/krrishg/service/llm/LLMFactoryTest.java deleted file mode 100644 index b2221a5..0000000 --- a/backend/src/test/java/com/krrishg/service/llm/LLMFactoryTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.krrishg.service.llm; - -import com.krrishg.support.FakeLLMProvider; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -class LLMFactoryTest { - - private FakeLLMProvider gemini; - private FakeLLMProvider openai; - private List providers; - - @BeforeEach - void setUp() { - gemini = new FakeLLMProvider("gemini"); - openai = new FakeLLMProvider("openai"); - providers = List.of(gemini, openai); - } - - @Test - void getActiveProviderReturnsConfiguredProvider() { - LLMFactory factory = new LLMFactory(providers, "gemini", "openai"); - factory.init(); - - LLMProvider active = factory.getActiveProvider(); - assertEquals("gemini", active.getName()); - } - - @Test - void getActiveProviderThrowsWhenNotFound() { - LLMFactory factory = new LLMFactory(providers, "nonexistent", "openai"); - factory.init(); - - assertThrows(IllegalStateException.class, factory::getActiveProvider); - } - - @Test - void getActiveProviderThrowsWhenNoProviders() { - LLMFactory factory = new LLMFactory(List.of(), "gemini", "openai"); - factory.init(); - - assertThrows(IllegalStateException.class, factory::getActiveProvider); - } - - @Test - void getFallbackProviderReturnsConfiguredFallback() { - LLMFactory factory = new LLMFactory(providers, "gemini", "openai"); - factory.init(); - - assertTrue(factory.getFallbackProvider().isPresent()); - assertEquals("openai", factory.getFallbackProvider().get().getName()); - } - - @Test - void getFallbackProviderReturnsEmptyWhenNotFound() { - LLMFactory factory = new LLMFactory(providers, "gemini", "nonexistent"); - factory.init(); - - assertTrue(factory.getFallbackProvider().isEmpty()); - } - - @Test - void getFallbackProviderReturnsEmptyWhenNotConfigured() { - LLMFactory factory = new LLMFactory(providers, "gemini", ""); - factory.init(); - - assertTrue(factory.getFallbackProvider().isEmpty()); - } - - @Test - void activeAndFallbackCanBeSameProvider() { - LLMFactory factory = new LLMFactory(providers, "gemini", "gemini"); - factory.init(); - - assertEquals("gemini", factory.getActiveProvider().getName()); - assertTrue(factory.getFallbackProvider().isPresent()); - assertEquals("gemini", factory.getFallbackProvider().get().getName()); - } - - @Test - void initLogsAvailableProviders() { - LLMFactory factory = new LLMFactory(providers, "gemini", "openai"); - assertDoesNotThrow(factory::init); - } -} diff --git a/backend/src/test/java/com/krrishg/service/llm/OpenAIProviderTest.java b/backend/src/test/java/com/krrishg/service/llm/OpenAIProviderTest.java index 865d852..7aefae5 100644 --- a/backend/src/test/java/com/krrishg/service/llm/OpenAIProviderTest.java +++ b/backend/src/test/java/com/krrishg/service/llm/OpenAIProviderTest.java @@ -7,12 +7,10 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.HttpEntity; import org.springframework.http.ResponseEntity; import org.springframework.web.client.RestTemplate; -import java.time.Duration; import java.util.List; import java.util.Map; @@ -32,19 +30,8 @@ class OpenAIProviderTest { @BeforeEach void setUp() { - RestTemplateBuilder builder = new RestTemplateBuilder() - .setConnectTimeout(Duration.ofSeconds(1)); - provider = new OpenAIProvider("sk-test", "gpt-4o-mini", - "https://api.openai.com/v1/chat/completions", builder); - - try { - var field = OpenAIProvider.class.getDeclaredField("restTemplate"); - field.setAccessible(true); - field.set(provider, restTemplate); - } catch (Exception e) { - throw new RuntimeException(e); - } + "https://api.openai.com/v1/chat/completions", restTemplate); defaultOptions = LLMOptions.builder().build(); } diff --git a/backend/src/test/java/com/krrishg/support/FakeEncryptionService.java b/backend/src/test/java/com/krrishg/support/FakeEncryptionService.java new file mode 100644 index 0000000..320218d --- /dev/null +++ b/backend/src/test/java/com/krrishg/support/FakeEncryptionService.java @@ -0,0 +1,25 @@ +package com.krrishg.support; + +import com.krrishg.config.EncryptionService; + +import java.util.Base64; + +public class FakeEncryptionService extends EncryptionService { + + public FakeEncryptionService() { + super("config/test-encryption.key"); + } + + public void init() { + } + + @Override + public String encrypt(String plaintext) { + return Base64.getEncoder().encodeToString(plaintext.getBytes()); + } + + @Override + public String decrypt(String encrypted) { + return new String(Base64.getDecoder().decode(encrypted)); + } +} diff --git a/backend/src/test/java/com/krrishg/support/FakeProviderFactory.java b/backend/src/test/java/com/krrishg/support/FakeProviderFactory.java new file mode 100644 index 0000000..d01431d --- /dev/null +++ b/backend/src/test/java/com/krrishg/support/FakeProviderFactory.java @@ -0,0 +1,49 @@ +package com.krrishg.support; + +import com.krrishg.service.llm.LLMProvider; +import com.krrishg.service.llm.ProviderFactory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class FakeProviderFactory extends ProviderFactory { + + private final Map providers = new HashMap<>(); + + public FakeProviderFactory() { + super("gemini-2.0-flash", "https://generativelanguage.googleapis.com/v1beta/models", + "gpt-4o-mini", "https://api.openai.com/v1/chat/completions", + null); + } + + public FakeProviderFactory withProvider(String name, FakeLLMProvider provider) { + providers.put(name, provider); + return this; + } + + @Override + public LLMProvider createProvider(String name, String apiKey) { + return createProvider(name, apiKey, null, null); + } + + @Override + public LLMProvider createProvider(String name, String apiKey, String baseUrl) { + return createProvider(name, apiKey, baseUrl, null); + } + + @Override + public LLMProvider createProvider(String name, String apiKey, String baseUrl, String model) { + FakeLLMProvider p = providers.get(name); + if (p == null) { + throw new IllegalArgumentException("Unknown LLM provider: " + name + + ". Available: " + providers.keySet()); + } + return p; + } + + @Override + public List getAvailableProviders() { + return List.copyOf(providers.keySet()); + } +} diff --git a/frontend/src/app/core/models/llm.ts b/frontend/src/app/core/models/llm.ts index 450e607..73a2a90 100644 --- a/frontend/src/app/core/models/llm.ts +++ b/frontend/src/app/core/models/llm.ts @@ -1,13 +1,21 @@ export interface LLMGenerateRequest { prompt: string; siteId: string; + provider: string; + apiKey?: string; + baseUrl?: string; + model?: string; references?: Reference[]; } export interface LLMRefineRequest { prompt: string; siteId: string; + provider: string; currentStructure: SiteStructure; + apiKey?: string; + baseUrl?: string; + model?: string; references?: Reference[]; } @@ -64,3 +72,11 @@ export interface GenerationVersion { fullStructure: string; createdAt: string; } + +export interface LLMKeyEntry { + configured: boolean; + baseUrl?: string; + model?: string; +} + +export type LLMKeyStatus = Record; diff --git a/frontend/src/app/core/services/llm-config.service.ts b/frontend/src/app/core/services/llm-config.service.ts new file mode 100644 index 0000000..2621be9 --- /dev/null +++ b/frontend/src/app/core/services/llm-config.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { LLMKeyStatus } from '../models/llm'; + +@Injectable({ providedIn: 'root' }) +export class LlmConfigService { + constructor(private http: HttpClient) {} + + getStatus(): Observable { + return this.http.get('/api/user/llm-config'); + } + + saveKey(provider: string, apiKey?: string, baseUrl?: string, model?: string): Observable { + return this.http.put('/api/user/llm-config', { provider, apiKey, baseUrl, model }); + } + + deleteKey(provider: string): Observable { + return this.http.delete(`/api/user/llm-config/${provider}`); + } +} diff --git a/frontend/src/app/llm-workspace/llm-workspace.component.ts b/frontend/src/app/llm-workspace/llm-workspace.component.ts index 18ee410..90d6f00 100644 --- a/frontend/src/app/llm-workspace/llm-workspace.component.ts +++ b/frontend/src/app/llm-workspace/llm-workspace.component.ts @@ -8,6 +8,7 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSelectModule } from '@angular/material/select'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatCardModule } from '@angular/material/card'; import { MatDividerModule } from '@angular/material/divider'; @@ -16,8 +17,9 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { LlmApiService } from '../core/services/llm-api.service'; +import { LlmConfigService } from '../core/services/llm-config.service'; import { LlmSessionService } from './llm-session.service'; -import { SiteStructure, GenerationVersion } from '../core/models/llm'; +import { SiteStructure, GenerationVersion, LLMKeyStatus } from '../core/models/llm'; import { PreviewDialogComponent } from './preview-dialog/preview-dialog.component'; import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dialog'; @@ -27,10 +29,14 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial imports: [ NgIf, NgFor, AsyncPipe, KeyValuePipe, DatePipe, FormsModule, RouterModule, MatToolbarModule, MatButtonModule, MatIconModule, - MatInputModule, MatFormFieldModule, MatProgressSpinnerModule, + MatInputModule, MatFormFieldModule, MatProgressSpinnerModule, MatSelectModule, MatSnackBarModule, MatCardModule, MatDividerModule, MatExpansionModule, MatDialogModule, ], + styles: [` + .provider-select { width: 140px; } + .provider-select .mat-mdc-form-field-subscript-wrapper { display: none; } + `], template: `
@@ -45,6 +51,8 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial + +
@@ -64,15 +72,29 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial Describe the pages, layout, theme, and content you want. - +
+ + Provider + + {{ p }} + + + {{ configuredProviders[0] }} + +
+
+

+ No API keys configured. Go to Settings to add an API key for an AI provider. +

+
@@ -180,7 +202,14 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial [disabled]="(session.generating$ | async) === true" > -
+
+ + Provider + + {{ p }} + + + {{ configuredProviders[0] }} +
+
+ + API Key + + + + + Base URL (optional) + + For OpenAI-compatible providers like DeepSeek. Leave empty for OpenAI. + + + Model (optional) + + Override the default model name for this provider. + +
+ +
+
+
+ + +
@@ -249,6 +310,7 @@ export class SettingsComponent implements OnInit, OnDestroy { private router = inject(Router); private siteService = inject(SiteService); private deploymentService = inject(DeploymentService); + private llmConfigService = inject(LlmConfigService); private snackBar = inject(MatSnackBar); siteId: string | null = null; @@ -278,6 +340,14 @@ export class SettingsComponent implements OnInit, OnDestroy { shDeployedAt: string | null = null; shSaving = false; + llmProviders = ['gemini', 'openai']; + llmKeyStatus: LLMKeyStatus = {}; + llmNewKeys: Record = {}; + llmNewBaseUrls: Record = {}; + llmNewModels: Record = {}; + llmSaving: Record = {}; + llmKeyVisible: Record = {}; + private destroy$ = new Subject(); ngOnInit(): void { @@ -285,7 +355,10 @@ export class SettingsComponent implements OnInit, OnDestroy { .pipe(takeUntil(this.destroy$)) .subscribe(params => { this.siteId = params.get('siteId'); - if (this.siteId) this.loadSite(); + if (this.siteId) { + this.loadSite(); + this.loadLlmConfig(); + } }); } @@ -345,6 +418,73 @@ export class SettingsComponent implements OnInit, OnDestroy { }); } + private loadLlmConfig(): void { + this.llmConfigService.getStatus() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (status) => { + this.llmKeyStatus = status; + for (const p of this.llmProviders) { + if (status[p]?.baseUrl) { + this.llmNewBaseUrls[p] = status[p].baseUrl!; + } + if (status[p]?.model) { + this.llmNewModels[p] = status[p].model!; + } + } + }, + error: () => {}, + }); + } + + saveLlmKey(provider: string): void { + const key = this.llmNewKeys[provider]?.trim(); + const baseUrl = this.llmNewBaseUrls[provider]?.trim() || undefined; + const model = this.llmNewModels[provider]?.trim() || undefined; + if (!key && !baseUrl && !model) return; + this.llmSaving[provider] = true; + this.llmConfigService.saveKey(provider, key, baseUrl, model) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.llmSaving[provider] = false; + const entry: LLMKeyEntry = { configured: true }; + if (baseUrl) entry.baseUrl = baseUrl; + if (model) entry.model = model; + this.llmKeyStatus[provider] = entry; + this.llmNewKeys[provider] = ''; + this.snackBar.open(`${provider} config saved`, 'Close', { duration: 2000 }); + }, + error: () => { + this.llmSaving[provider] = false; + this.snackBar.open(`Failed to save ${provider} API key`, 'Close', { duration: 3000 }); + }, + }); + } + + deleteLlmKey(provider: string): void { + this.llmSaving[provider] = true; + this.llmConfigService.deleteKey(provider) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: () => { + this.llmSaving[provider] = false; + this.llmKeyStatus[provider] = { configured: false }; + this.llmNewBaseUrls[provider] = ''; + this.llmNewModels[provider] = ''; + this.snackBar.open(`${provider} API key removed`, 'Close', { duration: 2000 }); + }, + error: () => { + this.llmSaving[provider] = false; + this.snackBar.open(`Failed to remove ${provider} API key`, 'Close', { duration: 3000 }); + }, + }); + } + + toggleKeyVisible(provider: string): void { + this.llmKeyVisible[provider] = !this.llmKeyVisible[provider]; + } + onDeploymentTargetChange(): void { if (!this.siteId) return; this.saving = true;