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

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) {}
}