add bring your own key support
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
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<List<Reference>>() {});
|
||||
}
|
||||
|
||||
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}")
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
12
backend/src/main/java/com/krrishg/dto/LLMConfigRequest.java
Normal file
12
backend/src/main/java/com/krrishg/dto/LLMConfigRequest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
Reference in New Issue
Block a user