add bring your own key support

This commit is contained in:
Krrish Ghimire
2026-07-06 22:45:24 +05:45
parent a6a9ac6e7e
commit 25db5b2970
23 changed files with 950 additions and 353 deletions

View File

@@ -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);
}
}
}

View File

@@ -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<Map<String, String>, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public String convertToDatabaseColumn(Map<String, String> 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<String, String> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) {
return Map.of();
}
try {
return MAPPER.readValue(dbData, new TypeReference<Map<String, String>>() {});
} catch (Exception e) {
throw new RuntimeException("Failed to deserialize JSON to map", e);
}
}
}

View File

@@ -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<Reference> references = null;
if (request.containsKey("references")) {
references = objectMapper.convertValue(
request.get("references"), new TypeReference<List<Reference>>() {});
}
SiteStructure structure = llmService.generateSite(principal.id(), siteId, prompt, references);
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<List<Reference>>() {});
}
try {
SiteStructure structure = llmService.refineSite(principal.id(), siteId,
currentStructure, prompt, references);
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}")

View File

@@ -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<Map<String, Object>> getLlmConfig(
@AuthenticationPrincipal UserPrincipal principal) {
User user = userRepository.findById(principal.id())
.orElseThrow(() -> new IllegalArgumentException("User not found"));
Map<String, String> keys = user.getLlmApiKeys();
Map<String, String> urls = user.getLlmBaseUrls();
Map<String, String> models = user.getLlmModels();
Map<String, Object> result = new HashMap<>();
if (keys != null) {
for (String provider : keys.keySet()) {
Map<String, Object> 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<Void> 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<String, String> 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<String, String> 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<String, String> 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<Void> deleteLlmConfig(
@AuthenticationPrincipal UserPrincipal principal,
@PathVariable String provider) {
User user = userRepository.findById(principal.id())
.orElseThrow(() -> new IllegalArgumentException("User not found"));
Map<String, String> keys = new HashMap<>();
if (user.getLlmApiKeys() != null) {
keys.putAll(user.getLlmApiKeys());
}
keys.remove(provider);
user.setLlmApiKeys(keys);
Map<String, String> urls = new HashMap<>();
if (user.getLlmBaseUrls() != null) {
urls.putAll(user.getLlmBaseUrls());
}
urls.remove(provider);
user.setLlmBaseUrls(urls);
Map<String, String> models = new HashMap<>();
if (user.getLlmModels() != null) {
models.putAll(user.getLlmModels());
}
models.remove(provider);
user.setLlmModels(models);
userRepository.save(user);
return ResponseEntity.ok().build();
}
}

View File

@@ -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);
}
}

View File

@@ -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<String, String> llmApiKeys;
@Convert(converter = MapToJsonConverter.class)
@Column(name = "llm_base_urls", columnDefinition = "TEXT")
private Map<String, String> llmBaseUrls;
@Convert(converter = MapToJsonConverter.class)
@Column(name = "llm_models", columnDefinition = "TEXT")
private Map<String, String> llmModels;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

View File

@@ -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<Reference> references) {
public SiteStructure generateSite(UUID userId, UUID siteId, String prompt,
List<Reference> 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<Reference> 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<Reference> 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<Reference> references) {
String refinementPrompt, List<Reference> 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<Reference> 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<Reference> 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<Reference> 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<String, String> 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<String, String> 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<String, String> 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<Reference> 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);
}
}

View File

@@ -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

View File

@@ -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<LLMProvider> providers;
private final String activeProvider;
private final String fallbackProvider;
private Map<String, LLMProvider> providerMap;
public LLMFactory(List<LLMProvider> 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<LLMProvider> getFallbackProvider() {
return getProvider(fallbackProvider);
}
private Optional<LLMProvider> getProvider(String name) {
return Optional.ofNullable(providerMap.get(name));
}
}

View File

@@ -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

View File

@@ -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<String, ProviderConfig> 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<String> getAvailableProviders() {
return List.copyOf(providerConfigs.keySet());
}
private record ProviderConfig(String model, String url) {}
}

View File

@@ -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

View File

@@ -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<String, Object> 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<String, Object> 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("<h1>Home</h1>")
.build()
))
.build();
when(llmService.generateSite(any(), any(), anyString(), any(), eq("gemini"), eq("custom-key"), any(), any())).thenReturn(structure);
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> request = Map.of(
"prompt", "Make it better",
"siteId", UUID.randomUUID().toString(),
"provider", "gemini",
"currentStructure", current
);
@@ -247,6 +269,7 @@ class LLMControllerTest {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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

View File

@@ -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": "<h1>Welcome</h1>"}
@@ -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": "<p>About</p>"}
@@ -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<GenerationVersion> 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": "<p>Hero</p>"}
@@ -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<GenerationVersion> 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": "<p>Hero</p>"}
@@ -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<GenerationVersion> 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": "<p>Hero</p>"}
{"title": "Home", "slug": "home", "rawHtml": "<p>Hero</p>"}
]
}
""");
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": "<p>Hero</p>"}
@@ -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));

View File

@@ -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();
}

View File

@@ -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<LLMProvider> 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);
}
}

View File

@@ -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();
}

View File

@@ -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));
}
}

View File

@@ -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<String, FakeLLMProvider> 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<String> getAvailableProviders() {
return List.copyOf(providers.keySet());
}
}

View File

@@ -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<string, LLMKeyEntry>;

View File

@@ -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<LLMKeyStatus> {
return this.http.get<LLMKeyStatus>('/api/user/llm-config');
}
saveKey(provider: string, apiKey?: string, baseUrl?: string, model?: string): Observable<void> {
return this.http.put<void>('/api/user/llm-config', { provider, apiKey, baseUrl, model });
}
deleteKey(provider: string): Observable<void> {
return this.http.delete<void>(`/api/user/llm-config/${provider}`);
}
}

View File

@@ -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: `
<div class="h-screen flex flex-col bg-gray-50">
<mat-toolbar color="primary" class="h-14 flex items-center justify-between px-4">
@@ -45,6 +51,8 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
</button>
</mat-toolbar>
<div class="flex-1 overflow-y-auto">
<div class="px-6 py-6 space-y-6">
@@ -64,16 +72,30 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
<span class="text-xs text-gray-400">
Describe the pages, layout, theme, and content you want.
</span>
<div class="flex items-center gap-2">
<mat-form-field appearance="outline" class="!mb-0 provider-select" *ngIf="configuredProviders.length > 1">
<mat-label>Provider</mat-label>
<mat-select [(ngModel)]="selectedProvider" class="text-sm">
<mat-option *ngFor="let p of configuredProviders" [value]="p" class="capitalize">{{ p }}</mat-option>
</mat-select>
</mat-form-field>
<span *ngIf="configuredProviders.length === 1" class="text-xs text-gray-500 capitalize mr-1">{{ configuredProviders[0] }}</span>
<button
mat-raised-button
color="primary"
[disabled]="!prompt.trim() || (session.generating$ | async)"
[disabled]="!prompt.trim() || (session.generating$ | async) || configuredProviders.length === 0"
(click)="generate()"
>
<mat-icon>auto_awesome</mat-icon>
Generate
</button>
</div>
<div *ngIf="configuredProviders.length === 0 && !(session.generating$ | async)" class="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p class="text-sm text-yellow-800">
No API keys configured. Go to <a class="underline font-medium cursor-pointer" (click)="goToSettings()">Settings</a> to add an API key for an AI provider.
</p>
</div>
</div>
</mat-card-content>
</mat-card>
@@ -180,7 +202,14 @@ import { DeleteVersionDialog } from './delete-version-dialog/delete-version-dial
[disabled]="(session.generating$ | async) === true"
></textarea>
</mat-form-field>
<div class="flex justify-end mt-2">
<div class="flex justify-end items-center gap-2 mt-2">
<mat-form-field appearance="outline" class="!mb-0 provider-select" *ngIf="configuredProviders.length > 1">
<mat-label>Provider</mat-label>
<mat-select [(ngModel)]="selectedProvider" class="text-sm">
<mat-option *ngFor="let p of configuredProviders" [value]="p" class="capitalize">{{ p }}</mat-option>
</mat-select>
</mat-form-field>
<span *ngIf="configuredProviders.length === 1" class="text-xs text-gray-500 capitalize mr-1">{{ configuredProviders[0] }}</span>
<button
mat-raised-button
color="accent"
@@ -261,6 +290,7 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private router = inject(Router);
private api = inject(LlmApiService);
private llmConfigService = inject(LlmConfigService);
private snackBar = inject(MatSnackBar);
private dialog = inject(MatDialog);
session = inject(LlmSessionService);
@@ -270,6 +300,11 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
showRefine = false;
siteId: string | null = null;
versions: GenerationVersion[] = [];
llmKeyStatus: LLMKeyStatus = {};
llmBaseUrls: Record<string, string> = {};
llmModels: Record<string, string> = {};
configuredProviders: string[] = [];
selectedProvider = '';
private destroy$ = new Subject<void>();
ngOnInit(): void {
@@ -285,10 +320,36 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
this.versions = [];
this.session.clearHistory();
this.loadVersions();
this.loadLlmConfig();
}
});
}
private loadLlmConfig(): void {
this.llmConfigService.getStatus()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (status) => {
this.llmKeyStatus = status;
const urls: Record<string, string> = {};
const models: Record<string, string> = {};
this.configuredProviders = Object.keys(status).filter(k => {
if (status[k]?.baseUrl) urls[k] = status[k].baseUrl!;
if (status[k]?.model) models[k] = status[k].model!;
return status[k]?.configured;
});
this.llmBaseUrls = urls;
this.llmModels = models;
if (this.configuredProviders.length === 1) {
this.selectedProvider = this.configuredProviders[0];
} else if (this.configuredProviders.length > 1 && !this.selectedProvider) {
this.selectedProvider = this.configuredProviders[0];
}
},
error: () => {},
});
}
private loadVersions(): void {
if (!this.siteId) return;
this.session.setLoadingVersions(true);
@@ -320,13 +381,21 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
generate(): void {
if (!this.prompt.trim() || !this.siteId) return;
if (!this.prompt.trim() || !this.siteId || !this.selectedProvider) return;
this.showRefine = false;
this.session.setPrompt(this.prompt);
this.session.setGenerating(true);
this.session.clearError();
this.api.generate({ prompt: this.prompt, siteId: this.siteId })
const baseUrl = this.llmBaseUrls[this.selectedProvider];
const model = this.llmModels[this.selectedProvider];
this.api.generate({
prompt: this.prompt,
siteId: this.siteId,
provider: this.selectedProvider,
baseUrl,
model,
})
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (data) => {
@@ -344,13 +413,18 @@ export class LlmWorkspaceComponent implements OnInit, OnDestroy {
refine(): void {
const result = this.session.currentResult;
if (!result || !this.refinePrompt.trim() || !this.siteId) return;
if (!result || !this.refinePrompt.trim() || !this.siteId || !this.selectedProvider) return;
this.session.setGenerating(true);
this.session.clearError();
const baseUrl = this.llmBaseUrls[this.selectedProvider];
const model = this.llmModels[this.selectedProvider];
this.api.refine({
prompt: this.refinePrompt,
siteId: this.siteId,
provider: this.selectedProvider,
baseUrl,
model,
currentStructure: result,
})
.pipe(takeUntil(this.destroy$))

View File

@@ -17,6 +17,8 @@ import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { SiteService } from '../../core/services/site.service';
import { DeploymentService } from '../../core/services/deployment.service';
import { LlmConfigService } from '../../core/services/llm-config.service';
import { LLMKeyEntry, LLMKeyStatus } from '../../core/models/llm';
import { SelfHostedConfigResponse } from '../../core/models/deployment';
@Component({
@@ -224,6 +226,65 @@ import { SelfHostedConfigResponse } from '../../core/models/deployment';
</mat-card-content>
</mat-card>
<mat-card class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<h4 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-4">LLM API Keys</h4>
<p class="text-sm text-gray-600 mb-3">
Add API keys for AI providers to enable site generation. Keys are encrypted at rest.
</p>
<div class="space-y-4">
<div *ngFor="let provider of llmProviders" class="border rounded-lg p-3">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="w-3 h-3 rounded-full shrink-0"
[class.bg-green-500]="llmKeyStatus[provider]?.configured"
[class.bg-gray-300]="!llmKeyStatus[provider]?.configured"></span>
<span class="text-sm font-medium capitalize">{{ provider }}</span>
</div>
<div class="flex gap-2">
<button mat-stroked-button color="warn" *ngIf="llmKeyStatus[provider]?.configured"
(click)="deleteLlmKey(provider)" class="!text-xs !min-w-0 !px-2">
<mat-icon>delete</mat-icon>
Remove
</button>
</div>
</div>
<mat-form-field appearance="outline" class="w-full">
<mat-label>API Key</mat-label>
<input matInput [type]="llmKeyVisible[provider] ? 'text' : 'password'"
[(ngModel)]="llmNewKeys[provider]" [disabled]="llmSaving[provider]" />
<button mat-icon-button matSuffix (click)="toggleKeyVisible(provider)"
[attr.aria-label]="llmKeyVisible[provider] ? 'Hide key' : 'Show key'">
<mat-icon>{{ llmKeyVisible[provider] ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full !mb-4" *ngIf="provider === 'openai'">
<mat-label>Base URL (optional)</mat-label>
<input matInput
placeholder="https://api.deepseek.com/v1"
[(ngModel)]="llmNewBaseUrls[provider]" [disabled]="llmSaving[provider]" />
<mat-hint>For OpenAI-compatible providers like DeepSeek. Leave empty for OpenAI.</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="w-full">
<mat-label>Model (optional)</mat-label>
<input matInput
placeholder="{{ provider === 'gemini' ? 'gemini-3.1-flash-lite' : 'gpt-4o-mini' }}"
[(ngModel)]="llmNewModels[provider]" [disabled]="llmSaving[provider]" />
<mat-hint>Override the default model name for this provider.</mat-hint>
</mat-form-field>
<div class="flex justify-end">
<button mat-flat-button color="primary"
[disabled]="(!llmNewKeys[provider] || !llmNewKeys[provider].trim()) && (!llmNewBaseUrls[provider] || !llmNewBaseUrls[provider].trim()) && (!llmNewModels[provider] || !llmNewModels[provider].trim()) || llmSaving[provider]"
(click)="saveLlmKey(provider)">
<mat-icon>save</mat-icon>
{{ llmSaving[provider] ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card class="p-4" appearance="outlined">
<mat-card-content class="!p-0">
<div class="flex items-center justify-between">
@@ -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<string, string> = {};
llmNewBaseUrls: Record<string, string> = {};
llmNewModels: Record<string, string> = {};
llmSaving: Record<string, boolean> = {};
llmKeyVisible: Record<string, boolean> = {};
private destroy$ = new Subject<void>();
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;